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/validateresponse, all 9 SDKs verify against a build-pinned public key, and theSAWABONA_CATALOG__REQUIRE_SIGNINGenforce knob exists (opt-in). What remains is operational, not code — see_SAWABONA/FUTURE_OPTIMIZATIONS.mdfor 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 withinexp+ the offline grace window. The offline grace window inherently replays a cached token —exp+ the grace policy are what bound that replay. Bind todevice_hashso 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:
- Verify the Ed25519 signature against a pinned public key matching
kid. - On signature failure → authoritative denial, never a cache fallback.
- Verify the signature on cache load, not only online — this is what closes C2.
- Enforce
nbf/exp; reject an expired entitlement even within the offline grace window. - Enforce
device_hashbinding. - 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):
- Sign ✅ — the engine signs the validation response / offline token (additive; old SDKs keep reading the unsigned body).
- 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.
- Enforce ✅ (opt-in) —
SAWABONA_CATALOG__REQUIRE_SIGNING=truemakes 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 — thesigned_entitlementfield 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.