Implement authorization import and revocation checks (T-503)
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/internal/canonicaljson"
|
||||
)
|
||||
|
||||
func TestRevocationVerifierAndStateBoundaries(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier, err := NewRevocationVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
|
||||
document := signedRevocationDocument(t, privateKey, generated, generated.Add(24*time.Hour), []string{"lic-revoked"})
|
||||
list, err := verifier.Verify(document)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify() error = %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
licenseID string
|
||||
now time.Time
|
||||
want RevocationState
|
||||
}{
|
||||
{"current", "lic-active", generated.Add(time.Hour), RevocationStateCurrent},
|
||||
{"revoked", "lic-revoked", generated.Add(time.Hour), RevocationStateRevoked},
|
||||
{"at expiry", "lic-active", generated.Add(24 * time.Hour), RevocationStateCurrent},
|
||||
{"at grace end", "lic-active", generated.Add(24*time.Hour + RevocationGrace), RevocationStateGrace},
|
||||
{"after grace", "lic-active", generated.Add(24*time.Hour + RevocationGrace + time.Second), RevocationStateUnavailable},
|
||||
{"before generated", "lic-active", generated.Add(-time.Second), RevocationStateUnavailable},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := list.StateFor(test.licenseID, test.now); got != test.want {
|
||||
t.Fatalf("StateFor() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
list.RevokedLicenseIDs[0] = "lic-mutated"
|
||||
if again, err := verifier.Verify(document); err != nil || again.RevokedLicenseIDs[0] != "lic-revoked" {
|
||||
t.Fatalf("Verify() after output mutation = %#v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevocationVerifierFailsClosed(t *testing.T) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier, err := NewRevocationVerifier(publicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated := time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC)
|
||||
valid := signedRevocationDocument(t, privateKey, generated, generated.Add(time.Hour), nil)
|
||||
tampered := append([]byte(nil), valid...)
|
||||
tampered[5] = 'x'
|
||||
tooLong := signedRevocationDocument(t, privateKey, generated, generated.Add(MaxRevocationValidity+time.Second), nil)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
document []byte
|
||||
want error
|
||||
}{
|
||||
{"empty", nil, ErrInvalidRevocation},
|
||||
{"tampered", tampered, ErrRevocationSignatureInvalid},
|
||||
{"interval too long", tooLong, ErrInvalidRevocation},
|
||||
{"duplicate", []byte(`{"schema_version":1,"schema_version":1}`), ErrDuplicateField},
|
||||
{"missing signature", []byte(`{"schema_version":1,"generated_at":"2026-07-20T00:00:00Z","expires_at":"2026-07-20T01:00:00Z","revoked_license_ids":[]}`), ErrRevocationSignatureMissing},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := verifier.Verify(test.document); !errors.Is(err, test.want) {
|
||||
t.Fatalf("Verify() error = %v, want %v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := NewRevocationVerifier(make([]byte, ed25519.PublicKeySize-1)); !errors.Is(err, ErrPublicKeyInvalid) {
|
||||
t.Fatalf("NewRevocationVerifier(short) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signedRevocationDocument(
|
||||
t *testing.T,
|
||||
privateKey ed25519.PrivateKey,
|
||||
generated time.Time,
|
||||
expires time.Time,
|
||||
revoked []string,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
payload := map[string]any{
|
||||
"schema_version": json.Number("1"),
|
||||
"generated_at": generated.Format(timestampLayout),
|
||||
"expires_at": expires.Format(timestampLayout),
|
||||
"revoked_license_ids": stringSliceAsAny(revoked),
|
||||
}
|
||||
canonical, err := canonicaljson.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical))
|
||||
document, err := canonicaljson.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
func stringSliceAsAny(values []string) []any {
|
||||
items := make([]any, len(values))
|
||||
for index, value := range values {
|
||||
items[index] = value
|
||||
}
|
||||
return items
|
||||
}
|
||||
Reference in New Issue
Block a user