Implement offline license verification (T-502)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user