ADR-0001. Ed25519-signed, offline-verifiable license certificates via Redis
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-20 |
| Deciders | licensing-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 withAPP_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 getsEX = 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
| Pros | Cons |
|---|---|
| Zero runtime coupling — consumers don't call licensing | Asymmetric crypto + AES on every license publish (cheap, but real) |
Read path is a single Redis GET + local verify | Shared 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 missed | Feature edits via CRUD don't re-publish — cert can lag DB until a lifecycle action |
| Fail-open avoids a licensing outage taking down all services | Fail-open means a Redis outage silently degrades enforcement — gates must treat null deliberately |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Synchronous HTTP validate per request | Always fresh | Couples every service to licensing uptime + latency | Unacceptable hot-path cost & blast radius |
| JWT (asymmetric) instead of AES+Ed25519 envelope | Standard, widely tooled | Payload is readable (base64) — feature config exposed | Confidentiality requirement |
| Symmetric-only HMAC certificate | Simpler, one secret | Any consumer holding the secret can forge certs | Need issuer/verifier trust separation |
| Kafka broadcast of license changes | Push, event-sourced | Adds a broker dependency to a read-on-demand need; consumers must persist state | Over-engineered for read-heavy/write-rare; Redis cache is sufficient |
References
licensing/src/services/licensing/licensing-base.service.ts—publishCertificate()core/src/services/license/certs/signer.ts—LicenseCertSignerHelper.signcore/src/services/license/certs/verifier.ts—LicenseCertVerifierHelper.verifycore/src/middlewares/license/license.middleware.ts—LicenseProvider(fail-open)core/src/services/license/certs/types.ts—CertificateDefinitions.FULL_FORM = aes-256-gcm+ed25519