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
|
||||
}
|
||||
@@ -47,7 +47,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-617`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新编排;T-403 已完成受限 SoftBoxUpdater、自身 EXE 健康确认与可恢复切换;T-616 已完成 production cmd 的 Catalog 快照事件投递和明确空态诊断;T-617 已修复 prepared rename+sync 后的自更新恢复不一致并补足无头故障恢复/health flag 测试。T-501 已完成 machine_hash v1:严格 MachineGuid 与 Windows 目录所在卷序列号构成带域分隔的 SHA-256,任一来源不可用即拒绝且不保存原始标识。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||
当前项目已完成 Phase 0~2、T-301~T-303、T-615 与审核整改 `T-604`~`T-617`。Windows 安全路径阻断项、图标缓存资源边界、后台结果回 UI 线程的事件接线、双适配器交互契约、`VisibleItems` 快照生命周期、双端 Gio shell 职责拆分、unsafe cache 安全诊断/runbook、ZIP 中央目录/EOCD(含 ZIP64)预扫描、安装文件/目录/journal 的代码层耐久顺序、Catalog canonicalization/签名静态 corpus,以及同句柄 Catalog size/SHA→严格 app.json→staging/switch/回滚安装链均已关闭;T-303 已将 verified-package 的 staging 前磁盘/运行状态预检和稳定失败码落实到 core,T-615 已将 ZIP 输入/staging 输出 I/O 分界、平台磁盘满分类和清理失败落实到 core。T-401 已完成完整路径进程检测、受控启动和 Switcher 临界区复查;T-402 已完成子软件更新编排;T-403 已完成受限 SoftBoxUpdater、自身 EXE 健康确认与可恢复切换;T-616 已完成 production cmd 的 Catalog 快照事件投递和明确空态诊断;T-617 已修复 prepared rename+sync 后的自更新恢复不一致并补足无头故障恢复/health flag 测试。T-501 已完成 machine_hash v1:严格 MachineGuid 与 Windows 目录所在卷序列号构成带域分隔的 SHA-256,任一来源不可用即拒绝且不保存原始标识;T-502 已完成 License v1 的共享 canonical JSON、离线 Ed25519 验签和严格 machine_hash 比对,不含生产 trust root 或文件/UI composition。物理断电、文件锁与杀毒软件干扰验证保留到 T-601 发布前环境验证。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -55,7 +55,7 @@ SoftBox 软件盒子是一个使用 Go + Gio 开发的 Windows 桌面客户端,
|
||||
2. 已完成 Phase 1:清单验签、ZIP 安全解压、原子切换回滚原型。
|
||||
3. 已完成 Phase 2 与 T-301:清单/列表/详情/图标缓存 + 可恢复下载队列。
|
||||
4. 已完成 T-604:modern/Win7 workspace 与 Gio 版本解析彻底隔离。
|
||||
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;T-403 已完成 SoftBoxUpdater 的受限 transaction、PID 自然退出、固定健康启动与恢复;T-616 已完成 Catalog 快照启动投递与明确空态诊断;T-617 已完成 prepared rename+sync 的恢复修复、五 phase Recover 与双端 health flag 参数拒绝测试;T-501 已完成严格双来源的 machine_hash v1。T-502 已正式冻结 License v1 的共享 canonical JSON、Ed25519 验签和 machine_hash 比对,下一步领取实现。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||
5. 已完成 T-606~T-615:图标缓存资源边界、UI 线程事件接线、双 Gio 适配器交互契约、`VisibleItems` generation 生命周期、双端 `shell.go` 同 package 镜像职责拆分、unsafe cache 诊断/人工恢复指引、ZIP 中央目录/EOCD 预扫描、安装耐久顺序、Catalog 静态签名向量,以及 staging 输出 I/O 根因与磁盘满诊断;已完成 T-302/T-303:已验签 Catalog 选择与同句柄 size/SHA、严格 app.json、安全 staging/switch/健康与记录写回滚链路,以及 staging 前磁盘/运行状态预检与稳定失败码。T-401 已完成进程检测、受控启动与切换临界区复查;T-402 已完成关闭确认、自然退出等待和更新编排;T-403 已完成 SoftBoxUpdater 的受限 transaction、PID 自然退出、固定健康启动与恢复;T-616 已完成 Catalog 快照启动投递与明确空态诊断;T-617 已完成 prepared rename+sync 的恢复修复、五 phase Recover 与双端 health flag 参数拒绝测试;T-501 已完成严格双来源的 machine_hash v1;T-502 已完成 License v1 共享 canonical JSON、离线验签和机器绑定比较。下一步按路线图正式落成 Phase 5 的 T-503。T-601 仍须补真实 Windows 环境的断电/干扰注入。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -13,16 +13,17 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-20
|
||||
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-617 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排、T-403 受限盒子自更新事务及 T-617 恢复状态机/测试整改已完成;Phase 5 的 T-501 严格双来源 machine_hash v1 已完成
|
||||
- 阶段:Phase 2 已完成(T-201~T-204)并由 T-616 补齐启动 Catalog 快照投递/空态诊断;Phase 3 的 T-301 可恢复下载队列、T-302 安装流程整合、T-303 失败处理/磁盘预检查与 T-615 staging 输出 I/O/磁盘满诊断整改已完成;审核整改 T-604~T-617 与 Phase 4 的 T-401 进程检测、受控启动和切换临界区复查、T-402 子软件更新编排、T-403 受限盒子自更新事务及 T-617 恢复状态机/测试整改已完成;Phase 5 的 T-501 严格双来源 machine_hash v1 与 T-502 License v1 离线验签已完成
|
||||
- 技术栈:根 Go 1.25 workspace 只纳入 core/app-modern,`app-win7/go.work` 独立纳入 core/app-win7;版本闸门证明 modern Gio v0.10.1 与 win7 Gio v0.6.0 不交叉解析
|
||||
- 生产代码:core 已有 Catalog/本地状态/存储、共享 Windows 安全相对路径策略与静态跨实现 canonicalization/Ed25519 vector corpus(拒绝非法 surrogate、`-0` 和非唯一 Base64 signature,大整数保持 token)、安全 ZIP 解压/回滚原型及 T-302/T-303/T-615 安装 use case(`core/application/install.InstallService` 只取已过滤 Catalog entry + architecture,强制注入 disk/storage-failure/target-state checker;`Extractor.ExtractVerifiedFileWithCheck` 在同一普通文件句柄按 size→SHA-256→EOCD/ZIP64→严格 app.json→已规划 payload 的 staging 前预检→安全 staging 的顺序处理,空间要求为 payload+64 MiB,ZIP 输入错误与 staging 创建/write/sync/close 错误分界并保留原始 I/O 链;平台可识别的后者磁盘满返回 `disk_full`,其余输出 I/O 返回稳定 code 且不触发 switch;清理失败可观察,Recover 仅删除已验证 layout 内的残留 staging;每个实际 payload 文件 hash 与受验证 entrypoint/working directory/min_os/requires_admin 写入 installed-app;health 或记录写失败经 Switcher 回滚;更新 current→backup 紧邻前复查精确 entrypoint,明确运行/检测故障保持旧版本并清理 staging),transaction/switch/rollback/recovery 的 journal、rename、清理经统一 fail-closed 耐久栅栏,Windows 使用目录句柄 FlushFileBuffers)、纯 core `application/launch`(只接收 app ID、受控 current/普通 entrypoint/兼容/授权/运行状态/启动器接口全部 fail closed)、双端 Toolhelp 完整映像路径检测/Win7 可用系统版本判断/无参数受控启动与非 Windows fail-closed stub、发布稳定只读 generation 的无 IO 软件列表模型、按 key in-flight + 流式有界读取 + 32 MiB/256-key LRU 的可信图标缓存、图标 Load/Decode 事件发布用例、有界 application event relay,以及默认并发 2 的持久可恢复下载队列;modern/win7 主循环已接 relay/Invalidate,AppShell 已实现搜索/分类/视图、惰性列表、详情右栏、完整图标失败 identity 生命周期与仅 `unsafe_cache` 可见的安全 locator/人工恢复提示,并按 root/header/catalog/detail/style 同 package 镜像职责拆文件
|
||||
- T-402 更新用例:`core/application/update` 只接收外层可信的 install selection,验证已装版本/旧 entrypoint后,运行中才请求关闭确认并以 1 秒~10 分钟上限等待自然退出,随后委托已有 `InstallService` 的双重运行复查与 rollback;取消、超时、Toolhelp 检测错误和安装错误均保留稳定 code/错误链,不强杀且不改 `data/`、`licenses/`。两端 platform 对齐 `WaitForExit` 契约,Windows 固定短轮询完整路径,非 Windows 返回明确不支持。
|
||||
- T-403 自更新:`core/updater` 只接受正 PID、真实 `<root>/app` 与同 root `<root>/staging/<safe-request-id>`,先等待旧 PID 自然退出,随后恢复遗留 journal 或以 `prepared → target_backed_up → staging_activated → launched → committed` 切换;目录 rename、原子 JSON 和目录 durability 均由受限路径与平台同步栅栏保护。助手只启动固定 `<root>/app/SoftBox.exe --softbox-update-health <request-id>`,主程序在 Gio Layout 前从自身 EXE 写最小 health 确认;失败优先恢复旧 app,Windows 锁阻止恢复时保留 backup/journal,不强杀。两端有无 Gio `cmd/softboxupdater`,非 Windows 平台边界 fail closed;没有自更新下载、签名消费、版本选择或 UI 触发器。
|
||||
- T-616 启动 Catalog:`core/application.CatalogBootstrap` 只消费 composition 注入的已验证、目标过滤内存快照,以深拷贝 `CatalogRefreshed`/稳定 `CatalogRejected` payload 经现有 runtime/relay 交给 UI Frame;双端 shell 在 UI goroutine 更新 `SetItems` 并明确显示 loading、未配置、加载失败、已加载空目录与筛选空结果。当前 cmd 有意注入 `UnconfiguredCatalogLoader`,故 `dist/SoftBox.exe` 不会显示 fake 软件,而会显示“Catalog 来源尚未配置”;可信 URL/公钥/缓存 composition 仍待发布配置。
|
||||
- T-617 自更新整改:`prepared` journal 不再单独决定是否移动;Recover 仅在 target 存在且 managed backup 缺失时删 journal,target 缺失且 backup 存在时先 restore,其余拓扑 fail closed 并保留材料。首次 backup rename 后目录 sync 错误立即走相同受限恢复路径,恢复包装保留 `ErrRecoveryRequired` 和底层 durability 根因。覆盖 prepared/target_backed_up/staging_activated/launched/committed、transaction/rename/sync/cleanup 和 health timeout 阻塞后 Recover;双端 health flag 的缺 ID/多余参数均被拒绝。Windows 真机锁、杀毒和断电仍待 T-601。
|
||||
- T-501 机器指纹:`core/licensing.DeriveMachineHash` 是 Go 1.20 兼容的纯算法,严格规范化 `MachineGuid` 并按固定域分隔与 Windows 目录所在卷序列号生成 64 字符小写 SHA-256;两端 `platform/windows.MachineHash` 仅在调用期间读取这两个来源,采集/规范化失败只返回无原始值的 sentinel,非 Windows 返回 `ErrUnsupported`。原始 GUID、卷序列号和 MAC 均不进入持久化、事件、UI 或日志;许可证 JSON/验签/导入仍待 T-502。
|
||||
- T-501 机器指纹:`core/licensing.DeriveMachineHash` 是 Go 1.20 兼容的纯算法,严格规范化 `MachineGuid` 并按固定域分隔与 Windows 目录所在卷序列号生成 64 字符小写 SHA-256;两端 `platform/windows.MachineHash` 仅在调用期间读取这两个来源,采集/规范化失败只返回无原始值的 sentinel,非 Windows 返回 `ErrUnsupported`。原始 GUID、卷序列号和 MAC 均不进入持久化、事件、UI 或日志。
|
||||
- T-502 许可证验证:`core/internal/canonicaljson` 是 Catalog/License 共用的受限 canonical JSON 实现;`core/licensing.Verifier` 只接受复制后的 32 字节 public key、License v1 document 与 T-501 expected hash,严格验签、九字段、机器绑定和 product 查询,失败只返回无内容 sentinel。`schemas/license.schema.json` 与 `testdata/license` 固定协议/跨实现签名向量且没有私钥;生产 trust root、许可证文件导入/存储、授权 UI、trial/revocation/rebind 和启动接线仍待 T-503。
|
||||
- 测试:core 覆盖 Catalog 静态 canonicalization/Ed25519 vectors、非法 surrogate/`-0`/Base64 fail-closed、列表快照 generation/零复制、SemVer/12 状态、本地安装记录、Windows dot-space/设备名/Unicode 折叠路径攻击、ZIP destination 包含性与 EOCD/ZIP64 原始包/中央目录/条目数预扫描、T-302/T-303/T-615 同句柄 package size/SHA、严格/有界 app.json、verified payload 预检 hook、容量精确阈值/故障、程序运行/状态故障、稳定安装失败码、staging write/sync/close ENOSPC 与普通输出 I/O 原因保留、CRC 输入分界、清理失败/受控恢复、payload hash 与启动元数据记录、Catalog 选择拒绝、transaction recovery、health/记录写失败回滚、switch 临界区复查、受控启动的旧 metadata/unsafe layout/缺文件/兼容/授权/运行/启动失败、payload/staging tree/journal/rename/rollback/recovery/cleanup 耐久顺序及错误注入、Windows 原生目录 `FlushFileBuffers`、图标并发/取消/读取边界/LRU、真实目录/symlink fail-closed 与 cache→`unsafe_cache` event、relay 背压与关闭、下载并发/暂停/取消/重试/Range/断连/恢复/事件失败与文件身份替换;两个 app 覆盖 Toolhelp snapshot full-path collision/error seam、OS version 判断和非 Windows fail-closed stub,以及 Editor/视图/分类/行/恢复/关闭接线、500 项 viewport、AppID 控件与分类控件生命周期、详情上下文、空状态语义、UI drain 前后、图标失败身份生命周期与 `unsafe_cache` 详情语义;安装恢复矩阵保持通过
|
||||
- 数据:`schemas/` 已有 manifest/app.json/installed-app.json/download-task.json v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
|
||||
- 数据:`schemas/` 已有 manifest/app/installed-app/download-task/license v1 Schema并注明 Windows 路径运行时权威规则;`testdata/catalog/` 有公开虚构清单样例和 v1 静态 canonicalization/Ed25519 corpus;`testdata/license/` 有不含私钥的 License v1 静态签名语料;`testdata/zip/` 与 `testdata/download/` 记录运行时生成的攻击/传输矩阵
|
||||
- 标准启动路径:`./init.sh` / `./init.ps1`(同步依赖、执行完整 Phase 0 闸门、打印双目标构建命令)
|
||||
- 标准验证路径:`bash scripts/verify_phase0.sh` / `./scripts/verify_phase0.ps1`
|
||||
- 版本管理:git 已初始化,main 分支,远端 origin 为 Gitea `opc/soft_quay`;harness 文档已提交
|
||||
@@ -33,20 +34,20 @@
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | harness coding 文档集(本次初始化完成) |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-617、T-401~T-403 与 T-501 已完成 |
|
||||
| `docs/tasks/` | 已有 | Phase 0~2、T-301~T-303、T-604~T-617、T-401~T-403、T-501 与 T-502 已完成 |
|
||||
| `scripts/` | 已有 | harness 治理、core 边界、Go 版本检查与 Phase 0 双平台验证入口 |
|
||||
| `core/` | 已建 | Go 1.20 兼容;已有正式 Catalog、本地状态/存储、共享 Windows safepath、列表模型、有界并发图标缓存、图标事件/relay、可恢复下载队列与 Phase 1 安装安全原型 |
|
||||
| `app-modern/` | 已建 | Go 1.25.0 + Gio v0.10.1;Modern AppShell 已接入虚拟列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并拆为五类 shell 职责文件 |
|
||||
| `app-win7/` | 已建 | Go 1.20 + Gio v0.6.0;Legacy AppShell 已接入低成本列表、详情、图标事件 drain/过期拒绝和内存 ImageOp,并保持同名职责文件与版本差异 |
|
||||
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json` 与 `download-task.schema.json` |
|
||||
| `schemas/` | 已建 | `manifest.schema.json`、`app.schema.json`、`installed-app.schema.json`、`download-task.schema.json` 与 `license.schema.json` |
|
||||
| `testdata/` | 已建 | 包含 Catalog 假数据、ZIP 恶意矩阵与下载协议测试说明;后续任务继续扩展 |
|
||||
|
||||
## 任务状态
|
||||
|
||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-617`;Phase 4 的 `T-401`~`T-403`;Phase 5 的 `T-501`。
|
||||
- 正在进行:无;`T-502` 已正式落成,冻结 License v1 的九字段、共享受限 canonical JSON、Ed25519 验签与严格 machine_hash 比对,下一步领取实现。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
- 已完成:Phase 0 的 `T-001`~`T-004`;Phase 1 的 `T-101`、`T-102`、`T-103`;Phase 2 的 `T-201`~`T-204` 与 `T-616`;Phase 3 的 `T-301`~`T-303` 与 `T-615`;审核整改 `T-604`~`T-617`;Phase 4 的 `T-401`~`T-403`;Phase 5 的 `T-501`、`T-502`。
|
||||
- 正在进行:无;下一步可按路线图正式落成 T-503(许可证文件导入、授权 UI、trial/revocation/rebind 与启动授权接线,依赖 T-502、T-204)。T-601 的物理断电与干扰故障注入仍保留为发布前环境验证。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+6
-4
@@ -3,12 +3,12 @@ id: T-502
|
||||
title: Ed25519 离线许可证验证
|
||||
phase: 5
|
||||
deps: [T-501]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-20
|
||||
issue: null
|
||||
context_ref: null
|
||||
context_ref: c51ff30c54ad612b5f91649b35f9e940a28be573
|
||||
claim_branch: null
|
||||
work_branch: null
|
||||
work_branch: agent/codex/T-502
|
||||
write_paths:
|
||||
- docs/tasks/T-502.md
|
||||
- core/internal/canonicaljson/
|
||||
@@ -33,7 +33,7 @@ T-501 已能从 Windows 平台得到不含原始硬件标识的 `machine_hash`
|
||||
## 方案
|
||||
|
||||
1. 把 Catalog 当前的受限 JSON 解析与 canonical 输出下沉到仅 `core/` 可见的 `core/internal/canonicaljson`,保持 Catalog 导出的错误身份和静态 corpus 行为不变。该实现只接受有效 UTF-8、无未配对 surrogate、无重复 object key、整数(拒绝 `-0`、小数和指数),按递归字典序 key、无多余空白的 JSON 生成 signing bytes;Catalog 保留薄 wrapper,避免改变既有公开行为。
|
||||
2. 在纯 Go 1.20 的 `core/licensing` 增加 `License`、`Verifier` 和只读产品授权查询。`NewVerifier(publicKey)` 复制并严格要求 32 字节 Ed25519 公钥;`Verify(document, expectedMachineHash)` 先以共享 canonical JSON 移除顶层 `signature` 得到 signing bytes,要求 64 字节标准 padded Base64 签名,再验签、严格 decode 和验证字段,最后精确比较调用方提供的 64 位小写 `machine_hash`。任何失败返回稳定、无许可证内容的 sentinel;成功结果深拷贝 products,不保存 document、签名或原始机器来源。
|
||||
2. 在纯 Go 1.20 的 `core/licensing` 增加 `License`、`Verifier` 和只读 `License.AuthorizesProduct(productID) bool` 查询。`NewVerifier(publicKey)` 复制并严格要求 32 字节 Ed25519 公钥;`Verify(document, expectedMachineHash)` 先以共享 canonical JSON 移除顶层 `signature` 得到 signing bytes,要求 64 字节标准 padded Base64 签名,再验签、严格 decode 和验证字段,最后精确比较调用方提供的 64 位小写 `machine_hash`。任何失败返回稳定、无许可证内容的 sentinel;成功结果深拷贝 products,不保存 document、签名或原始机器来源。
|
||||
3. 冻结 License v1:只允许 `schema_version`、`license_id`、`machine_hash`、`products`、`issued_at`、`perpetual`、`update_policy`、`rebind_policy`、`signature` 九个顶层字段,全部必需且不得重复/未知。`license_id`、product 与 policy 分别匹配 `^lic-[a-z0-9][a-z0-9-]{0,59}$`、`^[a-z0-9-]+$`、`^[a-z0-9][a-z0-9._:-]{0,127}$`;products 非空且不重复,`issued_at` 是精确 RFC3339 UTC 秒级文本,`machine_hash` 必须为 T-501 的 64 字符小写 hex。canonical signing bytes 是删除顶层 `signature` 后的共享受限 canonical JSON;`schema_version` 只能为 1。`perpetual`、update/rebind policy 仅作为已签名元数据,本任务不实施 expiry、trial、revocation 或 rebind 决策。
|
||||
4. 新增 `schemas/license.schema.json` 和只含虚构产品、测试公钥、已签名 document/签名载荷的静态 license corpus。绝不提交生产公钥、任何生产或测试私钥、真实许可证 ID、机器摘要或注册码。测试证明有效语料、对象键/空白等价 canonicalization、篡改、错误 key、重复/未知字段、非 canonical Base64、非法 JSON/数字、错误机器摘要、产品查询以及输入/输出深拷贝均 fail closed;现有 Catalog corpus 继续覆盖下沉后的兼容性。
|
||||
5. 更新 API/架构/路线图和项目快照:当前 core verifier 只接收调用方注入的 public key 和 T-501 hash,尚无生产信任根、许可证文件读写、UI、试用、撤销名单、update/rebind 策略或启动用例 composition。T-503 才从受控 `licenses/` 文件导入、展示并把经验证的 product 授权接到盒子与子软件流程。
|
||||
@@ -60,3 +60,5 @@ T-501 已能从 Windows 平台得到不含原始硬件标识的 `machine_hash`
|
||||
## 执行记录
|
||||
|
||||
- 2026-07-20:正式落成。冻结 License v1 的九字段、canonical signing bytes、严格 Ed25519/机器摘要验证与不含私钥的静态语料;决定提取并复用既有 Catalog canonical JSON,而不是复制安全解析器。明确生产 trust root、文件导入、trial/revocation/rebind 与 UI 均留给后续任务。
|
||||
- 2026-07-20:领取任务,基于 `c51ff30c54ad612b5f91649b35f9e940a28be573` 在 `agent/codex/T-502` 执行;先重跑基线,再实现共享 canonical JSON、纯 core verifier 与静态测试语料。
|
||||
- 2026-07-20:完成。将 Catalog 的受限 JSON 解析/canonical 输出下沉为 `core/internal/canonicaljson`,Catalog wrapper 与既有静态 corpus 保持通过;新增纯 Go 1.20 `licensing.Verifier`、严格 License v1 九字段/Ed25519/Base64/expected machine_hash 验证和 `License.AuthorizesProduct`。`schemas/license.schema.json` 与 `testdata/license` 只含虚构值、测试公钥和预签静态 documents,没有任何私钥。完整 core/两端 app 测试、双端 Windows amd64 构建、`./scripts/verify_phase0.ps1`、上下文与治理校验均通过。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://softbox.invalid/schemas/license.schema.json",
|
||||
"title": "SoftBox License v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"license_id",
|
||||
"machine_hash",
|
||||
"products",
|
||||
"issued_at",
|
||||
"perpetual",
|
||||
"update_policy",
|
||||
"rebind_policy",
|
||||
"signature"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": 1
|
||||
},
|
||||
"license_id": {
|
||||
"type": "string",
|
||||
"pattern": "^lic-[a-z0-9][a-z0-9-]{0,59}$"
|
||||
},
|
||||
"machine_hash": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"products": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9-]+$"
|
||||
}
|
||||
},
|
||||
"issued_at": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$",
|
||||
"$comment": "Runtime validation is authoritative for calendar validity."
|
||||
},
|
||||
"perpetual": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"update_policy": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"rebind_policy": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"signature": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9+/]{86}==$"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
# License v1 test corpus
|
||||
|
||||
`license-v1-vectors.json` contains only fake machine hashes/products, a test
|
||||
public key, signed valid and invalid-shape documents, and the corresponding
|
||||
valid canonical signing bytes. It contains no private key, production key, real
|
||||
license ID, or real machine identifier. The static bytes are a
|
||||
cross-implementation contract for License v1 signing.
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"public_key_base64": "jl6xuV51N8SYjwr+7DReGnSHVXTG6PZYg/5aayoM2fA=",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "valid",
|
||||
"document": "{\"issued_at\":\"2026-07-20T01:02:03Z\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"perpetual\":true,\"products\":[\"product-json-parser\",\"toolkit\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"JScvchl2W5O49yzNywlE5dhDI46Mb+VeIMdtud1pXj2T3IdzEbUimk3gRsw4ZKiPffTMaGJoPfuGSCR0pHT5Dw==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"signed_payload_base64": "eyJpc3N1ZWRfYXQiOiIyMDI2LTA3LTIwVDAxOjAyOjAzWiIsImxpY2Vuc2VfaWQiOiJsaWMtdGVzdC0yMDI2MDcyMCIsIm1hY2hpbmVfaGFzaCI6IjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYiLCJwZXJwZXR1YWwiOnRydWUsInByb2R1Y3RzIjpbInByb2R1Y3QtanNvbi1wYXJzZXIiLCJ0b29sa2l0Il0sInJlYmluZF9wb2xpY3kiOiJzZWxmLXNlcnZpY2UtMS1wZXItOTBkIiwic2NoZW1hX3ZlcnNpb24iOjEsInVwZGF0ZV9wb2xpY3kiOiJ1cGRhdGVzLXVudGlsLTIwMjctMTItMzEifQ==",
|
||||
"signature": "JScvchl2W5O49yzNywlE5dhDI46Mb+VeIMdtud1pXj2T3IdzEbUimk3gRsw4ZKiPffTMaGJoPfuGSCR0pHT5Dw==",
|
||||
"expected_machine_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"expected_products": ["product-json-parser", "toolkit"]
|
||||
},
|
||||
{
|
||||
"name": "unknown-field",
|
||||
"document": "{\"extra\":true,\"issued_at\":\"2026-07-20T01:02:03Z\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"perpetual\":true,\"products\":[\"product-json-parser\",\"toolkit\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"QB4a4DvrGQeEI8tUti4gNTKvtdSb9SxlZjKdKCnv7VGq/CqqTWqHw7bb5SGguum+ueXymTmOkO24VdodTRmNDQ==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"want_error": "invalid_license"
|
||||
},
|
||||
{
|
||||
"name": "missing-perpetual",
|
||||
"document": "{\"issued_at\":\"2026-07-20T01:02:03Z\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"products\":[\"product-json-parser\",\"toolkit\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"ByawlRJ6CWuELm7uYdFfzUWEgw5N2LuFNGIV1qzJejnOZ5C7yOqUBwl2JG0iw9umk5f/0blboWTlkg6x8DHDDA==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"want_error": "invalid_license"
|
||||
},
|
||||
{
|
||||
"name": "null-perpetual",
|
||||
"document": "{\"issued_at\":\"2026-07-20T01:02:03Z\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"perpetual\":null,\"products\":[\"product-json-parser\",\"toolkit\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"HKVyEX5o/gzRpJk3TAqhp7sBf9+qjJvhfZk4zavWLrBzwY+ZgCW15XuMSh7ZwvXtnh23mFyssJvQgBPHOzIuCQ==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"want_error": "invalid_license"
|
||||
},
|
||||
{
|
||||
"name": "duplicate-product",
|
||||
"document": "{\"issued_at\":\"2026-07-20T01:02:03Z\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"perpetual\":true,\"products\":[\"product-json-parser\",\"product-json-parser\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"UqtzJ6FnVkfCsrJanRzdUnVsFEgH7qRpoTlJgNhF4ZWu8L46so9hvUfskhc560AkADb1xvKi8Ih8cxrlApVbAg==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"want_error": "invalid_license"
|
||||
},
|
||||
{
|
||||
"name": "invalid-timestamp",
|
||||
"document": "{\"issued_at\":\"2026-07-20T01:02:03+00:00\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"perpetual\":true,\"products\":[\"product-json-parser\",\"toolkit\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"ZthYd0W9WpFJJ+Fe7m43BkJ6rdwVn+ZbV0skBAFLvVPbDDCSzuYBA0Kpa581s41uE+zgtsdWqnk6X894hDPBAQ==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"want_error": "invalid_license"
|
||||
},
|
||||
{
|
||||
"name": "uppercase-machine-hash",
|
||||
"document": "{\"issued_at\":\"2026-07-20T01:02:03Z\",\"license_id\":\"lic-test-20260720\",\"machine_hash\":\"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF\",\"perpetual\":true,\"products\":[\"product-json-parser\",\"toolkit\"],\"rebind_policy\":\"self-service-1-per-90d\",\"schema_version\":1,\"signature\":\"h4en9HN21gcXuHQ4nEW00kwfHXcagkIqW+O8FWpR2H1WVzfHkm0Lj34YJySgGRVeslSR/3Yzy9ualeJtkA/kBA==\",\"update_policy\":\"updates-until-2027-12-31\"}",
|
||||
"want_error": "invalid_license"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user