Version 3 · 2025‑07‑14 – for team review
Goal: One crypto primitive (Ed25519) for everything (JWT, playlist signatures), while letting wallets (EVM secp256k1, Tezos ed25519/P‑256) and classic passkeys (P‑256) coexist. Resource services must verify tokens offline; only Auth‑Server mints them.
| Actor class | Trust anchor | Example sub (JWT) |
|---|---|---|
| Human (web / mobile) | Client‑generated Ed25519 key bound at first login | did:key:z6Mk… |
| FF1 Device | TPM‑sealed Ed25519 DeviceKey | did:key:z6Mkhw… |
| Internal service | Vault‑held Ed25519 key | svc:search |
| Automation agent | Vault‑held Ed25519 key | agent:curate‑v1 |
Every public signing key is exposed in JWKS form so all verifiers share one parser.
| Token | TTL | Carries |
|---|---|---|
| Access | 15 min | sub, aud, exp, scope, actor_type, jti |
| Refresh | 7 days | Opaque handle → mint new Access |
| Device | 30 days | Same as Access + device scopes (device:send) |
All tokens are EdDSA‑signed JWTs; resource services verify signature + claims with cached JWKS — no hop back to Auth‑Server.
The jti (JWT ID) is a unique identifier (e.g. a UUID-v7) included in each token to prevent replay attacks:
- Uniqueness: Auth-Server generates a fresh
jtifor every issued token. - Replay guard: For short-lived flows (Client-assertion and Access tokens), Auth-Server stores the
jtiin Redis with a TTL of 2 × token lifetime. If it sees the samejtiagain, it rejects the request with 401 Unauthorized. - Auditability:
jtican be logged to trace exactly which token was used for a specific API call.
This mechanism ensures that even if an attacker steals a valid token or assertion, reusing it is infeasible once the jti has been seen.
All tokens are EdDSA‑signed JWTs; resource services verify signature + claims with cached JWKS — no hop back to Auth‑Server.‑signed JWTs**; resource services verify signature + claims with cached JWKS — no hop back to Auth‑Server.
- Compact (32 B pub, 64 B sig) → cheaper playlists
- Constant‑time, audit‑friendly
- Faster verify (~2× vs P‑256 on Intel N100)
- Already used for DP‑1 capsules → one code path
-
Generate PKCE pair (client‑side)
code_verifier = 32 random bytes → BASE64URL code_challenge = BASE64URL( SHA‑256(code_verifier) ) -
Kick off the authorisation request
GET /authorize? response_type = code client_id = ff-web redirect_uri = https://app.feralfile.com/callback scope = playlist:write follow:* code_challenge = <code_challenge> code_challenge_method = S256 state = <128‑bit random> -
Auth‑Server renders login UI and returns a one‑time nonce (also feeds wallet/passkey challenge).
-
Client completes credential proof and signs the same nonce with the freshly generated Ed‑key (see Branch A/B below).
-
Auth‑Server validates proofs → issues an authorization code (TTL 60 s, single‑use) via
302back to theredirect_uri:https://app.feralfile.com/callback?code=<auth_code>&state=<state> -
Client immediately exchanges the code:
POST /token grant_type = authorization_code code = <auth_code> code_verifier = <original 43‑byte verifier> client_id = ff-web redirect_uri = https://app.feralfile.com/callbackAuth‑Server hashes
code_verifier, checks it equals the storedcode_challenge; mismatch ⇒ 400. -
Auth‑Server returns tokens differently for web vs mobile:
-
Web (browser‑based
client_id = ff-web) — replies 200 OK with headers:Set-Cookie: ff_access=<JWT>; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax Set-Cookie: ff_refresh=<opaque>; Max-Age=604800; Path=/auth/token; Secure; HttpOnly; SameSite=Strict
Response body:
{ "token_type": "cookie" }Access token travels automatically in subsequent HTTPS requests as the
ff_accesscookie. JavaScript cannot read or modify it (HttpOnly) → mitigates XSS token theft. -
Mobile / CLI — keeps the previous JSON payload:
{ "access_token": "<JWT – 15 min>", "refresh_token": "<opaque – 7 days>", "token_type": "Bearer", "expires_in": 900 }
Client storage
- Web – no storage API needed; browser handles cookies. Silent refresh happens when the front‑end POSTs
/token(cookies accompany the call). - Mobile/CLI – SDK caches tokens in OS secure storage and adds
Authorization: Bearerheader on each request. (memory / secure-storage); subsequent API requests addAuthorization: Bearer <access_token>.
- Silent refresh: SDK calls
/tokenwithgrant_type=refresh_tokenbefore expiry to roll tokens.
Why PKCE? If an attacker steals the short‑lived authorisation code (step 5) they still cannot finish step 6 because they do not know the secret
code_verifier. This removes the need for a hidden client‑secret in SPA/mobile apps.
- After an Ed‑key is bound, the client switches to the lighter client‑credentials + client‑assertion flow (EdDSA‑signed JWT) and no longer needs
/authorize.
4.2 Branch A · Wallet (chain‑native key — secp256k1 for EVM, ed25519/P‑256 for Tezos) Branch A · Wallet (chain‑native key — secp256k1 for EVM, ed25519/P‑256 for Tezos)
sequenceDiagram
participant B as Browser
participant W as Wallet
participant A as Auth‑Server
B->>W: personal_sign("Bind #nonce")
W-->>B: wallet_sig
Note over B: generate Ed25519 key (WebCrypto)
B->>A: POST /bind-wallet {ed_pub, wallet_sig, ed_sig, nonce}
A-->>B: 200 OK (did:key…)
ed_sig = Ed-key signs same nonce → prevents key‑injection.
sequenceDiagram
participant M as Mobile App
participant A as Auth‑Server
M->>A: WebAuthn (P‑256) response
A-->>M: nonce
Note over M: generate Ed‑key in Secure Enclave
M->>A: /bind-passkey {ed_pub, passkey_sig, ed_sig, nonce}
A-->>M: did:key bound
Older browsers repeat this once; modern alg‑‑8 passkeys skip bind (they are Ed25519).
Each user gets a JWKS‑compatible list at GET /v1/users/{uid}/jwks:
{"keys":[{"kid":"browser‑20250714","kty":"OKP","crv":"Ed25519","x":"11qY…","use":"sig"},…]}Verifiers use the same helper they use for global JWKS.
- Service loads its Ed private key from Vault.
- Mints 60‑s client‑assertion JWT (
jti = uuid‑v7). POST /tokengrant_type=client_credentialsclient_assertion=<jwt>scope=search:index.- Replay guard — Auth‑Server stores each
jtiin Redis for 2× TTL → duplicate ⇒ 401. - Auth‑Server returns 5‑min Access JWT (
actor_type="service").
Resource API checks actor_type, signature, scopes; no DB hit.
- Factory records
pubkey → serialin Device Registry. - First boot: FF1 signs nonce ➜
/auth/device {pub, sig}. - Auth‑Server validates row, issues 30‑day Device JWT.
- Device stores JWT in RAM; refreshes when online.
| Web | Mobile |
|---|---|
| No backup by default – every browser generates its own Ed‑key; if lost, user re‑logs with wallet/passkey, binds a new key. | Key stored in Secure Enclave / Keystore and syncs via OS backup. |
Optional export – SDK can exportKey("pkcs8"), encrypt with passphrase (Argon2 + AES‑GCM) ➜ user downloads JSON/QR for cold backup. |
App settings expose a “Trusted browsers” list (kid, added_at) with Revoke button.
## 8 · Scope & ACL chain
- Auth‑Server:
granted = requested ∩ client_allow ∩ role. - Resource middleware:
RequireScope("playlist:write"). - Casbin (Orbit 1‑2) or OPA (Orbit 3) for row/attr rules.
| Orbit | Target date | Auth milestone |
|---|---|---|
| 0 | Q3‑2025 | API‑GW verifies Ed25519 JWT; FF1 bootstrap live |
| 1 | Q4‑2025 | Wallet + Passkey bind flows; Service client‑assertion; per‑user JWKS endpoint |
| 2 | Q1‑2026 | Curator multi‑sig (playlist signatures[] use user JWKS) |
| 3 | Q3‑2026 | OPA side‑car, geo/licence policies; secure‑element signing option |
- Client generates & owns the Ed25519 key → server never touches secrets.
- Auth‑Server signs; everyone else verifies offline.
- JWKS everywhere – global (
/.well‑known/…) + per‑entity (/users/{uid}/jwks) = one parser. - Replay‑guard + 5‑min tokens keep leak blast‑radius tiny.
Ready for critique — highlight any edge‑case or integration concern the draft missed and we’ll fold it in.