clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/pop-tickets
lofi-node / docs / app-ticket.md
app-ticket.md 11.0K rendered
1# App-connect tickets — the contract23This is the format and semantics the lofi app side implements against. An app ticket is the4credential a user hands to a lofi app so it syncs against their self-hosted node. It is distinct5from the **node-pairing ticket** (the `endpoint…` string used by `lofi-node pair`).67## Ticket string89```10lofisync1.<base64url(JSON, no padding)>11```1213```jsonc14{15 "v": 1,16 "appId": "cfe52e44-7a59-4232-8dbb-bf53f27aeed6",17 "url": "http://192.168.1.10:4802/t/<secret>", // ← the app's serverUrl, verbatim18 "scope": "provision", // optional: "sync" (default when absent) | "provision"19 "label": "phone", // optional, user-facing20 "node": "endpoint…" // optional: the node's iroh EndpointTicket (forward compat)21}22```2324Parsers must treat unknown OPTIONAL fields as forward-compatible additions and an absent `scope` as25`"sync"` — every pre-scope ticket keeps meaning transport-only. Machine-readable conformance26fixtures: [fixtures/app-ticket-fixtures.json](fixtures/app-ticket-fixtures.json).2728- `<secret>` is 32 random bytes base64url (43 chars, alphabet `[A-Za-z0-9_-]`) — safe as a URL path29 segment with no encoding.30- The node stores only the SHA-256 digest of the secret; the ticket string is displayable **once**,31 at issuance.3233## Why the secret lives in the URL path3435jazz clients preserve a base path in `serverUrl` (`appScopedUrl`) and reject query params. With36`serverUrl = <ticket.url>`, every request the client makes carries the secret with **zero client37changes**:3839- sync WebSocket → `…/t/<secret>/apps/<appId>/ws`40- catalogue reads → `…/t/<secret>/apps/<appId>/schemas`, `schema/<hash>`41- admin (deploys) → `…/t/<secret>/apps/<appId>/admin/…` (Jazz's `X-Jazz-Admin-Secret` is still42 required on top — the ticket gates transport, the admin secret gates administration)4344The node's access gate validates the secret (timing-safe, digest vs digest), strips the45`/t/<secret>` prefix, and proxies to the internal Jazz server (which binds loopback-only).4647## Scopes: sync vs provision4849- **`sync`** (default): transport only. Admin/catalogue-mutating routes (`…/apps/<id>/admin/…`)50 answer the SAME `401 {"error":"invalid_ticket"}` as an unknown secret — nothing to enumerate.51 Enrolling a ticket attaches transport only and never mutates the store.52- **`provision`**: a strict superset of sync — everything above PLUS store administration. For53 provision-scoped HTTP requests the gate **injects the node's `X-Jazz-Admin-Secret` itself** (on54 `/admin/*` and on catalogue reads like `/schemas` and `/schema/<hash>`, which the merge-deploy55 flow needs to fetch the stored head schema verbatim). The admin secret therefore never leaves the56 node and never transits the client: a provisioning client passes any placeholder admin secret to57 jazz-tools `deploy` — the gate strips inbound `X-Jazz-Admin-Secret` headers in ticket mode and58 substitutes its own. Possession of a provision ticket IS the store-administration opt-in. Issue59 one per provisioning context (`ticket issue --provision --label60 laptop-admin`), not per device.6162Revocation semantics are identical for both scopes (401 / WS 4001).6364## Scope-down exchange (derive a sync ticket)6566A provision ticket can mint a **derived sync ticket** from itself, so a user who pastes a single67provision ticket ends up with two capability tiers: the app persists the derived sync ticket at rest68and keeps the provision ticket sealed (passkey-PRF) or memory-only.6970```71POST <ticket.url>/derive-sync-ticket72```7374- **Auth**: the provision-scoped secret in the URL path, like every gated request. A sync-scoped or75 unknown secret gets the same `401 {"error":"invalid_ticket"}` as any unauthorized request —76 nothing to enumerate. Non-POST methods on a provision ticket get `405`.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.84- **Response**: `200 {"v": 1, "id": "<ticket id>", "ticket": "lofisync1.…"}` — a complete85 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`.8990The derived ticket's record carries the parent's id, and **revocation cascades**: once the parent is91revoked (or its record removed), every ticket derived from it fails verification exactly like a92revoked ticket — 401 on new requests, close code 4001 for live WebSockets. Revoking a derived ticket93alone leaves its parent untouched. Derived tickets are always sync scope, so derivation cannot94escalate and effective chains are one level deep; the parent check still walks the whole lineage.95`lofi-node ticket list` shows derived tickets as `[from <parent id>]` and reports them REVOKED once96their lineage is.9798## Proof-of-possession binding99100A derived sync ticket can be bound to a device keypair at derive time (the `devicePublicKey` field101above). On a bound ticket, the bare secret opens only the possession exchange; every other request102must ride a connect token minted by a fresh signature from the bound key — an exfiltrated ticket103string alone no longer connects.104105```106POST <ticket.url>/pop/challenge107→ 200 { "v": 1, "id": "<challenge id>", "nonce": "<43-char base64url>", "expiresIn": 120 }108109POST <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```113114- **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.133134## Store-status preflight135136Against a store with **no deployed schema, client writes hang indefinitely** — so a sync-only client137needs a preflight it can reach without the admin secret. Any valid ticket (sync scope included) may138call:139140```141GET <ticket.url>/store-status142```143144```jsonc145{146 "v": 1,147 "appId": "…",148 "schema": {149 "deployed": true, // false → { "deployed": false } only150 "headHash": "ff85ac…", // newest stored schema hash151 "permissionsHead": "0195…" // current permissions head object id, or null152 }153}154```155156Metadata only — never schema contents, never policies, never secrets. The node answers it itself (it157holds the admin secret and queries its loopback Jazz). lofi's store classifier maps this to158`no_schema` / hash-comparison states instead of hanging; `502 {"error":"store_unavailable"}` means159the node's Jazz is unreachable. On open-mode (ungated) nodes this endpoint does not exist — dev160setups hold the admin secret and can query Jazz directly.161162## Enrollment flow (app side)1631641. User pastes/scans the ticket string; app parses it (`decodeAppTicket` in `@nzip/lofi-node`165 mirrors the validation: prefix, `v: 1`, http(s) URL with a `/t/<43-char-secret>` path).1662. A `provision`-scoped ticket is split before anything persists: the app calls the scope-down167 exchange (above) and declares the derived sync ticket as its sink; the provision original is held168 in memory and, on PRF-capable devices, sealed behind the user's passkey — otherwise the user's169 password manager keeps the durable copy. Against a node without the exchange, the ticket enrolls170 as pasted.1713. The declared sink persists only as a sealed envelope under a device-bound key (localStorage key172 `lofi:data-sink:<appId>`; nothing bearer-shaped is stored in cleartext). Boot opens it silently —173 no ceremony — and uses `ticket.url` as the runtime `serverUrl`. Unlocking sealed provision174 capability for an admin operation is a user-verifying passkey ceremony.1754. `ticket.appId` should match the app's own id; refuse enrollment otherwise.176177## Revocation semantics178179- Unknown **and** revoked secrets both get `401 {"error":"invalid_ticket"}` — indistinguishable to180 probers, on HTTP and on the WS upgrade (the 401 _is_ the upgrade response).181- A ticket revoked mid-session closes its live WebSockets with close code **4001**182 (`ticket revoked`) within a couple of seconds.183- On 401/4001 the app should treat the stored ticket as dead and surface re-enrollment (do not184 silently retry forever).185- `GET /health` (no secret) is open — safe for liveness probes.186187## Security notes188189- 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.195- Plain http is acceptable on a trusted LAN; anything beyond that should front the gate with TLS196 (the URL scheme in the ticket may be `https`). Installed PWAs generally require a secure origin197 anyway.198- Issue one ticket per device/context (`--label phone`) so revocation is scoped.