Skip to content

Certificate System

Overview

The licensing service publishes a signed, encrypted snapshot of every license — its status, tier, features, activation config, and expiration — to Redis whenever the license state changes. Other services (Commerce, Sale, Inventory, …) read this snapshot via LicenseMiddleware from @nx/core. They never call the licensing service for runtime licensing checks; the certificate carries everything they need.

This is "offline-capable" in the sense that consumer services don't need a synchronous round-trip to the licensing service for every request. They do still need Redis.

Architecture

What's in the payload

ILicenseCertificatePayload (from @nx/core/services/license/certs/types.ts):

ts
interface ILicenseCertificatePayload<FeaturesType = Record<string, unknown>> {
  license: { id: string; key: string };
  entity:  { type: string; id: string };          // e.g. { type: 'merchants', id: '<merchantId>' }
  status:  string;                                 // 'activated' | 'expired' | 'suspended' | 'revoked'
  tier:    string;                                 // policy.type — '000_TRIAL' / '100_SUBSCRIPTION' / '200_PERPETUAL'
  features: FeaturesType;                          // resolved feature flags (overrides applied)
  activation: { limit: number } | null;            // resolved activation config (override → policy → null)
  expiresAt: string | null;                        // license.expiresAt (ISO) or null for perpetual
  issuedAt: string;                                // ISO timestamp at signing time (NOT license.issuedAt)
  certExpiresAt: string;                           // signing time + APP_ENV_LICENSING_CERT_TTL_SECONDS
}

issuedAt is the signing time, not the license issuance time

publishCertificate() sets payload.issuedAt = new Date().toISOString() at the moment it builds the payload, which means it changes every time the certificate is republished. If you actually want the license's true issue date, read it from the database (license.issuedAt) — it isn't preserved in the certificate payload.

Signing process

Implemented by LicenseCertSignerHelper.sign({ payload, secret, privateKey }) in packages/core/src/services/license/certs/signer.ts.

The algorithm metadata stored in the envelope is the constant CertificateDefinitions.FULL_FORM = 'aes-256-gcm+ed25519'. The verifier rejects any envelope whose alg doesn't match this exact string.

Inputs

ArgumentSourceRequired
payloadBuilt by LicensingBaseService.publishCertificate() from the license, its policy, resolved features, and resolved activation config
secretAPP_ENV_APPLICATION_SECRET (passed in by the caller) — used as the AES-256-GCM key
privateKeyAPP_ENV_LICENSING_ED25519_PRIVATE_KEY (PEM string)

The AES secret is the application secret, not the license key

A common misunderstanding: the AES secret is APP_ENV_APPLICATION_SECRET, not license.key. The same secret is used by LicenseCertSignerHelper.sign() and LicenseCertVerifierHelper.verify(), which means every consumer service must be configured with the same APP_ENV_APPLICATION_SECRET as the licensing service that signed the certificate. This is a symmetric secret in addition to the asymmetric Ed25519 keypair.

Output envelope

ts
interface ICertificateEnvelope {
  enc: string;   // AES-256-GCM ciphertext (string-encoded by the AES helper)
  sig: string;   // Ed25519 signature, base64url-encoded
  alg: string;   // 'aes-256-gcm+ed25519'
}

The envelope is JSON-stringified and then base64-encoded. The base64 string is what ends up in license.certificate and in Redis.

Publishing process

LicensingBaseService.publishCertificate({ license }):

  1. Read APP_ENV_LICENSING_ED25519_PRIVATE_KEY. If missing → log warning and return early (no certificate generated).
  2. Read APP_ENV_APPLICATION_SECRET. If missing → log warning and return early.
  3. Read APP_ENV_LICENSING_CERT_TTL_SECONDS (default 86400).
  4. findPolicy({ id: license.policyId })
  5. resolveFeatures({ policyId, featuresOverride: license.override?.features ?? null })
  6. resolveActivation({ policy, license }){ limit } or null
  7. Build the payload (see issuedAt warning above).
  8. LicenseCertSignerHelper.getInstance().sign({ payload, secret, privateKey })
  9. licenseRepository.updateById({ id: license.id, data: { certificate } })
  10. If a Redis client is bound, redis.client.set('lic:certs:<entityType>:<entityId>', certificate, 'EX', certTtl)

Silent skip on missing config

The "log warning and return" branches in steps 1–2 mean a misconfigured environment will quietly issue licenses with no certificate. Consumers will then see null from LicenseMiddleware (which fail-opens) and act as if the merchant has no license at all. There is no startup-time validation of these env vars.

When publishing fires

TriggerWhy
LicenseManagementService.issue()Initial certificate after creation
LicenseManagementService.suspend()Status changes to suspended
LicenseManagementService.reinstate()Status changes back to activated
LicenseManagementService.renew()Status returns to activated and expiresAt extends
LicenseManagementService.revoke()Status changes to revoked
ValidationService.validate() (auto-expiry only)Status flips to expired after past-grace detection

