Files
soft_quay/core/licensing/revocation.go

232 lines
7.1 KiB
Go

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
}
}