Skip to content

Client-Verifiable Entitlements via Ed25519 Signing

Version: 1.0 (Implemented) Last Updated: 2026-06-14 Status: 🟢 Shipped — all three stages (Sign / Verify / Enforce) merged 2026-06-12 Scope: sawabona engine (signer) + all 9 SDKs (verifiers) Closes: audit findings C1 (validation JWT not client-verifiable) and C2 (forgeable offline cache seal) from the 2026-06-12 review.

Status note (2026-06-14). This document was written as a design RFC and is kept as the design rationale. The feature is shipped: the engine signs the /api/v1/licenses/validate response, all 9 SDKs verify against a build-pinned public key, and the SAWABONA_CATALOG__REQUIRE_SIGNING enforce knob exists (opt-in). What remains is operational, not code — see _SAWABONA/FUTURE_OPTIMIZATIONS.md for the current-state summary and the rollout steps. Read the sections below as the why, not as pending work.


1. Problem

The license validation response carries the entitlement (plan, features, expiry) inside a JWT signed HS256 (symmetric) — EncodingKey::from_secret(...) in sawabona-core/src/services/license.rs. HS256 means the same secret signs and verifies, so only the server (and gateways sharing the secret) can verify it. An end-user SDK client has no secret and must never ship one, so today the SDK cannot cryptographically verify the entitlement at all.

Consequences:

  • C1 — online, the SDK trusts the server purely over TLS; there is no cryptographic integrity anchor it can check itself.
  • C2 — the offline cache is sealed with an HMAC whose key derives from a locally-reconstructable device hash (sha256(product_slug:hostname)), so a local attacker can forge a cached entitlement and have it honored for the whole offline grace window.

2. Threat model (why TLS is not enough)

For a licensing / monetization product, the adversary is frequently the operator of the client machine (they want to unlock features they did not pay for, or extend an expired licence offline). That adversary controls their own network, DNS, filesystem, and any data the client merely receives. TLS protects data in transit against a third party; it does not protect an entitlement at rest or served offline against the machine's own operator.

The conclusion that drives every decision below: the entitlement must be a self-verifying artifact, signed by a key the client cannot possess, and verified against a public key the client cannot substitute.

3. Decision

Issue every entitlement as a fixed-format envelope signed with Ed25519, and have each SDK verify it against a public key pinned at SDK build time.

This is deliberately not a JWT. See §8 for the rationale.

3.1 Reuse, don't reinvent

The engine already produces Ed25519-signed, client-verifiable artifacts:

  • offline license tokens (issue_offline_license_token)
  • catalog snapshots (sawabona-core/src/catalog/snapshot.rs)

The recommendation is to generalize the existing offline-token mechanism into the single entitlement artifact returned by /validate (online) and stored in the cache (offline). One code path, already exercised, closes both C1 and C2: online responses become verifiable, and a forged cache entry fails the signature.

4. The signed envelope

A versioned, canonical structure — conceptually:

SignedEntitlement {
  v: 1,                       # envelope version (NOT an algorithm selector)
  kid: "ed25519-2026-06",     # key id, for rotation (§6)
  payload: <canonical bytes>, # the claims below, canonically encoded
  sig: <Ed25519 signature over (v ‖ kid ‖ payload)>
}

payload claims (all REQUIRED unless noted):

Claim Purpose
license_id the licence this asserts
tenant_id tenant scope
plan, plan_name entitlement
features entitlement
iat, nbf, exp freshness bounds (see §7)
jti / nonce uniqueness, anti-replay correlation
device_hash binds the artifact to one machine (anti-copy)
challenge_nonce binds an online response to the proof exchange
quota (opt) metering ceiling

Canonical encoding: deterministic JSON (sorted keys, no insignificant whitespace) or a fixed binary layout — match whatever the existing offline-token / catalog-snapshot code already does, so there is one canonicalizer in the codebase, not two. Canonicalization bugs are a classic signature-bypass source; do not hand-roll a second scheme.

5. Key distribution — PINNED, not fetched

The Ed25519 public key(s) MUST be compiled into each SDK binary as a constant (the trust anchor).

Do NOT distribute the public key via a JWKS / .well-known endpoint for the client verifier. Against a network MITM, JWKS-over-HTTPS is fine — but here the attacker controls their own machine: they would serve their own JWKS and their own validly-signed token, and everything would verify. A fetched key is no trust anchor when the fetcher is the adversary. JWKS remains acceptable for server-to-server / gateway verification, never for the anti-piracy client path.

