package licensing import ( "bytes" "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "io" "time" "softbox.local/core/internal/canonicaljson" ) const ( // MaxRevocationValidity is the maximum signed freshness interval accepted // from a Revocation List v1 document. MaxRevocationValidity = 31 * 24 * time.Hour // RevocationGrace is the bounded offline period after a cached list expires. RevocationGrace = 7 * 24 * time.Hour ) var ( ErrInvalidRevocation = errors.New("revocation list is invalid") ErrRevocationSignatureMissing = errors.New("revocation list signature is missing") ErrRevocationSignatureInvalid = errors.New("revocation list signature is invalid") ) var revocationFields = map[string]struct{}{ "schema_version": {}, "generated_at": {}, "expires_at": {}, "revoked_license_ids": {}, "signature": {}, } // RevocationList is a verified Revocation List v1 document. It deliberately // excludes its source document, signature and signing key. type RevocationList struct { GeneratedAt time.Time ExpiresAt time.Time RevokedLicenseIDs []string } // RevocationState is the non-sensitive authorization result of one cached // revocation list at a caller-supplied instant. type RevocationState string const ( RevocationStateCurrent RevocationState = "current" RevocationStateGrace RevocationState = "grace" RevocationStateUnavailable RevocationState = "unavailable" RevocationStateRevoked RevocationState = "revoked" ) // RevocationVerifier validates Revocation List v1 documents with one copied // Ed25519 public key. Callers may inject the same controlled authorization key // used for License v1, but no fallback key exists. type RevocationVerifier struct { publicKey ed25519.PublicKey } // NewRevocationVerifier copies and validates a Revocation List v1 signing key. func NewRevocationVerifier(publicKey []byte) (RevocationVerifier, error) { if len(publicKey) != ed25519.PublicKeySize { return RevocationVerifier{}, ErrPublicKeyInvalid } return RevocationVerifier{publicKey: append(ed25519.PublicKey(nil), publicKey...)}, nil } // Verify validates one Revocation List v1 document. func (verifier RevocationVerifier) Verify(document []byte) (RevocationList, error) { if len(verifier.publicKey) != ed25519.PublicKeySize { return RevocationList{}, ErrPublicKeyInvalid } rootValue, err := canonicaljson.Parse(document) if err != nil { return RevocationList{}, revocationCanonicalError(err) } root, ok := rootValue.(map[string]any) if !ok { return RevocationList{}, ErrInvalidRevocation } signatureValue, exists := root["signature"] if !exists { return RevocationList{}, ErrRevocationSignatureMissing } signatureText, ok := signatureValue.(string) if !ok { return RevocationList{}, ErrRevocationSignatureInvalid } hasExactFields := hasExactRevocationShape(root) delete(root, "signature") signedPayload, err := canonicaljson.Marshal(root) if err != nil { return RevocationList{}, revocationCanonicalError(err) } signature, err := decodeRevocationSignature(signatureText) if err != nil || !ed25519.Verify(verifier.publicKey, signedPayload, signature) { return RevocationList{}, ErrRevocationSignatureInvalid } if !hasExactFields { return RevocationList{}, ErrInvalidRevocation } wire, err := decodeRevocationList(document) if err != nil { return RevocationList{}, err } return validateRevocationList(wire) } // StateFor reports whether one license ID is safe to use under this verified // cache. A future-dated list is unavailable rather than trusted. func (list RevocationList) StateFor(licenseID string, now time.Time) RevocationState { if now.Before(list.GeneratedAt) || !list.ExpiresAt.After(list.GeneratedAt) || list.ExpiresAt.Sub(list.GeneratedAt) > MaxRevocationValidity { return RevocationStateUnavailable } for _, revokedID := range list.RevokedLicenseIDs { if revokedID == licenseID { return RevocationStateRevoked } } if !now.After(list.ExpiresAt) { return RevocationStateCurrent } if !now.After(list.ExpiresAt.Add(RevocationGrace)) { return RevocationStateGrace } return RevocationStateUnavailable } func hasExactRevocationShape(root map[string]any) bool { if len(root) != len(revocationFields) { return false } for field := range revocationFields { if _, exists := root[field]; !exists { return false } } if _, ok := root["schema_version"].(json.Number); !ok { return false } if _, ok := root["revoked_license_ids"].([]any); !ok { return false } for _, field := range []string{"generated_at", "expires_at", "signature"} { if _, ok := root[field].(string); !ok { return false } } return true } type revocationWire struct { SchemaVersion int `json:"schema_version"` GeneratedAt string `json:"generated_at"` ExpiresAt string `json:"expires_at"` RevokedLicenseIDs []string `json:"revoked_license_ids"` Signature string `json:"signature"` } func decodeRevocationList(document []byte) (revocationWire, error) { decoder := json.NewDecoder(bytes.NewReader(document)) decoder.DisallowUnknownFields() decoder.UseNumber() var wire revocationWire if err := decoder.Decode(&wire); err != nil { return revocationWire{}, ErrInvalidRevocation } var extra any if err := decoder.Decode(&extra); err != io.EOF { return revocationWire{}, ErrInvalidRevocation } return wire, nil } func validateRevocationList(wire revocationWire) (RevocationList, error) { if wire.SchemaVersion != 1 || wire.Signature == "" { return RevocationList{}, ErrInvalidRevocation } generatedAt, err := time.Parse(timestampLayout, wire.GeneratedAt) if err != nil || generatedAt.Format(timestampLayout) != wire.GeneratedAt { return RevocationList{}, ErrInvalidRevocation } expiresAt, err := time.Parse(timestampLayout, wire.ExpiresAt) if err != nil || expiresAt.Format(timestampLayout) != wire.ExpiresAt || !expiresAt.After(generatedAt) || expiresAt.Sub(generatedAt) > MaxRevocationValidity { return RevocationList{}, ErrInvalidRevocation } ids := make([]string, len(wire.RevokedLicenseIDs)) seen := make(map[string]struct{}, len(wire.RevokedLicenseIDs)) for index, licenseID := range wire.RevokedLicenseIDs { if !licenseIDPattern.MatchString(licenseID) { return RevocationList{}, ErrInvalidRevocation } if _, exists := seen[licenseID]; exists { return RevocationList{}, ErrInvalidRevocation } seen[licenseID] = struct{}{} ids[index] = licenseID } return RevocationList{ GeneratedAt: generatedAt, ExpiresAt: expiresAt, RevokedLicenseIDs: ids, }, nil } func decodeRevocationSignature(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, ErrRevocationSignatureInvalid } return signature, nil } func revocationCanonicalError(err error) error { switch { case errors.Is(err, canonicaljson.ErrDuplicateField): return ErrDuplicateField case errors.Is(err, canonicaljson.ErrUnsupportedNumber): return ErrUnsupportedNumber default: return ErrInvalidRevocation } }