Implement offline license verification (T-502)
This commit is contained in:
+17
-226
@@ -1,241 +1,32 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"softbox.local/core/internal/canonicaljson"
|
||||
)
|
||||
|
||||
var integerJSONNumber = regexp.MustCompile(`^(0|[1-9][0-9]*|-[1-9][0-9]*)$`)
|
||||
|
||||
func parseRestrictedJSON(data []byte) (any, error) {
|
||||
if !utf8.Valid(data) {
|
||||
return nil, fmt.Errorf("%w: input is not valid UTF-8", ErrInvalidDocument)
|
||||
}
|
||||
if err := validateJSONStringSurrogates(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
|
||||
value, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: trailing JSON value", ErrInvalidDocument)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: trailing data: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateJSONStringSurrogates(data []byte) error {
|
||||
for index := 0; index < len(data); index++ {
|
||||
if data[index] != '"' {
|
||||
continue
|
||||
}
|
||||
next, err := scanJSONStringSurrogates(data, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index = next - 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanJSONStringSurrogates(data []byte, start int) (int, error) {
|
||||
for index := start + 1; index < len(data); index++ {
|
||||
switch data[index] {
|
||||
case '"':
|
||||
return index + 1, nil
|
||||
case '\\':
|
||||
if index+1 >= len(data) {
|
||||
return 0, fmt.Errorf("%w: incomplete string escape", ErrInvalidDocument)
|
||||
}
|
||||
if data[index+1] != 'u' {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
codeUnit, ok := decodeJSONHexCodeUnit(data, index+2)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: invalid unicode escape", ErrInvalidDocument)
|
||||
}
|
||||
switch {
|
||||
case codeUnit >= 0xd800 && codeUnit <= 0xdbff:
|
||||
if index+7 >= len(data) || data[index+6] != '\\' || data[index+7] != 'u' {
|
||||
return 0, fmt.Errorf("%w: high surrogate is not paired", ErrInvalidDocument)
|
||||
}
|
||||
lowSurrogate, ok := decodeJSONHexCodeUnit(data, index+8)
|
||||
if !ok || lowSurrogate < 0xdc00 || lowSurrogate > 0xdfff {
|
||||
return 0, fmt.Errorf("%w: high surrogate is not followed by a low surrogate", ErrInvalidDocument)
|
||||
}
|
||||
index += 11
|
||||
case codeUnit >= 0xdc00 && codeUnit <= 0xdfff:
|
||||
return 0, fmt.Errorf("%w: low surrogate has no high surrogate", ErrInvalidDocument)
|
||||
default:
|
||||
index += 5
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: unterminated string", ErrInvalidDocument)
|
||||
}
|
||||
|
||||
func decodeJSONHexCodeUnit(data []byte, start int) (uint16, bool) {
|
||||
if start+4 > len(data) {
|
||||
return 0, false
|
||||
}
|
||||
var value uint16
|
||||
for _, digit := range data[start : start+4] {
|
||||
value <<= 4
|
||||
switch {
|
||||
case digit >= '0' && digit <= '9':
|
||||
value |= uint16(digit - '0')
|
||||
case digit >= 'a' && digit <= 'f':
|
||||
value |= uint16(digit-'a') + 10
|
||||
case digit >= 'A' && digit <= 'F':
|
||||
value |= uint16(digit-'A') + 10
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func decodeJSONValue(decoder *json.Decoder) (any, error) {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case json.Delim:
|
||||
switch value {
|
||||
case '{':
|
||||
object := make(map[string]any)
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: object key: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: object key is not a string", ErrInvalidDocument)
|
||||
}
|
||||
if _, exists := object[key]; exists {
|
||||
return nil, fmt.Errorf("%w: %q", ErrDuplicateField, key)
|
||||
}
|
||||
child, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object[key] = child
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
return nil, fmt.Errorf("%w: unterminated object", ErrInvalidDocument)
|
||||
}
|
||||
return object, nil
|
||||
case '[':
|
||||
var array []any
|
||||
for decoder.More() {
|
||||
child, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
array = append(array, child)
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim(']') {
|
||||
return nil, fmt.Errorf("%w: unterminated array", ErrInvalidDocument)
|
||||
}
|
||||
return array, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unexpected delimiter %q", ErrInvalidDocument, value)
|
||||
}
|
||||
case json.Number:
|
||||
if !integerJSONNumber.MatchString(string(value)) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
|
||||
}
|
||||
return value, nil
|
||||
case string, bool, nil:
|
||||
return value, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported token %T", ErrInvalidDocument, token)
|
||||
}
|
||||
value, err := canonicaljson.Parse(data)
|
||||
return value, catalogCanonicalError(err)
|
||||
}
|
||||
|
||||
func canonicalJSON(value any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
if err := appendCanonicalJSON(&buffer, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
encoded, err := canonicaljson.Marshal(value)
|
||||
return encoded, catalogCanonicalError(err)
|
||||
}
|
||||
|
||||
func appendCanonicalJSON(buffer *bytes.Buffer, value any) error {
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
buffer.WriteString("null")
|
||||
case bool:
|
||||
if value {
|
||||
buffer.WriteString("true")
|
||||
} else {
|
||||
buffer.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode string: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
buffer.Write(encoded)
|
||||
case json.Number:
|
||||
if !integerJSONNumber.MatchString(string(value)) {
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedNumber, value)
|
||||
}
|
||||
buffer.WriteString(string(value))
|
||||
case []any:
|
||||
buffer.WriteByte('[')
|
||||
for index, child := range value {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
if err := appendCanonicalJSON(buffer, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buffer.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
buffer.WriteByte('{')
|
||||
for index, key := range keys {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
encodedKey, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode key: %v", ErrInvalidDocument, err)
|
||||
}
|
||||
buffer.Write(encodedKey)
|
||||
buffer.WriteByte(':')
|
||||
if err := appendCanonicalJSON(buffer, value[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buffer.WriteByte('}')
|
||||
func catalogCanonicalError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, canonicaljson.ErrDuplicateField):
|
||||
return fmt.Errorf("%w: canonical JSON", ErrDuplicateField)
|
||||
case errors.Is(err, canonicaljson.ErrUnsupportedNumber):
|
||||
return fmt.Errorf("%w: canonical JSON", ErrUnsupportedNumber)
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported value %T", ErrInvalidDocument, value)
|
||||
return fmt.Errorf("%w: canonical JSON", ErrInvalidDocument)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package canonicaljson
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidDocument = errors.New("invalid canonical JSON document")
|
||||
ErrDuplicateField = errors.New("duplicate canonical JSON field")
|
||||
ErrUnsupportedNumber = errors.New("unsupported canonical JSON number")
|
||||
)
|
||||
|
||||
var integerJSONNumber = regexp.MustCompile(`^(0|[1-9][0-9]*|-[1-9][0-9]*)$`)
|
||||
|
||||
// Parse rejects ambiguous JSON forms before returning a generic JSON value.
|
||||
func Parse(data []byte) (any, error) {
|
||||
if !utf8.Valid(data) {
|
||||
return nil, fmt.Errorf("%w: input is not valid UTF-8", ErrInvalidDocument)
|
||||
}
|
||||
if err := validateJSONStringSurrogates(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
|
||||
value, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := decoder.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%w: trailing JSON value", ErrInvalidDocument)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: trailing data", ErrInvalidDocument)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Marshal emits the deterministic signing representation for a Parse result.
|
||||
func Marshal(value any) ([]byte, error) {
|
||||
var buffer bytes.Buffer
|
||||
if err := appendCanonicalJSON(&buffer, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func validateJSONStringSurrogates(data []byte) error {
|
||||
for index := 0; index < len(data); index++ {
|
||||
if data[index] != '"' {
|
||||
continue
|
||||
}
|
||||
next, err := scanJSONStringSurrogates(data, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index = next - 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanJSONStringSurrogates(data []byte, start int) (int, error) {
|
||||
for index := start + 1; index < len(data); index++ {
|
||||
switch data[index] {
|
||||
case '"':
|
||||
return index + 1, nil
|
||||
case '\\':
|
||||
if index+1 >= len(data) {
|
||||
return 0, fmt.Errorf("%w: incomplete string escape", ErrInvalidDocument)
|
||||
}
|
||||
if data[index+1] != 'u' {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
codeUnit, ok := decodeJSONHexCodeUnit(data, index+2)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: invalid unicode escape", ErrInvalidDocument)
|
||||
}
|
||||
switch {
|
||||
case codeUnit >= 0xd800 && codeUnit <= 0xdbff:
|
||||
if index+7 >= len(data) || data[index+6] != '\\' || data[index+7] != 'u' {
|
||||
return 0, fmt.Errorf("%w: high surrogate is not paired", ErrInvalidDocument)
|
||||
}
|
||||
lowSurrogate, ok := decodeJSONHexCodeUnit(data, index+8)
|
||||
if !ok || lowSurrogate < 0xdc00 || lowSurrogate > 0xdfff {
|
||||
return 0, fmt.Errorf("%w: high surrogate is not followed by a low surrogate", ErrInvalidDocument)
|
||||
}
|
||||
index += 11
|
||||
case codeUnit >= 0xdc00 && codeUnit <= 0xdfff:
|
||||
return 0, fmt.Errorf("%w: low surrogate has no high surrogate", ErrInvalidDocument)
|
||||
default:
|
||||
index += 5
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: unterminated string", ErrInvalidDocument)
|
||||
}
|
||||
|
||||
func decodeJSONHexCodeUnit(data []byte, start int) (uint16, bool) {
|
||||
if start+4 > len(data) {
|
||||
return 0, false
|
||||
}
|
||||
var value uint16
|
||||
for _, digit := range data[start : start+4] {
|
||||
value <<= 4
|
||||
switch {
|
||||
case digit >= '0' && digit <= '9':
|
||||
value |= uint16(digit - '0')
|
||||
case digit >= 'a' && digit <= 'f':
|
||||
value |= uint16(digit-'a') + 10
|
||||
case digit >= 'A' && digit <= 'F':
|
||||
value |= uint16(digit-'A') + 10
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func decodeJSONValue(decoder *json.Decoder) (any, error) {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: decode token", ErrInvalidDocument)
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case json.Delim:
|
||||
switch value {
|
||||
case '{':
|
||||
object := make(map[string]any)
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: object key", ErrInvalidDocument)
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: object key is not a string", ErrInvalidDocument)
|
||||
}
|
||||
if _, exists := object[key]; exists {
|
||||
return nil, ErrDuplicateField
|
||||
}
|
||||
child, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object[key] = child
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim('}') {
|
||||
return nil, fmt.Errorf("%w: unterminated object", ErrInvalidDocument)
|
||||
}
|
||||
return object, nil
|
||||
case '[':
|
||||
var array []any
|
||||
for decoder.More() {
|
||||
child, err := decodeJSONValue(decoder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
array = append(array, child)
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
if err != nil || end != json.Delim(']') {
|
||||
return nil, fmt.Errorf("%w: unterminated array", ErrInvalidDocument)
|
||||
}
|
||||
return array, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unexpected delimiter", ErrInvalidDocument)
|
||||
}
|
||||
case json.Number:
|
||||
if !integerJSONNumber.MatchString(string(value)) {
|
||||
return nil, ErrUnsupportedNumber
|
||||
}
|
||||
return value, nil
|
||||
case string, bool, nil:
|
||||
return value, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported token", ErrInvalidDocument)
|
||||
}
|
||||
}
|
||||
|
||||
func appendCanonicalJSON(buffer *bytes.Buffer, value any) error {
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
buffer.WriteString("null")
|
||||
case bool:
|
||||
if value {
|
||||
buffer.WriteString("true")
|
||||
} else {
|
||||
buffer.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode string", ErrInvalidDocument)
|
||||
}
|
||||
buffer.Write(encoded)
|
||||
case json.Number:
|
||||
if !integerJSONNumber.MatchString(string(value)) {
|
||||
return ErrUnsupportedNumber
|
||||
}
|
||||
buffer.WriteString(string(value))
|
||||
case []any:
|
||||
buffer.WriteByte('[')
|
||||
for index, child := range value {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
if err := appendCanonicalJSON(buffer, child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buffer.WriteByte(']')
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
buffer.WriteByte('{')
|
||||
for index, key := range keys {
|
||||
if index > 0 {
|
||||
buffer.WriteByte(',')
|
||||
}
|
||||
encodedKey, err := json.Marshal(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode key", ErrInvalidDocument)
|
||||
}
|
||||
buffer.Write(encodedKey)
|
||||
buffer.WriteByte(':')
|
||||
if err := appendCanonicalJSON(buffer, value[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
buffer.WriteByte('}')
|
||||
default:
|
||||
return fmt.Errorf("%w: unsupported value", ErrInvalidDocument)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package canonicaljson parses and emits the restricted canonical JSON shared
|
||||
// by signed core protocols.
|
||||
package canonicaljson
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user