Files
Garage-SDKs/docs/offline-licences.md
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

346 lines
14 KiB
Markdown

# Offline licences
How `garage_entitlements` checks a licence key, where it keeps them between
runs, and what it takes to do the same thing without the Flutter package.
There's a short bit on `garage_iap`'s older licence at the end, for contrast.
Everything here is read off the source in
`garage_entitlements/lib/src/` — `jwks_verify.dart` for the checks,
`key_cache.dart` for storage, `garage_entitlements.dart` for `refresh()` and
`cached()`. If this doc and the code ever disagree, the code wins.
- [What a key is](#what-a-key-is)
- [The checks](#the-checks)
- [Where keys are kept](#where-keys-are-kept)
- [refresh()](#refresh)
- [cached(), and the subject](#cached-and-the-subject)
- [Key rotation](#key-rotation)
- [The device clock](#the-device-clock)
- [Verifying without the package](#verifying-without-the-package)
- [garage_iap's licence, for contrast](#garage_iaps-licence-for-contrast)
## What a key is
One compact RS256 JWT per entitlement. The header carries `alg` and `kid`, the
payload looks like this:
```json
{
"sub": "user-123",
"iss": "https://pay.imbenji.net",
"aud": "field-notes/pro",
"project": "field-notes",
"sku": "pro",
"kind": "one_off",
"mode": "live",
"expires_at": "2026-10-03T00:00:00Z",
"iat": 1790000000,
"exp": 1790003600
}
```
| Claim | What it is |
| --- | --- |
| `sub` | the user id the key was minted for |
| `iss` | who signed it |
| `aud` | `"<project>/<sku>"` — one string, not an array |
| `project`, `sku` | the same two things again, split out |
| `kind` | `"one_off"` or `"subscription"` |
| `mode` | `"live"` or `"sandbox"` |
| `expires_at` | when the *entitlement* runs out, ISO 8601. Missing for something owned outright |
| `iat`, `exp` | seconds since epoch. `exp` is the *key's* expiry |
`exp` is the access decision. The server clamps it to the entitlement's own
expiry before signing, so a key cant outlive the thing it unlocks. `expires_at`
is for a UI to show ("renews on the 3rd") — dont gate on it.
The only claims that get checked are `iss`, `aud`, `sub`, `mode` and `exp`.
`project`, `sku` and `kind` are read into the `GarageKey` after the checks
pass, and `expires_at` becomes `entitlementExpiresAt`.
## The checks
`verifyKey()` in `jwks_verify.dart`. The order is fixed, and the first failure
stops it — any failure means not entitled. Every failure is an
`EntitlementsError` with a `code`, so a caller can treat them all the same.
Before the six proper checks there are two cheap ones: the token has to split
into three parts (`bad_token`) and the header `alg` has to be `RS256`
(`bad_alg`). No `none`, no HS256, no negotiating.
**1. Signature.** Look up the JWKS key whose `kid` matches the header's `kid`,
rebuild the RSA public key from its `n` and `e`, and check a PKCS#1 v1.5
SHA-256 signature over `header.payload`. That's pointycastle, the same RSA
stack the store signs with — there's no JWT library in the package, on
purpose. Only `kty: "RSA"` entries are considered.
A `kid` that isnt in the JWKS is refused (`kid_not_found`), not guessed at. The
package wont try the other keys to see if one happens to work. The one
exception: if the header has *no* `kid` at all, the first RSA key in the doc is
used.
A bad signature is `bad_signature`. A mangled signature that makes pointycastle
throw is logged and treated the same.
**2. `iss`.** Must equal the issuer the app was built with — the `issuer`
constructor argument, which defaults to `kGarageLicenceIssuer`:
```dart
const String kGarageLicenceIssuer = String.fromEnvironment(
"GARAGE_LICENCE_ISSUER",
defaultValue: "https://pay.imbenji.net",
);
```
So `--dart-define=GARAGE_LICENCE_ISSUER=...` overrides it at build time. The
point is that it's a constant the app checks against. The token doesnt get to
say who it is. Fails as `iss_mismatch`.
**3. `aud`.** Must be exactly `"<projectSlug>/<sku>"` — a single string. Right
project wrong sku fails, right sku wrong project fails, an array fails.
`aud_mismatch`.
**4. `sub`.** Must equal the signed-in user's id. Somebody elses key copied
onto this device fails here. `sub_mismatch`. Where the expected `sub` comes
from matters a lot offline, see [cached()](#cached-and-the-subject).
**5. `mode`.** Must equal the `mode` the `GarageEntitlements` was constructed
with (`"live"` by default). A sandbox key never satisfies a live check, and the
other way round. A missing `mode` fails too. `mode_mismatch`.
**6. `exp`.** A key with no `exp` is refused (`no_exp`). Otherwise, if the
device clock is past it, `expired`.
If all of that passes you get a `GarageKey`. `has(sku)` then just looks in the
verified set — and `key(sku)` re-checks `exp` every time it's asked, dropping a
key that went stale while the app was running.
## Where keys are kept
`KeyCache` is the seam. The default is `SecureKeyCache`, on
`flutter_secure_storage` — the same place `garage_auth` keeps its tokens.
One blob per project, under `ge.keys.<projectSlug>`:
```json
{
"keys": { "pro": "<compact jwt>", "extras": "<compact jwt>" },
"jwks": { "keys": [ { "kty": "RSA", "kid": "...", "n": "...", "e": "..." } ] }
}
```
The JWKS and the whole key set are written together, in one write. There's
deliberately no "keys but no JWKS" state, because that's a pile of tokens you
cant check.
What's on disk is the raw tokens, not a verdict. Every load re-verifies them.
Things worth knowing about the default store:
- A read that fails (or a blob that wont parse) is logged and treated as no
cache.
- A write or clear that fails is logged and **not** thrown. So a `refresh()`
can succeed in memory and still not have persisted — the next cold start
would see the old blob.
`MemoryKeyCache` is the in memory one, for tests or anywhere you dont want
persistence:
```dart
final ent = GarageEntitlements(
auth: auth,
projectSlug: "field-notes",
apiBaseUrl: "https://pay.imbenji.net/api",
cache: MemoryKeyCache(),
);
```
`clear()` deletes the blob and empties the in memory set. Call it on sign-out.
The `sub` check would reject the old user's keys anyway, but there's no reason
to leave them on disk.
## refresh()
```
GET <apiBaseUrl>/v1/licences?project=<projectSlug>[&ttl=<seconds>]
```
with the signed-in user's bearer. The response carries the keys and the JWKS
together:
```json
{
"licences": [ { "sku": "pro", "licence": "<compact jwt>", ... } ],
"jwks": { "keys": [ ... ] }
}
```
What it does with it, in order:
1. No `jwks` object in the body is an error (`no_jwks`). Nothing changes.
2. Work out the expected `sub` (see below).
3. For each entry, take the sku from the token's own `aud` — not the envelope's
`sku`, which is a convenience and ignored — and run the full
[six checks](#the-checks) against the JWKS that came in the same response.
A token with an `aud` it cant split into project/sku is `bad_token`.
4. If **any** key fails, the whole refresh throws. The cache and the in memory
set are left exactly as they were.
5. Only once every key has verified: write the new blob (all keys + the new
JWKS), then replace the in memory set and notify listeners.
Replace, not merge. A sku missing from the response is gone, from memory and
from disk. That's how a cancelled subscription stops working the next time the
app is online, rather than hanging on untill its key's `exp`. An empty
`licences` list empties the set.
`ttl` asks for shorter keys. The server clamps it, so asking for longer than
the project allows doesnt error, it just doesnt get you longer.
## cached(), and the subject
`cached()` is the boot path: read the blob, verify each key, fill the set. It
never fetches keys. But it does need the expected `sub` for check 4, and that
is the one place it can need the network.
The subject comes from `_subject()`:
- The first time it's asked, it calls `auth.profile()`, which is the OIDC
userinfo endpoint (plus the discovery document, if `garage_auth` hasnt
fetched that yet this run). It takes `sub` out of the answer.
- After that it's remembered on the `GarageEntitlements` instance, so later
calls are free.
So what happens depends on whether that instance has resolved a subject yet:
- **It has** — say `refresh()` ran earlier this session. `cached()` is fully
offline.
- **It hasnt, and userinfo is reachable** — one round trip, then offline.
- **It hasnt, and userinfo isnt reachable** — a cold start with no network is
the usual one. `profile()` throws, `cached()` logs "cant resolve the subject",
and **drops every key** from the in memory set. `has()` is false for
everything.
- **The user isnt signed in** — `profile()` returns null, there's no `sub`
(`no_subject`), same result: every key dropped.
In the last two cases only the in memory set is emptied. The blob on disk is
left alone, so a later `cached()` that can resolve the subject picks the keys
straight back up.
Note that it's the first `cached()` on a fresh instance that makes the call, so
"verifies whats on disk, zero network" in the README holds once the subject is
known, not on a cold offline launch. If your app has to unlock features on a
plane from a cold start, that's the path to plan around.
Once the subject is resolved, each cached key is checked on its own. A key that
fails — expired is the normal case, but also rotated away, wrong user, wrong
mode — is logged and dropped, and the rest are kept. One dead key doesnt take
the set with it. The sku it's checked against is the map key it was stored
under, so a token filed under the wrong sku fails `aud`.
The remembered subject lives as long as the instance and `clear()` doesnt reset
it. If a different user can sign in without the app restarting, give them a new
`GarageEntitlements` rather than reusing the old one.
## Key rotation
Rotation needs nothing from the app.
The JWKS always arrives in the same response as the keys, and is cached with
them, so the keys on disk are always paired with the JWKS they were checked
against. While both old and new signing keys are in the published JWKS, keys
signed by either verify (there's a test for exactly that). When a key is
finally rotated out, any cached key still signed by it fails `kid_not_found` on
the next `cached()` — and the next `refresh()` brings down fresh keys *and* the
new JWKS together, so it sorts itself out the next time the app is online.
## The device clock
Offline, `exp` is compared against the device clock, and the device clock can
be wound back. That's an accepted limitation. The alternative is refusing to
work without a network, which is the thing offline keys exist to avoid.
What bounds it is the key lifetime. The shorter the TTL, the more often the app
has to come online and fetch fresh keys anyway.
## Verifying without the package
If you're checking a key somewhere else — a backend, a CLI, another language —
it's the same job.
**Getting keys and the JWKS.** The package gets both from one call:
```
GET https://pay.imbenji.net/api/v1/licences?project=<projectSlug>
Authorization: Bearer <the user's access token>
```
`ttl=<seconds>` is optional. The body is `{"licences": [{"licence": "<jwt>", ...}], "jwks": {...}}`.
`garage_entitlements` doesnt call a separate JWKS endpoint — it only ever uses
the `jwks` that comes back in that body — so that's the one to use.
If you're holding a key you got some other way, you still need the JWKS that
goes with it. Keep them together, like the package does.
**Checking one.** In this order, and treat any failure as not entitled:
0. Three dot-separated parts. Header `alg` is exactly `RS256` — reject anything
else before looking further.
1. Find the JWKS entry with `kty: "RSA"` and a `kid` equal to the header's
`kid`. Not found = reject. Dont fall back to trying every key. Verify
RSASSA-PKCS1-v1_5 with SHA-256 over the ASCII bytes of
`<header>.<payload>` (the base64url strings as they are in the token), using
the public key from base64url `n` and `e`.
2. `iss == "https://pay.imbenji.net"`. Hard-code it. Dont read it from the
token and trust it.
3. `aud == "<project>/<sku>"`, as a plain string, for the project and sku
you're gating.
4. `sub ==` the user you think you're talking to, from your own session — not
from the token.
5. `mode ==` `"live"` (or `"sandbox"` while the project is in sandbox). Missing
is a fail.
6. `exp` present, and now is before it.
Most JWT libraries will do 1, 2, 3 and 6 for you if you give them the JWKS, pin
the algorithm to RS256 and tell them the issuer and audience. `sub` and `mode`
you check yourself.
On a server you have a proper clock and a network, so the offline caveats dont
apply — and if all you want is "does this user own it right now", the
`/v1/entitlements` ledger is the truth anyway. The key is for when you cant
ask.
## garage_iap's licence, for contrast
`garage_iap` predates all of this and works differently. One licence JWT for
the whole app, not one per entitlement:
- Fetched from `GET <apiBaseUrl>/v1/licence?app=<appSlug>`, with the JWKS
fetched separately from `GET <apiBaseUrl>/v1/licence/jwks.json` in the same
trip.
- Its payload has an `app` claim and a `products` claim — a list of
`{sku, kind, expires_at}` for everything the user owns in that app.
- Cached as `{token, jwks}` under `gi.licence.<appSlug>` via the
`LicenceCache` seam (`SecureLicenceCache`, or `MemoryLicenceCache` for
tests). It's only written after it verifies.
`verifyLicence()` checks less: `RS256`, the signature by `kid` (same lookup
rules as above), then `sub`, then `app`, then `exp`. There's no `iss`, no
`aud`, and no `mode` check. `has(sku)` then asks whether the sku is anywhere in
`products` — the per product `expires_at` isnt looked at, only the licence's
own `exp`.
That's the wallet problem the per-entitlement keys were built to avoid: a
lock that only cares about one feature gets handed the list of everything the
user owns. It also resolves the subject through `profile()` the same way, so
the same cold-offline caveat applies — except there a failure to resolve it
throws out of `cachedLicence()` rather than quietly emptying the set.