feat(gate): proof-of-possession sync tickets
- commit
- c17df3e58a06c14e0d81fdf82015ab44b6b54e62
- parent
- a33d52a00b42c0f3a297289329557dc1fabb5b5d
- parent
- 536ce96839f1f0812c9341bd2c8e83bc7f5ca4d9
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | docs/ | 59 | +++++- |
| A | docs/ | 23 | ++ |
| M | docs/ | 10 | + |
| M | docs/ | 11 | +- |
| M | src/ | 72 | +++++++- |
| M | src/ | 212 | ++++++++++++++++++++- |
| A | tests/ | 304 | ++++++++++++++++++++++++++++++ |
@@ -74,11 +74,18 @@ POST <ticket.url>/derive-sync-ticket
7474 - **Auth**: the provision-scoped secret in the URL path, like every gated request. A sync-scoped or7575 unknown secret gets the same `401 {"error":"invalid_ticket"}` as any unauthorized request —7676 nothing to enumerate. Non-POST methods on a provision ticket get `405`.77-- **Request body** (optional): `{ "label": "laptop" }`. Absent, the label defaults to78- `<parent label> (sync)` (parent id when unlabelled).77+- **Request body** (optional): `{ "label": "laptop" }`, and optionally a device public key to bind78+ the derived ticket to possession (below):79+ `{ "label": "laptop", "devicePublicKey": { "alg": "ES256", "spki": "<base64url DER SPKI>" } }`.80+ Absent a label, it defaults to `<parent label> (sync)` (parent id when unlabelled). A malformed or81+ wrong-curve `devicePublicKey` is `400 {"error":"invalid_device_key"}` — a client bug, not an auth82+ probe. Nodes that predate the field ignore it, and the response then carries no `pop` member: the83+ client must fall back to bearer behavior.7984 - **Response**: `200 {"v": 1, "id": "<ticket id>", "ticket": "lofisync1.…"}` — a complete80- sync-scoped ticket string. Its `url` uses the node's configured public base (`--public-url`),81- exactly like CLI-issued tickets; the secret is embedded once and never stored.85+ sync-scoped ticket string, plus `"pop": true` when a device key was bound. Its `url` uses the86+ node's configured public base (`--public-url`), exactly like CLI-issued tickets; the secret is87+ embedded once and never stored. The ticket string itself is unchanged by binding — the client88+ knows the ticket is bound because it offered the key and received `pop: true`.8289 8390 The derived ticket's record carries the parent's id, and **revocation cascades**: once the parent is8491 revoked (or its record removed), every ticket derived from it fails verification exactly like a@@ -88,6 +95,42 @@ escalate and effective chains are one level deep; the parent check still walks t
8895 `lofi-node ticket list` shows derived tickets as `[from <parent id>]` and reports them REVOKED once8996 their lineage is.9097 98+## Proof-of-possession binding99+100+A derived sync ticket can be bound to a device keypair at derive time (the `devicePublicKey` field101+above). On a bound ticket, the bare secret opens only the possession exchange; every other request102+must ride a connect token minted by a fresh signature from the bound key — an exfiltrated ticket103+string alone no longer connects.104+105+```106+POST <ticket.url>/pop/challenge107+→ 200 { "v": 1, "id": "<challenge id>", "nonce": "<43-char base64url>", "expiresIn": 120 }108+109+POST <ticket.url>/pop/answer { "v": 1, "id": "<challenge id>", "sig": "<base64url raw r||s>" }110+→ 200 { "v": 1, "connect": "<43-char base64url>", "expiresIn": 86400 }111+→ 401 { "error": "invalid_ticket" }112+```113+114+- **Signed message** (exact bytes, UTF-8):115+ `"lofisync-pop-v1\n" + appId + "\n" + ticketId + "\n" + nonce` — the app id and ticket id bind the116+ signature to this store and ticket, the single-use nonce prevents replay. The signature is117+ WebCrypto ECDSA P-256 over SHA-256 in raw `r||s` form.118+- **One rejection shape.** Bad signatures, expired, unknown, and already-consumed challenges all119+ answer `401 {"error":"invalid_ticket"}` — nothing to enumerate. A challenge dies on its first120+ answer attempt, valid or not. Challenges expire after 120 seconds; at most 32 are outstanding per121+ ticket (the oldest is dropped past that).122+- **The connect token** is appended to the enrolled `serverUrl` as a path segment —123+ `<ticket.url>/c/<connect>` — which jazz clients preserve exactly like the ticket secret itself.124+ The node stores only the token's digest. Its lifetime slides: each authenticated use extends it,125+ so a live sync session never expires mid-flight. Tokens are memory-only — a node restart126+ invalidates them and the client re-runs the exchange; revocation kills them with the ticket.127+- **Unbound tickets are unchanged** (pure bearer), and a bearer ticket calling `/pop/*` gets128+ `400 {"error":"pop_not_bound"}`. Conformance fixtures for the signature format live in129+ `docs/fixtures/pop-fixtures.json` (a test-only extractable keypair — never use outside tests).130+- **Binding is not downgrade-proof against node replacement**: a node build that predates the `pop`131+ record field serves the ticket as pure bearer. The record field is forward-compatible132+ (`tickets.json` stays v1), so a later node honors bindings made before a rollback.133+91134 ## Store-status preflight92135 93136 Against a store with **no deployed schema, client writes hang indefinitely** — so a sync-only client@@ -143,8 +186,12 @@ setups hold the admin secret and can query Jazz directly.
143186 144187 ## Security notes145188 146-- The ticket is a **bearer credential** (256-bit entropy). Anyone holding it can sync as an147- authorized transport peer; identity/permissions remain Jazz's local-first layer on top.189+- An unbound ticket is a **bearer credential** (256-bit entropy). Anyone holding it can sync as an190+ authorized transport peer; identity/permissions remain Jazz's local-first layer on top. A191+ possession-bound ticket downgrades theft of the string alone to nothing — connecting requires a192+ signature from the device key, which never leaves the device. Script running on the app's own193+ origin can still drive the signer while the page runs; binding defeats exfiltration, not live194+ same-origin abuse.148195 - Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS149196 (the URL scheme in the ticket may be `https`). Installed PWAs generally require a secure origin150197 anyway.@@ -0,0 +1,23 @@
1+{2+ "v": 1,3+ "note": "Test-only extractable P-256 keypair for cross-repo proof-of-possession conformance. Never use outside tests.",4+ "alg": "ES256",5+ "privateJwk": {6+ "kty": "EC",7+ "alg": "ES256",8+ "crv": "P-256",9+ "d": "PzjVU88vqolSBytZmvr7J-NsmiXTGFWGmTZDduPZ9Mg",10+ "x": "ntqUm63M96Ql075mLIuJLyEp4XW5TQ2FSv8OIsOWD2A",11+ "y": "IkLhIUQUOJhAhdMe353MnNZrKipkfWa8Bo0LNW4NQs4",12+ "key_ops": [13+ "sign"14+ ],15+ "ext": true16+ },17+ "spki": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEntqUm63M96Ql075mLIuJLyEp4XW5TQ2FSv8OIsOWD2AiQuEhRBQ4mECF0x7fncyc1msqKmR9ZrwGjQs1bg1Czg",18+ "appId": "pop-fixture-app",19+ "ticketId": "abcdef123456",20+ "nonce": "AwoRGB8mLTQ7QklQV15lbHN6gYiPlp2kq7K5wMfO1dw",21+ "message": "lofisync-pop-v1\\npop-fixture-app\\nabcdef123456\\nAwoRGB8mLTQ7QklQV15lbHN6gYiPlp2kq7K5wMfO1dw",22+ "signature": "RlRq_HLxLrDCDfSWVzX898RtTZVH5KQkEOFjd7OBnOsXBShAzfkft-d7HwM9h91QjvJ9UNbXw5FFegZUNlKzdA"23+}@@ -18,10 +18,20 @@ loopback-only Jazz server. This page is the route-level reference for a **ticket
1818 | `/t/<secret>/apps/<appId>/…` | any scope | Catalogue reads and other app routes, prefix-stripped and proxied. |1919 | `/t/<secret>/apps/<appId>/admin/…` | `provision` | Store administration; the node's admin secret is injected server-side. |2020 | `POST /t/<secret>/derive-sync-ticket` | `provision` | Mints a parent-linked, sync-scoped ticket (below). |21+| `POST /t/<secret>/pop/challenge` | bound only | Mints a proof-of-possession challenge (below). |22+| `POST /t/<secret>/pop/answer` | bound only | Verifies a device signature; answers with a connect token. |23+| `/t/<secret>/c/<token>/…` | bound only | Every gated route of a bound ticket, behind its connect token. |2124 2225 The `<secret>` is a 43-character base64url path segment; the gate compares digests in constant time,2326 strips the prefix, preserves the query string, and forwards.2427 28+On a **possession-bound** ticket (one derived with a `devicePublicKey`), the bare secret opens only29+`/health` and the `/pop/*` exchange; every other route answers `401` until the request carries a30+live connect token under `/c/<token>`. Unbound tickets are unaffected, and a bearer ticket calling31+`/pop/*` gets `400 {"error":"pop_not_bound"}`. The full exchange contract — signed-message bytes,32+single-use challenges, token lifetime — is normative in the app-ticket contract33+(`docs/app-ticket.md`).34+2535 ## health2636 2737 ```jsonc@@ -54,12 +54,19 @@ less than it claims.
5454 5555 ## The security posture, honestly5656 57-An app ticket is a **bearer credential** with 256-bit entropy. Anyone holding it can sync as an58-authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on57+An app ticket starts as a **bearer credential** with 256-bit entropy. Anyone holding it can sync as58+an authorized transport peer; identity and row-level permissions remain Jazz's local-first layer on5959 top, enforced by the deployed policy, not by the ticket. Plain http is acceptable on a trusted LAN;6060 beyond one, front the gate with TLS — installed PWAs generally require a secure origin anyway. Issue6161 tickets per device or context so revocation is scoped.6262 63+A derived sync ticket can additionally be **bound to the enrolling device**: the app offers a device64+public key at the scope-down exchange, and connecting then requires a fresh signature from that key65+— which never leaves the device — before the node accepts any traffic. A stolen ticket string alone66+no longer connects. What binding does not change: script running on the app's own origin can still67+drive the device key while the page is open, and the row-level permission story is still Jazz's.68+Binding defeats exfiltration of the credential, not live abuse of a compromised page.69+6370 ## What the app does with it6471 6572 You paste; the app does the rest. The ticket is validated (against the same rules this node@@ -32,6 +32,17 @@ export interface AppTicket {
3232 node?: string;3333 }3434 35+/** A device public key bound to a ticket at derive time: connections on the36+ * ticket then require a proof-of-possession challenge, so the ticket string37+ * alone no longer connects. `alg` is carried per record so a future curve38+ * upgrade is a value change, not a format change. */39+export interface AppTicketPop {40+ alg: "ES256";41+ /** base64url DER SubjectPublicKeyInfo of the device's P-256 public key. */42+ spki: string;43+ boundAt: string;44+}45+3546 /** Persisted record of an issued ticket — digest only, never the secret. */3647 export interface AppTicketRecord {3748 id: string;@@ -44,6 +55,9 @@ export interface AppTicketRecord {
4455 secretHash: string;4556 createdAt: string;4657 revokedAt?: string;58+ /** Present when the ticket is possession-bound; absent records stay pure59+ * bearer, so tickets.json remains v1 and older nodes ignore the field. */60+ pop?: AppTicketPop;4761 }4862 4963 function base64urlNoPad(bytes: Uint8Array): string {@@ -194,12 +208,15 @@ export class AppTicketStore {
194208 195209 /** Issue a ticket. `parentId` links a derived ticket to the ticket it was196210 * minted from — the derived ticket then dies with its parent. The parent197- * must be live at issuance. Records without a parent serialize exactly as198- * before (the field is omitted), so tickets.json stays v1. */211+ * must be live at issuance. `pop` binds a device public key: the gate then212+ * requires a proof-of-possession exchange before any connection on the213+ * ticket. Records without a parent or pop serialize exactly as before (the214+ * fields are omitted), so tickets.json stays v1. */199215 async issue(200216 label?: string,201217 scope: "sync" | "provision" = "sync",202218 parentId?: string,219+ pop?: Omit<AppTicketPop, "boundAt">,203220 ): Promise<{ record: AppTicketRecord; secret: string }> {204221 await this.#reload();205222 if (parentId !== undefined) {@@ -217,6 +234,7 @@ export class AppTicketStore {
217234 parentId,218235 secretHash,219236 createdAt: new Date().toISOString(),237+ ...(pop ? { pop: { ...pop, boundAt: new Date().toISOString() } } : {}),220238 };221239 this.#tickets.push(record);222240 await this.#persist();@@ -256,3 +274,53 @@ export class AppTicketStore {
256274 : { status: "valid", record: match };257275 }258276 }277+278+/** The exact bytes a device signs to prove possession: version line, app id,279+ * ticket id, and the challenge nonce, newline-joined. Binding the app and280+ * ticket ids prevents splicing a signature across stores or tickets; the281+ * single-use nonce prevents replay. */282+export function popMessage(appId: string, ticketId: string, nonce: string): Uint8Array {283+ return new TextEncoder().encode(`lofisync-pop-v1\n${appId}\n${ticketId}\n${nonce}`);284+}285+286+function fromBase64url(text: string): Uint8Array {287+ const b64 = text.replaceAll("-", "+").replaceAll("_", "/");288+ const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);289+ const bin = atob(padded);290+ const bytes = new Uint8Array(bin.length);291+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);292+ return bytes;293+}294+295+/** Import a bound device key's SPKI; throws on any malformed or wrong-curve296+ * key — used by the derive endpoint to reject bad bindings up front. */297+export async function importPopKey(spki: string): Promise<CryptoKey> {298+ return await crypto.subtle.importKey(299+ "spki",300+ fromBase64url(spki).buffer as ArrayBuffer,301+ { name: "ECDSA", namedCurve: "P-256" },302+ false,303+ ["verify"],304+ );305+}306+307+/** Verify a proof-of-possession signature (WebCrypto raw r||s ECDSA over308+ * SHA-256). False on any failure — malformed keys and signatures verify309+ * false rather than throwing, so the caller keeps one rejection path. */310+export async function verifyPopSignature(311+ spki: string,312+ message: Uint8Array,313+ signature: string,314+): Promise<boolean> {315+ try {316+ const key = await importPopKey(spki);317+ return await crypto.subtle.verify(318+ { name: "ECDSA", hash: "SHA-256" },319+ key,320+ fromBase64url(signature).buffer as ArrayBuffer,321+ message.buffer as ArrayBuffer,322+ );323+ } catch {324+ return false;325+ }326+}@@ -10,10 +10,15 @@
1010 // an in-band frame, proven by the iroh tunnel doing the same); HTTP streams.1111 1212 import {13+ type AppTicketPop,1314 type AppTicketStore,1415 encodeAppTicket,16+ hashSecret,17+ importPopKey,1518 isRevokedByLineage,19+ popMessage,1620 SECRET_LENGTH,21+ verifyPopSignature,1722 } from "./appticket.ts";1823 import { forwardableHeaders, sanitizeCloseCode } from "./tunnel.ts";1924 @@ -27,6 +32,19 @@ function debug(...args: unknown[]): void {
2732 export const CLOSE_TICKET_REVOKED = 4001;2833 2934 const TICKET_PATH = new RegExp(`^/t/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);35+const CONNECT_PATH = new RegExp(`^/c/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);36+37+/** How long an unanswered proof-of-possession challenge stays valid. */38+const CHALLENGE_TTL_MS = 120_000;39+/** Sliding lifetime of a connect token; refreshed on each authenticated use,40+ * so a live sync session never expires mid-flight. Tokens are memory-only —41+ * a node restart invalidates them and the client re-runs the exchange. */42+const CONNECT_TOKEN_TTL_MS = 86_400_000;43+/** Outstanding-challenge cap per ticket; the oldest is dropped past it. */44+const MAX_CHALLENGES_PER_TICKET = 32;45+46+type PopChallenge = { ticketId: string; nonce: string; expiresAt: number };47+type ConnectToken = { ticketId: string; expiresAt: number };3048 3149 export interface GateOptions {3250 port: number;@@ -245,6 +263,63 @@ export function startGate(options: GateOptions): Gate {
245263 const hostname = options.hostname ?? "0.0.0.0";246264 const liveByTicket = new Map<string, Set<WebSocket>>();247265 266+ // Proof-of-possession state, memory-only by design: challenges are267+ // single-use with a short TTL, and connect tokens are stored hashed (the268+ // node never stores secrets) with a sliding TTL.269+ const challenges = new Map<string, PopChallenge>();270+ const connectTokens = new Map<string, ConnectToken>();271+272+ const base64urlNoPad = (bytes: Uint8Array): string => {273+ let bin = "";274+ for (const b of bytes) bin += String.fromCharCode(b);275+ return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");276+ };277+278+ const mintChallenge = (ticketId: string): { id: string; nonce: string } => {279+ const perTicket = [...challenges.entries()].filter(([, c]) => c.ticketId === ticketId);280+ if (perTicket.length >= MAX_CHALLENGES_PER_TICKET) {281+ let oldestId = perTicket[0][0];282+ let oldestAt = perTicket[0][1].expiresAt;283+ for (const [id, challenge] of perTicket) {284+ if (challenge.expiresAt < oldestAt) {285+ oldestAt = challenge.expiresAt;286+ oldestId = id;287+ }288+ }289+ challenges.delete(oldestId);290+ }291+ const id = base64urlNoPad(crypto.getRandomValues(new Uint8Array(12)));292+ const nonce = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));293+ challenges.set(id, { ticketId, nonce, expiresAt: Date.now() + CHALLENGE_TTL_MS });294+ return { id, nonce };295+ };296+297+ const answerChallenge = async (298+ ticketId: string,299+ pop: AppTicketPop,300+ body: { id?: unknown; sig?: unknown },301+ ): Promise<string | null> => {302+ if (typeof body.id !== "string" || typeof body.sig !== "string") return null;303+ const challenge = challenges.get(body.id);304+ // Single-use: the challenge dies on its first answer attempt, valid or not.305+ challenges.delete(body.id);306+ if (!challenge || challenge.ticketId !== ticketId || challenge.expiresAt < Date.now()) {307+ return null;308+ }309+ const verified = await verifyPopSignature(310+ pop.spki,311+ popMessage(options.appId, ticketId, challenge.nonce),312+ body.sig,313+ );314+ if (!verified) return null;315+ const token = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));316+ connectTokens.set(await hashSecret(token), {317+ ticketId,318+ expiresAt: Date.now() + CONNECT_TOKEN_TTL_MS,319+ });320+ return token;321+ };322+248323 const track = (ticketId: string, socket: WebSocket) => {249324 let set = liveByTicket.get(ticketId);250325 if (!set) liveByTicket.set(ticketId, set = new Set());@@ -259,11 +334,28 @@ export function startGate(options: GateOptions): Gate {
259334 // and close sockets whose ticket flipped to revoked. Cheap (only runs while260335 // gated sockets exist).261336 const sweep = setInterval(async () => {262- if (liveByTicket.size === 0) return;337+ // Expired proof-of-possession state ages out regardless of connections.338+ const now = Date.now();339+ for (const [id, challenge] of challenges) {340+ if (challenge.expiresAt < now) challenges.delete(id);341+ }342+ for (const [digest, token] of connectTokens) {343+ if (token.expiresAt < now) connectTokens.delete(digest);344+ }345+ if (liveByTicket.size === 0 && connectTokens.size === 0 && challenges.size === 0) return;263346 const records = await options.store.list();264- for (const [ticketId, sockets] of liveByTicket) {347+ const dead = (ticketId: string): boolean => {265348 const record = records.find((r) => r.id === ticketId);266- if (record && !isRevokedByLineage(record, records)) continue;349+ return !record || isRevokedByLineage(record, records);350+ };351+ for (const [id, challenge] of challenges) {352+ if (dead(challenge.ticketId)) challenges.delete(id);353+ }354+ for (const [digest, token] of connectTokens) {355+ if (dead(token.ticketId)) connectTokens.delete(digest);356+ }357+ for (const [ticketId, sockets] of liveByTicket) {358+ if (!dead(ticketId)) continue;267359 for (const socket of [...sockets]) {268360 try {269361 socket.close(CLOSE_TICKET_REVOKED, "ticket revoked");@@ -307,7 +399,77 @@ export function startGate(options: GateOptions): Gate {
307399 return unauthorized();308400 }309401 const scope = verdict.record.scope ?? "sync";310- const strippedPath = rest || "/";402+ let strippedPath = rest || "/";403+ // Possession-bound tickets: the bare secret opens only the404+ // proof-of-possession exchange; everything else must ride a connect405+ // token minted by a fresh signature from the bound device key. A stolen406+ // ticket string alone therefore no longer connects.407+ if (verdict.record.pop) {408+ // Liveness stays reachable with the bare secret — it reveals nothing409+ // a valid ticket holder could not learn from the open /health route.410+ if (strippedPath === "/health" && !isUpgrade) {411+ return proxyHttp(req, targetBase, "/health");412+ }413+ if (strippedPath === "/pop/challenge" && !isUpgrade) {414+ if (req.method !== "POST") {415+ return new Response("method not allowed", { status: 405 });416+ }417+ const challenge = mintChallenge(verdict.record.id);418+ return new Response(419+ JSON.stringify({420+ v: 1,421+ id: challenge.id,422+ nonce: challenge.nonce,423+ expiresIn: CHALLENGE_TTL_MS / 1000,424+ }),425+ { headers: { "content-type": "application/json" } },426+ );427+ }428+ if (strippedPath === "/pop/answer" && !isUpgrade) {429+ if (req.method !== "POST") {430+ return new Response("method not allowed", { status: 405 });431+ }432+ let body: { id?: unknown; sig?: unknown } = {};433+ try {434+ body = await req.json() as { id?: unknown; sig?: unknown };435+ } catch {436+ // malformed body falls through to the single rejection path437+ }438+ const token = await answerChallenge(verdict.record.id, verdict.record.pop, body);439+ if (token === null) {440+ debug("rejected pop answer", verdict.record.id);441+ return unauthorized();442+ }443+ debug("pop verified", verdict.record.id);444+ return new Response(445+ JSON.stringify({ v: 1, connect: token, expiresIn: CONNECT_TOKEN_TTL_MS / 1000 }),446+ { headers: { "content-type": "application/json" } },447+ );448+ }449+ const connect = strippedPath.match(CONNECT_PATH);450+ if (!connect) {451+ debug("rejected pop-bound bare path", verdict.record.id);452+ return unauthorized();453+ }454+ const [, connectSecret, connectRest] = connect;455+ const tokenDigest = await hashSecret(connectSecret);456+ const token = connectTokens.get(tokenDigest);457+ if (!token || token.ticketId !== verdict.record.id || token.expiresAt < Date.now()) {458+ debug("rejected connect token", verdict.record.id);459+ return unauthorized();460+ }461+ // Sliding lifetime: each authenticated use extends the token.462+ token.expiresAt = Date.now() + CONNECT_TOKEN_TTL_MS;463+ strippedPath = connectRest || "/";464+ } else if (strippedPath.startsWith("/pop/")) {465+ // The exchange only exists for bound tickets; a bearer ticket probing466+ // it is a client-configuration error, and the path must never leak467+ // upstream.468+ return new Response(JSON.stringify({ error: "pop_not_bound" }), {469+ status: 400,470+ headers: { "content-type": "application/json" },471+ });472+ }311473 if (strippedPath === "/store-status" && !isUpgrade) {312474 return storeStatus(targetBase, options.appId, options.adminSecret);313475 }@@ -325,15 +487,39 @@ export function startGate(options: GateOptions): Gate {
325487 return new Response("method not allowed", { status: 405 });326488 }327489 let requested: string | undefined;490+ let deviceKey: { alg?: unknown; spki?: unknown } | undefined;328491 try {329- const body = await req.json() as { label?: unknown };492+ const body = await req.json() as { label?: unknown; devicePublicKey?: unknown };330493 if (typeof body.label === "string") requested = body.label;494+ if (typeof body.devicePublicKey === "object" && body.devicePublicKey !== null) {495+ deviceKey = body.devicePublicKey as { alg?: unknown; spki?: unknown };496+ }331497 } catch {332498 // empty or non-JSON body: fall through to the default label333499 }500+ // An offered device key binds the derived ticket to possession. A501+ // malformed key is a client bug, not an auth probe — 400, not 401.502+ let pop: { alg: "ES256"; spki: string } | undefined;503+ if (deviceKey !== undefined) {504+ if (deviceKey.alg !== "ES256" || typeof deviceKey.spki !== "string") {505+ return new Response(JSON.stringify({ error: "invalid_device_key" }), {506+ status: 400,507+ headers: { "content-type": "application/json" },508+ });509+ }510+ try {511+ await importPopKey(deviceKey.spki);512+ } catch {513+ return new Response(JSON.stringify({ error: "invalid_device_key" }), {514+ status: 400,515+ headers: { "content-type": "application/json" },516+ });517+ }518+ pop = { alg: "ES256", spki: deviceKey.spki };519+ }334520 const parent = verdict.record;335521 const label = requested ?? `${parent.label ?? parent.id} (sync)`;336- const derived = await options.store.issue(label, "sync", parent.id);522+ const derived = await options.store.issue(label, "sync", parent.id, pop);337523 const base = (options.publicBase ??338524 `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`).replace(/\/+$/, "");339525 const ticket = encodeAppTicket({@@ -343,10 +529,16 @@ export function startGate(options: GateOptions): Gate {
343529 label,344530 node: options.nodeTicket,345531 });346- debug("derived sync ticket", derived.record.id, "from", parent.id);347- return new Response(JSON.stringify({ v: 1, id: derived.record.id, ticket }), {348- headers: { "content-type": "application/json" },349- });532+ debug("derived sync ticket", derived.record.id, "from", parent.id, pop ? "pop" : "bearer");533+ return new Response(534+ JSON.stringify({535+ v: 1,536+ id: derived.record.id,537+ ticket,538+ ...(pop ? { pop: true } : {}),539+ }),540+ { headers: { "content-type": "application/json" } },541+ );350542 }351543 // Admin/catalogue-mutating routes need provision scope; a sync ticket352544 // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).@@ -0,0 +1,304 @@
1+// Proof-of-possession tickets against a stub upstream: binding at derive2+// time, the challenge/answer exchange, connect-token enforcement for HTTP3+// and WS, single-use and cross-ticket refusals, revocation cascade, and the4+// cross-repo signature fixtures.5+6+import { assert, assertEquals } from "@std/assert";7+import {8+ AppTicketStore,9+ decodeAppTicket,10+ popMessage,11+ verifyPopSignature,12+} from "../src/appticket.ts";13+import { CLOSE_TICKET_REVOKED, startGate } from "../src/gate.ts";14+15+function startStubUpstream() {16+ const server = Deno.serve({ hostname: "127.0.0.1", port: 0, onListen: () => {} }, (req) => {17+ const url = new URL(req.url);18+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {19+ const protocol = req.headers.get("sec-websocket-protocol")?.split(",")[0].trim();20+ const { socket, response } = Deno.upgradeWebSocket(req, protocol ? { protocol } : {});21+ socket.addEventListener("open", () => {22+ socket.send(23+ JSON.stringify({ hello: url.pathname + url.search, protocol: protocol ?? null }),24+ );25+ });26+ socket.addEventListener("message", (ev) => socket.send(ev.data));27+ return response;28+ }29+ return new Response(30+ JSON.stringify({ path: url.pathname + url.search, method: req.method }),31+ { headers: { "content-type": "application/json" } },32+ );33+ });34+ return {35+ url: `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`,36+ close: () => server.shutdown(),37+ };38+}39+40+async function withGate(41+ fn: (ctx: {42+ gateUrl: string;43+ store: AppTicketStore;44+ gate: ReturnType<typeof startGate>;45+ }) => Promise<void>,46+) {47+ const upstream = startStubUpstream();48+ const store = await AppTicketStore.load();49+ const gate = startGate({50+ port: 0,51+ hostname: "127.0.0.1",52+ target: () => upstream.url,53+ mode: "ticket",54+ store,55+ appId: "stub-app",56+ adminSecret: "node-admin-secret",57+ });58+ try {59+ await fn({ gateUrl: `http://127.0.0.1:${gate.port}`, store, gate });60+ } finally {61+ await gate.close();62+ await upstream.close();63+ }64+}65+66+const b64url = (bytes: Uint8Array) => {67+ let bin = "";68+ for (const b of bytes) bin += String.fromCharCode(b);69+ return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");70+};71+72+async function makeDeviceKey() {73+ const pair = await crypto.subtle.generateKey(74+ { name: "ECDSA", namedCurve: "P-256" },75+ false,76+ ["sign"],77+ );78+ const spki = b64url(new Uint8Array(await crypto.subtle.exportKey("spki", pair.publicKey)));79+ const sign = async (message: Uint8Array) =>80+ b64url(81+ new Uint8Array(82+ await crypto.subtle.sign(83+ { name: "ECDSA", hash: "SHA-256" },84+ pair.privateKey,85+ message.buffer as ArrayBuffer,86+ ),87+ ),88+ );89+ return { spki, sign };90+}91+92+/** Enroll: provision ticket → derive with a device key → parse the derived93+ * sync secret out of the returned ticket string. */94+async function enrollPop(gateUrl: string, store: AppTicketStore, device: { spki: string }) {95+ const provision = await store.issue("provision", "provision");96+ const res = await fetch(`${gateUrl}/t/${provision.secret}/derive-sync-ticket`, {97+ method: "POST",98+ body: JSON.stringify({99+ label: "phone",100+ devicePublicKey: { alg: "ES256", spki: device.spki },101+ }),102+ });103+ assertEquals(res.status, 200);104+ const body = await res.json() as { id: string; ticket: string; pop?: boolean };105+ assertEquals(body.pop, true);106+ const decoded = decodeAppTicket(body.ticket);107+ assert(decoded, "derived ticket must decode");108+ const secret = new URL(decoded.url).pathname.split("/t/")[1];109+ return { provision, derivedId: body.id, secret };110+}111+112+async function completePop(113+ gateUrl: string,114+ secret: string,115+ device: { sign(message: Uint8Array): Promise<string> },116+ appId = "stub-app",117+ ticketId?: string,118+) {119+ const challengeRes = await fetch(`${gateUrl}/t/${secret}/pop/challenge`, { method: "POST" });120+ assertEquals(challengeRes.status, 200);121+ const challenge = await challengeRes.json() as { id: string; nonce: string };122+ const sig = await device.sign(123+ popMessage(appId, ticketId ?? "", challenge.nonce),124+ );125+ const answerRes = await fetch(`${gateUrl}/t/${secret}/pop/answer`, {126+ method: "POST",127+ body: JSON.stringify({ v: 1, id: challenge.id, sig }),128+ });129+ return answerRes;130+}131+132+Deno.test("derive binds a device key; deriving without one stays bearer", async () => {133+ await withGate(async ({ gateUrl, store }) => {134+ const device = await makeDeviceKey();135+ const { derivedId } = await enrollPop(gateUrl, store, device);136+ const records = await store.list();137+ const bound = records.find((r) => r.id === derivedId);138+ assert(bound?.pop, "derived record must carry the bound key");139+ assertEquals(bound.pop.alg, "ES256");140+ assertEquals(bound.pop.spki, device.spki);141+142+ const provision = await store.issue("p2", "provision");143+ const res = await fetch(`${gateUrl}/t/${provision.secret}/derive-sync-ticket`, {144+ method: "POST",145+ body: JSON.stringify({ label: "legacy" }),146+ });147+ const body = await res.json() as { id: string; pop?: boolean };148+ assertEquals(body.pop, undefined);149+ const legacy = (await store.list()).find((r) => r.id === body.id);150+ assert(legacy && legacy.pop === undefined, "keyless derive must stay bearer");151+ });152+});153+154+Deno.test("a malformed device key is a 400, not an auth failure", async () => {155+ await withGate(async ({ gateUrl, store }) => {156+ const provision = await store.issue("p", "provision");157+ for (158+ const devicePublicKey of [159+ { alg: "RS256", spki: "AAAA" },160+ { alg: "ES256", spki: "not-a-key" },161+ { alg: "ES256" },162+ ]163+ ) {164+ const res = await fetch(`${gateUrl}/t/${provision.secret}/derive-sync-ticket`, {165+ method: "POST",166+ body: JSON.stringify({ devicePublicKey }),167+ });168+ assertEquals(res.status, 400);169+ assertEquals((await res.json() as { error: string }).error, "invalid_device_key");170+ }171+ });172+});173+174+Deno.test("a bound ticket refuses bare paths and honors the full exchange", async () => {175+ await withGate(async ({ gateUrl, store }) => {176+ const device = await makeDeviceKey();177+ const { derivedId, secret } = await enrollPop(gateUrl, store, device);178+179+ // The bare secret no longer reaches the upstream.180+ const bare = await fetch(`${gateUrl}/t/${secret}/echo`);181+ assertEquals(bare.status, 401);182+ await bare.body?.cancel();183+184+ // Challenge → sign → answer → token.185+ const answerRes = await completePop(gateUrl, secret, device, "stub-app", derivedId);186+ assertEquals(answerRes.status, 200);187+ const { connect } = await answerRes.json() as { connect: string };188+189+ // HTTP rides the token base with the prefix stripped.190+ const http = await fetch(`${gateUrl}/t/${secret}/c/${connect}/echo?x=1`);191+ assertEquals(http.status, 200);192+ assertEquals((await http.json() as { path: string }).path, "/echo?x=1");193+194+ // WS rides it too, preserving the subprotocol.195+ const ws = new WebSocket(196+ `ws://${new URL(gateUrl).host}/t/${secret}/c/${connect}/sync`,197+ ["jazz-sync"],198+ );199+ const first = await new Promise<string>((resolve, reject) => {200+ ws.addEventListener("message", (ev) => resolve(String(ev.data)), { once: true });201+ ws.addEventListener("error", () => reject(new Error("ws failed")), { once: true });202+ });203+ const hello = JSON.parse(first) as { hello: string; protocol: string | null };204+ assertEquals(hello.hello, "/sync");205+ assertEquals(hello.protocol, "jazz-sync");206+ ws.close();207+ });208+});209+210+Deno.test("wrong keys, consumed challenges, and foreign tokens refuse as one shape", async () => {211+ await withGate(async ({ gateUrl, store }) => {212+ const device = await makeDeviceKey();213+ const intruder = await makeDeviceKey();214+ const { derivedId, secret } = await enrollPop(gateUrl, store, device);215+216+ // A signature from the wrong key fails.217+ const wrongKey = await completePop(gateUrl, secret, intruder, "stub-app", derivedId);218+ assertEquals(wrongKey.status, 401);219+ assertEquals((await wrongKey.json() as { error: string }).error, "invalid_ticket");220+221+ // A challenge dies on its first answer, valid or not.222+ const challengeRes = await fetch(`${gateUrl}/t/${secret}/pop/challenge`, { method: "POST" });223+ const challenge = await challengeRes.json() as { id: string; nonce: string };224+ const sig = await device.sign(popMessage("stub-app", derivedId, challenge.nonce));225+ const firstUse = await fetch(`${gateUrl}/t/${secret}/pop/answer`, {226+ method: "POST",227+ body: JSON.stringify({ v: 1, id: challenge.id, sig }),228+ });229+ assertEquals(firstUse.status, 200);230+ await firstUse.body?.cancel();231+ const replay = await fetch(`${gateUrl}/t/${secret}/pop/answer`, {232+ method: "POST",233+ body: JSON.stringify({ v: 1, id: challenge.id, sig }),234+ });235+ assertEquals(replay.status, 401);236+ await replay.body?.cancel();237+238+ // A token minted for one ticket does not open another.239+ const secondDevice = await makeDeviceKey();240+ const second = await enrollPop(gateUrl, store, secondDevice);241+ const secondAnswer = await completePop(242+ gateUrl,243+ second.secret,244+ secondDevice,245+ "stub-app",246+ second.derivedId,247+ );248+ const { connect: foreignToken } = await secondAnswer.json() as { connect: string };249+ const crossed = await fetch(`${gateUrl}/t/${secret}/c/${foreignToken}/echo`);250+ assertEquals(crossed.status, 401);251+ await crossed.body?.cancel();252+253+ // A bearer ticket probing the exchange is a configuration error.254+ const bearer = await store.issue("plain");255+ const probe = await fetch(`${gateUrl}/t/${bearer.secret}/pop/challenge`, { method: "POST" });256+ assertEquals(probe.status, 400);257+ assertEquals((await probe.json() as { error: string }).error, "pop_not_bound");258+ });259+});260+261+Deno.test("revoking the parent kills tokens and closes bound sockets", async () => {262+ await withGate(async ({ gateUrl, store }) => {263+ const device = await makeDeviceKey();264+ const { provision, derivedId, secret } = await enrollPop(gateUrl, store, device);265+ const answer = await completePop(gateUrl, secret, device, "stub-app", derivedId);266+ const { connect } = await answer.json() as { connect: string };267+268+ const ws = new WebSocket(`ws://${new URL(gateUrl).host}/t/${secret}/c/${connect}/sync`);269+ await new Promise<void>((resolve, reject) => {270+ ws.addEventListener("message", () => resolve(), { once: true });271+ ws.addEventListener("error", () => reject(new Error("ws failed")), { once: true });272+ });273+274+ await store.revoke(provision.record.id);275+ const closed = await new Promise<number>((resolve) => {276+ ws.addEventListener("close", (ev) => resolve(ev.code), { once: true });277+ });278+ assertEquals(closed, CLOSE_TICKET_REVOKED);279+280+ const after = await fetch(`${gateUrl}/t/${secret}/c/${connect}/echo`);281+ assertEquals(after.status, 401);282+ await after.body?.cancel();283+ });284+});285+286+Deno.test("the cross-repo signature fixtures verify", async () => {287+ const fixture = JSON.parse(288+ await Deno.readTextFile(new URL("../docs/fixtures/pop-fixtures.json", import.meta.url)),289+ ) as { spki: string; appId: string; ticketId: string; nonce: string; signature: string };290+ const message = popMessage(fixture.appId, fixture.ticketId, fixture.nonce);291+ assert(292+ await verifyPopSignature(fixture.spki, message, fixture.signature),293+ "the fixture signature must verify",294+ );295+ const tampered = popMessage(fixture.appId, fixture.ticketId, fixture.nonce + "x");296+ assert(297+ !(await verifyPopSignature(fixture.spki, tampered, fixture.signature)),298+ "a tampered message must not verify",299+ );300+ assert(301+ !(await verifyPopSignature("AAAA", message, fixture.signature)),302+ "a malformed key must verify false, not throw",303+ );304+});