PATCH /licenses/{id} (the standard CRUD update — used to set override, for example) does not trigger republication. Until a lifecycle action runs, the cached certificate keeps the old override values, even though POST /validation/validate (which queries the database fresh each call) will already reflect them.

Redis cache

Key patternValueTTL
lic:certs:{entityType}:{entityId}The base64 envelope as a single stringAPP_ENV_LICENSING_CERT_TTL_SECONDS (default 86400)

Examples:

  • lic:certs:merchants:01234567890123
  • lic:certs:users:01234567890456

The Redis value is a plain string

The cached value is just the base64 envelope as a string — not a JSON object like { certificate, licenseKey }. There is no licenseKey stored separately, because verification doesn't need it: the AES secret used by the verifier is APP_ENV_APPLICATION_SECRET.

Verification

Implemented by LicenseCertVerifierHelper.verify({ certificate, secret, publicKey }):

  1. base64-decode → JSON-parse → ICertificateEnvelope
  2. envelope.alg === 'aes-256-gcm+ed25519' — otherwise 400 Bad Request
  3. Reconstruct Buffer('license:<enc>') and Ed25519.verify(message, publicKey, sig) — failure → 401 Unauthorized (Certificate signature verification failed)
  4. aes.decrypt(envelope.enc, secret) → JSON-parse the plaintext → ILicenseCertificatePayload
  5. If payload.certExpiresAt < now.toISOString()401 Unauthorized (Certificate has expired)
  6. Return the payload

The verifier must be given:

ArgumentSource
certificateThe base64 envelope from Redis
secretAPP_ENV_APPLICATION_SECRET (must match the signer's)
publicKeyAPP_ENV_LICENSING_ED25519_PUBLIC_KEY (PEM)

Consumer side: LicenseMiddleware

Source: packages/core/src/middlewares/license/license.middleware.ts. Provided by @nx/core for any service that needs to read license context for the current authenticated user.

ts
class LicenseMiddleware extends BaseHelper implements IProvider<MiddlewareHandler> {
  static readonly LICENSE_CONTEXT_KEY = 'license';

  constructor(
    private readonly publicKey: string,           // APP_ENV_LICENSING_ED25519_PUBLIC_KEY
    private readonly applicationSecret: string,   // APP_ENV_APPLICATION_SECRET
    private readonly redis: DefaultRedisHelper,
  ) { ... }
}

How it runs

  1. Read the authenticated user from context.get(Authentication.CURRENT_USER). If absent, skip and next().
  2. Pull merchants: { id: string }[] and userId: string | undefined (from currentUser.sub ?? currentUser.id) off the auth context.
  3. In parallel:
    • For each merchant: resolveCertificate({ entityType: 'merchants', entityId: m.id })
    • For the user (if any): resolveCertificate({ entityType: 'users', entityId: userId })
  4. resolveCertificate reads lic:certs:<type>:<id> from Redis and runs the verifier helper. Any failure (Redis miss, deserialization error, signature failure, expired payload) is caught and returns nullfail-open.
  5. Set the merged result on context under LicenseMiddleware.LICENSE_CONTEXT_KEY = 'license':
ts
interface ILicenseContext {
  merchants: Record<string, ILicenseCertificatePayload | null>;
  user: ILicenseCertificatePayload | null;
}

Reading from a controller / service

ts
const licenseCtx = context.get('license') as ILicenseContext | undefined;
const merchantLicense = licenseCtx?.merchants[merchantId] ?? null;

if (!merchantLicense) {
  // Either no license, or verification failed (treated identically — "unknown")
}

if (merchantLicense?.status !== 'activated') {
  // Suspended / revoked / expired — gate the feature
}

if (merchantLicense?.tier === '000_TRIAL') {
  // Apply trial restrictions
}

const maxProducts = merchantLicense?.features['max_products'] as number | undefined;
if (maxProducts != null && currentCount >= maxProducts) {
  // Enforce limit
}

Fail-open consequences

The middleware deliberately never throws on certificate problems — Redis being down or a verification failure both produce null. Treat null as "unknown", not as "unlicensed". If you need a hard licensing gate, do the explicit check in your business logic and choose how to react when the value is null.

Environment variables

Full table (signer keys + the matching consumer keys) lives in Configuration. In short: the signer needs APP_ENV_APPLICATION_SECRET + APP_ENV_LICENSING_ED25519_PRIVATE_KEY + APP_ENV_LICENSING_CERT_TTL_SECONDS; every consumer needs the same APP_ENV_APPLICATION_SECRET plus the matching APP_ENV_LICENSING_ED25519_PUBLIC_KEY.

PageDescription
IntegrationThe cross-service trust seam
API EventsCert envelope + payload schemas
License LifecycleThe operations that trigger republishing
OperationsKeypair rotation runbook
ADR-0001Why Ed25519 + offline verification

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