234 lines
6.5 KiB
Go
234 lines
6.5 KiB
Go
package licensing
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"regexp"
|
|
"time"
|
|
|
|
"softbox.local/core/internal/canonicaljson"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidLicense = errors.New("license is invalid")
|
|
ErrDuplicateField = errors.New("license has duplicate field")
|
|
ErrUnsupportedNumber = errors.New("license has unsupported number")
|
|
ErrSignatureMissing = errors.New("license signature is missing")
|
|
ErrSignatureInvalid = errors.New("license signature is invalid")
|
|
ErrPublicKeyInvalid = errors.New("license public key is invalid")
|
|
ErrMachineHashInvalid = errors.New("machine hash is invalid")
|
|
ErrMachineMismatch = errors.New("license machine does not match")
|
|
)
|
|
|
|
var (
|
|
licenseIDPattern = regexp.MustCompile(`^lic-[a-z0-9][a-z0-9-]{0,59}$`)
|
|
machineHashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
productIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
|
policyIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]{0,127}$`)
|
|
timestampLayout = "2006-01-02T15:04:05Z"
|
|
licenseFields = map[string]struct{}{
|
|
"schema_version": {},
|
|
"license_id": {},
|
|
"machine_hash": {},
|
|
"products": {},
|
|
"issued_at": {},
|
|
"perpetual": {},
|
|
"update_policy": {},
|
|
"rebind_policy": {},
|
|
"signature": {},
|
|
}
|
|
)
|
|
|
|
// Verifier validates License v1 documents using one copied Ed25519 public key.
|
|
type Verifier struct {
|
|
publicKey ed25519.PublicKey
|
|
}
|
|
|
|
// NewVerifier copies and validates a License v1 signing public key.
|
|
func NewVerifier(publicKey []byte) (Verifier, error) {
|
|
if len(publicKey) != ed25519.PublicKeySize {
|
|
return Verifier{}, ErrPublicKeyInvalid
|
|
}
|
|
return Verifier{publicKey: append(ed25519.PublicKey(nil), publicKey...)}, nil
|
|
}
|
|
|
|
// Verify validates one License v1 document and binds it to expectedMachineHash.
|
|
func (verifier Verifier) Verify(document []byte, expectedMachineHash string) (License, error) {
|
|
if len(verifier.publicKey) != ed25519.PublicKeySize {
|
|
return License{}, ErrPublicKeyInvalid
|
|
}
|
|
if !machineHashPattern.MatchString(expectedMachineHash) {
|
|
return License{}, ErrMachineHashInvalid
|
|
}
|
|
|
|
rootValue, err := canonicaljson.Parse(document)
|
|
if err != nil {
|
|
return License{}, licenseCanonicalError(err)
|
|
}
|
|
root, ok := rootValue.(map[string]any)
|
|
if !ok {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
signatureValue, exists := root["signature"]
|
|
if !exists {
|
|
return License{}, ErrSignatureMissing
|
|
}
|
|
signatureText, ok := signatureValue.(string)
|
|
if !ok {
|
|
return License{}, ErrSignatureInvalid
|
|
}
|
|
hasExactFields := hasExactLicenseShape(root)
|
|
delete(root, "signature")
|
|
|
|
signedPayload, err := canonicaljson.Marshal(root)
|
|
if err != nil {
|
|
return License{}, licenseCanonicalError(err)
|
|
}
|
|
signature, err := decodeCanonicalSignature(signatureText)
|
|
if err != nil || !ed25519.Verify(verifier.publicKey, signedPayload, signature) {
|
|
return License{}, ErrSignatureInvalid
|
|
}
|
|
if !hasExactFields {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
|
|
wire, err := decodeLicense(document)
|
|
if err != nil {
|
|
return License{}, err
|
|
}
|
|
license, err := validateLicense(wire)
|
|
if err != nil {
|
|
return License{}, err
|
|
}
|
|
if license.MachineHash != expectedMachineHash {
|
|
return License{}, ErrMachineMismatch
|
|
}
|
|
return license, nil
|
|
}
|
|
|
|
func hasExactLicenseShape(root map[string]any) bool {
|
|
if len(root) != len(licenseFields) {
|
|
return false
|
|
}
|
|
for field := range licenseFields {
|
|
if _, exists := root[field]; !exists {
|
|
return false
|
|
}
|
|
}
|
|
if _, ok := root["schema_version"].(json.Number); !ok {
|
|
return false
|
|
}
|
|
if _, ok := root["perpetual"].(bool); !ok {
|
|
return false
|
|
}
|
|
if _, ok := root["products"].([]any); !ok {
|
|
return false
|
|
}
|
|
for _, field := range []string{
|
|
"license_id",
|
|
"machine_hash",
|
|
"issued_at",
|
|
"update_policy",
|
|
"rebind_policy",
|
|
"signature",
|
|
} {
|
|
if _, ok := root[field].(string); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type licenseWire struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
LicenseID string `json:"license_id"`
|
|
MachineHash string `json:"machine_hash"`
|
|
Products []string `json:"products"`
|
|
IssuedAt string `json:"issued_at"`
|
|
Perpetual bool `json:"perpetual"`
|
|
UpdatePolicy string `json:"update_policy"`
|
|
RebindPolicy string `json:"rebind_policy"`
|
|
Signature string `json:"signature"`
|
|
}
|
|
|
|
func decodeLicense(document []byte) (licenseWire, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(document))
|
|
decoder.DisallowUnknownFields()
|
|
decoder.UseNumber()
|
|
var wire licenseWire
|
|
if err := decoder.Decode(&wire); err != nil {
|
|
return licenseWire{}, ErrInvalidLicense
|
|
}
|
|
if err := consumeLicenseEOF(decoder); err != nil {
|
|
return licenseWire{}, err
|
|
}
|
|
return wire, nil
|
|
}
|
|
|
|
func consumeLicenseEOF(decoder *json.Decoder) error {
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
return ErrInvalidLicense
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateLicense(wire licenseWire) (License, error) {
|
|
if wire.SchemaVersion != 1 || !licenseIDPattern.MatchString(wire.LicenseID) ||
|
|
!machineHashPattern.MatchString(wire.MachineHash) || !policyIDPattern.MatchString(wire.UpdatePolicy) ||
|
|
!policyIDPattern.MatchString(wire.RebindPolicy) || wire.Signature == "" {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
issuedAt, err := time.Parse(timestampLayout, wire.IssuedAt)
|
|
if err != nil || issuedAt.Format(timestampLayout) != wire.IssuedAt {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
if len(wire.Products) == 0 {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
products := make([]string, len(wire.Products))
|
|
seenProducts := make(map[string]struct{}, len(wire.Products))
|
|
for index, product := range wire.Products {
|
|
if !productIDPattern.MatchString(product) {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
if _, exists := seenProducts[product]; exists {
|
|
return License{}, ErrInvalidLicense
|
|
}
|
|
seenProducts[product] = struct{}{}
|
|
products[index] = product
|
|
}
|
|
return License{
|
|
LicenseID: wire.LicenseID,
|
|
MachineHash: wire.MachineHash,
|
|
Products: products,
|
|
IssuedAt: issuedAt,
|
|
Perpetual: wire.Perpetual,
|
|
UpdatePolicy: wire.UpdatePolicy,
|
|
RebindPolicy: wire.RebindPolicy,
|
|
}, nil
|
|
}
|
|
|
|
func decodeCanonicalSignature(value string) ([]byte, error) {
|
|
signature, err := base64.StdEncoding.Strict().DecodeString(value)
|
|
if err != nil || base64.StdEncoding.EncodeToString(signature) != value || len(signature) != ed25519.SignatureSize {
|
|
return nil, ErrSignatureInvalid
|
|
}
|
|
return signature, nil
|
|
}
|
|
|
|
func licenseCanonicalError(err error) error {
|
|
switch {
|
|
case errors.Is(err, canonicaljson.ErrDuplicateField):
|
|
return ErrDuplicateField
|
|
case errors.Is(err, canonicaljson.ErrUnsupportedNumber):
|
|
return ErrUnsupportedNumber
|
|
default:
|
|
return ErrInvalidLicense
|
|
}
|
|
}
|