Implement offline license verification (T-502)

This commit is contained in:
ila
2026-07-20 01:29:40 +08:00
parent c51ff30c54
commit cd76f7f900
12 changed files with 887 additions and 239 deletions
+25
View File
@@ -0,0 +1,25 @@
package licensing
import "time"
// License is a verified License v1 document. It never contains the source
// document, signature, public key, or raw machine identifiers.
type License struct {
LicenseID string
MachineHash string
Products []string
IssuedAt time.Time
Perpetual bool
UpdatePolicy string
RebindPolicy string
}
// AuthorizesProduct reports whether this verified license lists productID.
func (license License) AuthorizesProduct(productID string) bool {
for _, licensedProduct := range license.Products {
if licensedProduct == productID {
return true
}
}
return false
}
+233
View File
@@ -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
}
}
+233
View File
@@ -0,0 +1,233 @@
package licensing
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"softbox.local/core/internal/canonicaljson"
)
type licenseVectorCorpus struct {
SchemaVersion int `json:"schema_version"`
PublicKeyBase64 string `json:"public_key_base64"`
Vectors []licenseVector `json:"vectors"`
}
type licenseVector struct {
Name string `json:"name"`
Document string `json:"document"`
SignedPayloadBase64 string `json:"signed_payload_base64"`
Signature string `json:"signature"`
ExpectedMachineHash string `json:"expected_machine_hash"`
ExpectedProducts []string `json:"expected_products"`
WantError string `json:"want_error"`
}
func TestVerifierStaticCorpus(t *testing.T) {
corpus := readLicenseVectorCorpus(t)
publicKey := corpusPublicKey(t, corpus)
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
valid := corpusValidVector(t, corpus)
for _, vector := range corpus.Vectors {
vector := vector
t.Run(vector.Name, func(t *testing.T) {
license, err := verifier.Verify([]byte(vector.Document), valid.ExpectedMachineHash)
if vector.WantError != "" {
want := licenseVectorError(t, vector.WantError)
if !errors.Is(err, want) {
t.Fatalf("Verify() error = %v, want %v", err, want)
}
return
}
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
if !reflect.DeepEqual(license.Products, vector.ExpectedProducts) {
t.Fatalf("Products = %q, want %q", license.Products, vector.ExpectedProducts)
}
if !license.AuthorizesProduct("product-json-parser") || license.AuthorizesProduct("other-product") {
t.Fatal("AuthorizesProduct() did not preserve the verified product set")
}
})
}
expectedPayload, err := base64.StdEncoding.DecodeString(valid.SignedPayloadBase64)
if err != nil {
t.Fatalf("decode signed payload: %v", err)
}
payload := canonicalLicensePayload(t, []byte(valid.Document))
if !bytes.Equal(payload, expectedPayload) {
t.Fatalf("canonical signing bytes = %q, want static corpus %q", payload, expectedPayload)
}
signature, err := base64.StdEncoding.DecodeString(valid.Signature)
if err != nil {
t.Fatalf("decode signature: %v", err)
}
if !ed25519.Verify(publicKey, expectedPayload, signature) {
t.Fatal("static public key does not verify static signing bytes")
}
}
func TestVerifierCanonicalWhitespaceAndKeyOrder(t *testing.T) {
corpus := readLicenseVectorCorpus(t)
publicKey := corpusPublicKey(t, corpus)
valid := corpusValidVector(t, corpus)
var wire licenseWire
if err := json.Unmarshal([]byte(valid.Document), &wire); err != nil {
t.Fatalf("decode static document: %v", err)
}
equivalent := ` { "update_policy" : "` + wire.UpdatePolicy + `", "signature" : "` + wire.Signature + `", "schema_version" : 1, "rebind_policy" : "` + wire.RebindPolicy + `", "products" : [ "` + wire.Products[0] + `", "` + wire.Products[1] + `" ], "perpetual" : true, "machine_hash" : "` + wire.MachineHash + `", "license_id" : "` + wire.LicenseID + `", "issued_at" : "` + wire.IssuedAt + `" } `
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
license, err := verifier.Verify([]byte(equivalent), valid.ExpectedMachineHash)
if err != nil {
t.Fatalf("Verify(equivalent) error = %v", err)
}
if !reflect.DeepEqual(license.Products, valid.ExpectedProducts) {
t.Fatalf("equivalent products = %q", license.Products)
}
}
func TestVerifierFailsClosed(t *testing.T) {
corpus := readLicenseVectorCorpus(t)
publicKey := corpusPublicKey(t, corpus)
valid := corpusValidVector(t, corpus)
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
tests := []struct {
name string
document []byte
hash string
wantErr error
}{
{name: "wrong machine", document: []byte(valid.Document), hash: strings.Repeat("f", 64), wantErr: ErrMachineMismatch},
{name: "invalid expected hash", document: []byte(valid.Document), hash: strings.ToUpper(valid.ExpectedMachineHash), wantErr: ErrMachineHashInvalid},
{name: "tampered", document: bytes.Replace([]byte(valid.Document), []byte("toolkit"), []byte("toolkitz"), 1), hash: valid.ExpectedMachineHash, wantErr: ErrSignatureInvalid},
{name: "duplicate field", document: []byte(`{"schema_version":1,"schema_version":1,"signature":"x"}`), hash: valid.ExpectedMachineHash, wantErr: ErrDuplicateField},
{name: "fractional number", document: []byte(`{"schema_version":1.5,"signature":"x"}`), hash: valid.ExpectedMachineHash, wantErr: ErrUnsupportedNumber},
{name: "invalid signature encoding", document: []byte(strings.Replace(valid.Document, `=="`, `"`, 1)), hash: valid.ExpectedMachineHash, wantErr: ErrSignatureInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := verifier.Verify(test.document, test.hash)
if !errors.Is(err, test.wantErr) {
t.Fatalf("Verify() error = %v, want %v", err, test.wantErr)
}
})
}
}
func TestVerifierCopiesPublicKeyAndProducts(t *testing.T) {
corpus := readLicenseVectorCorpus(t)
publicKey := corpusPublicKey(t, corpus)
valid := corpusValidVector(t, corpus)
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
publicKey[0] ^= 0xff
first, err := verifier.Verify([]byte(valid.Document), valid.ExpectedMachineHash)
if err != nil {
t.Fatalf("Verify(first) error = %v", err)
}
second, err := verifier.Verify([]byte(valid.Document), valid.ExpectedMachineHash)
if err != nil {
t.Fatalf("Verify(second) error = %v", err)
}
first.Products[0] = "changed"
if !second.AuthorizesProduct("product-json-parser") || second.AuthorizesProduct("changed") {
t.Fatal("Verify() returned products sharing mutable state")
}
if _, err := NewVerifier(make([]byte, ed25519.PublicKeySize-1)); !errors.Is(err, ErrPublicKeyInvalid) {
t.Fatalf("NewVerifier(short key) error = %v, want ErrPublicKeyInvalid", err)
}
var unconfigured Verifier
if _, err := unconfigured.Verify([]byte(valid.Document), valid.ExpectedMachineHash); !errors.Is(err, ErrPublicKeyInvalid) {
t.Fatalf("zero Verifier.Verify() error = %v, want ErrPublicKeyInvalid", err)
}
}
func readLicenseVectorCorpus(t *testing.T) licenseVectorCorpus {
t.Helper()
path := filepath.Join("..", "..", "testdata", "license", "license-v1-vectors.json")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read license vector corpus: %v", err)
}
var corpus licenseVectorCorpus
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&corpus); err != nil {
t.Fatalf("decode license vector corpus: %v", err)
}
if corpus.SchemaVersion != 1 || len(corpus.Vectors) == 0 {
t.Fatal("license vector corpus is incomplete")
}
return corpus
}
func corpusPublicKey(t *testing.T, corpus licenseVectorCorpus) ed25519.PublicKey {
t.Helper()
publicKey, err := base64.StdEncoding.DecodeString(corpus.PublicKeyBase64)
if err != nil || len(publicKey) != ed25519.PublicKeySize {
t.Fatal("license vector corpus has an invalid public key")
}
return ed25519.PublicKey(publicKey)
}
func corpusValidVector(t *testing.T, corpus licenseVectorCorpus) licenseVector {
t.Helper()
for _, vector := range corpus.Vectors {
if vector.Name == "valid" {
return vector
}
}
t.Fatal("license vector corpus has no valid vector")
return licenseVector{}
}
func licenseVectorError(t *testing.T, value string) error {
t.Helper()
switch value {
case "invalid_license":
return ErrInvalidLicense
default:
t.Fatalf("unsupported license vector want_error %q", value)
return nil
}
}
func canonicalLicensePayload(t *testing.T, document []byte) []byte {
t.Helper()
value, err := canonicaljson.Parse(document)
if err != nil {
t.Fatalf("parse license document: %v", err)
}
root, ok := value.(map[string]any)
if !ok {
t.Fatal("license document root is not an object")
}
delete(root, "signature")
payload, err := canonicaljson.Marshal(root)
if err != nil {
t.Fatalf("canonicalize license document: %v", err)
}
return payload
}