232 lines
7.1 KiB
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
|
|
}
|
|
}
|