6. Key rotation

  • The envelope carries a kid.
  • Each SDK ships an array of trusted public keys (current + next).
  • Rotate by: (1) ship an SDK release that trusts the new kid; (2) wait for adoption; (3) switch the signer to the new key. Overlap window prevents lockout.
  • Reuse the engine's existing key-versioning socle (SAWABONA_ENCRYPTION_KEY_VERSION / Keyring::from_env, commit 93d2b71) for the signing keyring — same pattern, different key type.

7. Freshness & revocation (residual limitation — assume it honestly)

A signature proves authenticity, not currency. A signed-but-revoked licence is still validly signed. Asymmetric signing does not by itself solve offline revocation.

  • Primary mechanism: a short exp (e.g. 24–72 h) forces periodic re-validation; revocation propagates within exp + the offline grace window. The offline grace window inherently replays a cached token — exp + the grace policy are what bound that replay. Bind to device_hash so the token can't be copied to another machine.
  • Optional, higher assurance: a signed (Ed25519) revocation feed (CRL-style), distributed and verified offline.

Recommendation: ship the short-exp mechanism first; treat the signed revocation feed as a later add-on only if a customer needs near-real-time offline revocation.

8. Why a fixed envelope instead of a JWT

JWT brings algorithm agility, which here is a footgun, not a feature:

  • Algorithm confusion — the canonical JWT vuln: if the client holds a public key and the JWT library accepts HS256, an attacker uses the public key as an HMAC secret and forges a token. With a public key on the client this is a live risk.
  • alg: none — must be explicitly rejected.

Because Sawabona controls both ends and already has an Ed25519 primitive in the proof kernel, a fixed envelope with no algorithm field removes the entire algorithm-negotiation attack surface. This is also what mature licensing systems do (e.g. Keygen's signed licence files are not JWTs). If a JWT is used anyway for some internal audience, the verifier MUST pin alg = EdDSA and reject none / HS*.

9. HS256 → EdDSA-only for the entitlement

Asymmetric verification serves every audience: the gateway can verify with the public key too (it does not need the private key). Switching the entitlement to EdDSA-only therefore also eliminates the shared-HS256-secret distribution problem. Keep HS256 only if a measured, high-throughput internal verify path justifies symmetric speed (unlikely — Ed25519 verification is fast). Before removing the HS256 secret, confirm it is not coupled to any other use (e.g. sessions).

10. SDK verification contract (lock with tests)

Every SDK MUST:

  1. Verify the Ed25519 signature against a pinned public key matching kid.
  2. On signature failure → authoritative denial, never a cache fallback.
  3. Verify the signature on cache load, not only online — this is what closes C2.
  4. Enforce nbf / exp; reject an expired entitlement even within the offline grace window.
  5. Enforce device_hash binding.
  6. Treat an unknown kid (no matching pinned key) as denial, not as "skip check".

11. Rollout (additive, non-breaking) — SHIPPED

All three stages are merged (2026-06-12):

  1. Sign ✅ — the engine signs the validation response / offline token (additive; old SDKs keep reading the unsigned body).
  2. Verify ✅ — all 9 SDKs verify the envelope and treat a bad signature as denial — but only once the consuming app pins a public key. With no key pinned, an SDK falls back to TLS-trust and reads the JWT claims; a verify-capable SDK version alone does not enforce anything.
  3. Enforce ✅ (opt-in) — SAWABONA_CATALOG__REQUIRE_SIGNING=true makes the engine always emit a signed entitlement and fail loudly (500) if the signing key is missing, rather than serving an unsigned body. It does not reject legacy clients — the signed_entitlement field stays additive, so an old SDK that ignores it keeps working.

What remains is operational only (flip the enforce flag in prod; pin the public key in each consuming app). See _SAWABONA/FUTURE_OPTIMIZATIONS.md.

12. Alternatives considered

Option Verdict
Keep HS256, verify client-side ❌ impossible without shipping the secret (fake control)
RS256 / ES256 ➖ works, but RSA padding / curve footguns; Ed25519 already in use
JWT (EdDSA) ➖ acceptable with strict alg pinning, but adds algorithm-confusion surface
Fixed Ed25519 envelope, pinned key ✅ recommended — reuses existing primitive, no alg footgun, real anti-piracy trust anchor
Public key via JWKS for the client ❌ no trust anchor when the fetcher is the adversary

13. One-line summary

Generalize the existing Ed25519 offline-token into the single entitlement artifact, returned online and cached offline, verified client-side against a build-time-pinned public key, in a fixed signed envelope with no alg field, with kid-based rotation and a short exp for revocation.