Skip to content

ADR-0001. Ed25519-signed, offline-verifiable license certificates via Redis

FieldValue
StatusAccepted
Date2026-03-20
Deciderslicensing-team, platform-team
Supersedes

Context

  • Every request in every service (sale, commerce, inventory, …) needs to know a merchant's / user's license state, tier, and features.
  • A synchronous HTTP call to licensing on every request would couple all services to licensing's availability and add latency to the hot path.
  • License state changes infrequently (issue / suspend / renew / revoke), but is read constantly — a read-heavy, write-rare profile.
  • The data must be tamper-proof (a consumer must not be able to forge a "PERPETUAL/all-features" payload) and confidential (feature config should not be readable from a leaked Redis dump).

Decision

We will have licensing sign a self-contained certificate for each license and cache it in Redis at lic:certs:<entityType>:<entityId>. Consumers verify it offline via @nx/core's LicenseMiddleware — they never call licensing at runtime.

The certificate envelope is aes-256-gcm+ed25519:

  • The ILicenseCertificatePayload (status, tier, features, activation limit, expiry) is AES-256-GCM-encrypted with APP_ENV_APPLICATION_SECRET.
  • The ciphertext is Ed25519-signed with APP_ENV_LICENSING_ED25519_PRIVATE_KEY; consumers verify with the public key shipped in @nx/core.
  • The payload carries certExpiresAt; the Redis key also gets EX = APP_ENV_LICENSING_CERT_TTL_SECONDS (default 86400).

Licensing re-publishes after every lifecycle mutation (issue / suspend / reinstate / renew / revoke) and on lazy expiry. LicenseMiddleware is fail-open: missing / expired / unverifiable certs yield a null license context ("unknown", not "unlicensed").

Consequences

ProsCons
Zero runtime coupling — consumers don't call licensingAsymmetric crypto + AES on every license publish (cheap, but real)
Read path is a single Redis GET + local verifyShared APP_ENV_APPLICATION_SECRET must be distributed to all verifiers (symmetric secret sprawl)
Tamper-proof (Ed25519) + confidential (AES)Keypair rotation requires a coordinated public-key roll + re-publish
Stale-state bounded by TTL even if a publish is missedFeature edits via CRUD don't re-publish — cert can lag DB until a lifecycle action
Fail-open avoids a licensing outage taking down all servicesFail-open means a Redis outage silently degrades enforcement — gates must treat null deliberately

Alternatives Considered

OptionProsConsWhy rejected
Synchronous HTTP validate per requestAlways freshCouples every service to licensing uptime + latencyUnacceptable hot-path cost & blast radius
JWT (asymmetric) instead of AES+Ed25519 envelopeStandard, widely tooledPayload is readable (base64) — feature config exposedConfidentiality requirement
Symmetric-only HMAC certificateSimpler, one secretAny consumer holding the secret can forge certsNeed issuer/verifier trust separation
Kafka broadcast of license changesPush, event-sourcedAdds a broker dependency to a read-on-demand need; consumers must persist stateOver-engineered for read-heavy/write-rare; Redis cache is sufficient

References

  • licensing/src/services/licensing/licensing-base.service.tspublishCertificate()
  • core/src/services/license/certs/signer.tsLicenseCertSignerHelper.sign
  • core/src/services/license/certs/verifier.tsLicenseCertVerifierHelper.verify
  • core/src/middlewares/license/license.middleware.tsLicenseProvider (fail-open)
  • core/src/services/license/certs/types.tsCertificateDefinitions.FULL_FORM = aes-256-gcm+ed25519

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.