Launcher auth contract
This page names, in one place, the credential rules a non-browser client follows: the native CLI, the daemon, the VS Code and Chrome extensions, and the offline launcher (PL2). Nothing here is new; it is the contract the pieces already implement, written down so a new client type never wires it ad hoc.
1. The device credential is a machine key
A device holds a machine API key (zsm_…) minted for it. It is scoped to the user's machine, revocable per row, has an optional expiry and records last use. An admin-scope key is a full session-equivalent on every owner-gated API route: the middleware resolves it to the owner's ZID exactly as a browser session.
Authorization: Bearer zsm_…
Send it on every request. Never rely on cookies from a native client; set __ZEQ_NATIVE__ (the CLI's HTTP client already does) so the client sends the Bearer header even on routes a browser would reach with the cookie.
2. Getting a device key
| Step | Call | Notes |
|---|---|---|
| Probe | GET /api/zeq-auth/session-key (session cookie or Bearer) | {ok, slug, key_prefix, scope, minted_at, machine_id, zid} — a prefix, never a usable key. 404 no_state_machine → spin up first; 404 no_active_key → mint. |
| Mint | POST /api/zeq-auth/session-key/mint | body { name?: string, device?: { id: uuid, label: string, platform: "pwa"|"cli"|"daemon"|"extension"|"other" } } → {ok, slug, machine_id, api_key, key_prefix, scope}. The raw key is returned once. With device, the node also records the device in its registry (zeq_devices) so it can be listed and forgotten later. |
| Revoke | Settings → Your devices → Forget (PL2 Phase 1) or the admin key routes | immediate; the next request with that key gets 401 |
3. Storage rules
- The portable credential (
.zeqkey, an extension of the.ZEQenvelope) is encrypted with the user's PIN: Argon2id, PBKDF2 fallback. It is a file the user keeps. - The live session token / device key goes in the platform keychain where one exists (VS Code
context.secrets, macOS Keychain, Windows Credential Manager, libsecret), never in a plaintext file. Browser clients keep it inchrome.storage.local/ IndexedDB. - Re-mint on
401rather than persisting forever; the extension already does this (zeq_sm_key).
4. Sessions, revocation and Protected Login
- Web sessions are 24-hour HMAC tokens carrying the identity's
token_version; an equation reissue bumps it and every older session is refused, including byPOST /api/zeq-auth/verify-v3(valid:false,SESSION_REVOKED). - A device that must open a web UI (Vault, Studio) exchanges its device key for a short session —
POST /api/zeq-auth/device/session(PL2 Phase 1). - Protected Login: when a user has a login word, sign-in from a device carries it inside the launcher; the user never types it.
5. Device-bound requests (PL2 Phase 4)
Later, a device also signs each request with its own Ed25519 key registered under the ZID:
x-zeq-device-id: <uuid>
x-zeq-device-zeqond: <zeqond>
x-zeq-device-sig: Ed25519( device|<ZID>|<device_id>|<zeqond>|<METHOD>|<path>|<sha256(body)> )
Same replay window as the node-to-node protocol; the verifier is the node's key-agnostic Ed25519 check. The machine key then becomes a fallback.
6. Privacy
Everything a user owns is private by default (slug page, pages, files, apps). A device key reads its owner's private machine; strangers get the same 404 as for a non-existent slug. Publishing is an explicit switch (Manage machine → Publish, or the agent's publish step).
Phase 1 additions (2026-09-03): the launcher itself
/launcher/ is the PWA (installable, works offline for the unlock step). It writes and reads the .zeqkey file:
- File format: a HiteCrypto v2 envelope (Argon2id key derivation, AES-256-GCM), the same library as the
.ZEQrecovery kit, locked with a PIN of 6+ digits or a passphrase of 12+ characters. The PIN never leaves the device. - Payload (inside the envelope):
{ kind:"zeq-launcher", v:1, zid, slug, home_node, machine_id, device_id, device_label, device_key, created_zeqond, policy }.device_keyis the per-devicezsm_admin key minted for that device; the equation is never in the file. - Local lockout: five wrong PINs, then a wait that doubles each time (30 s → 1 h), stored on the device.
POST /api/zeq-auth/device/session — the exchange the launcher makes after the PIN opens the file.
- Auth:
Authorization: Bearer zsm_…(the device key). Body:{ "device_id": "<uuid>" }. - Returns
{ ok:true, token, zid, device_id, expires_in }and sets the ordinary session cookie. The token is the same short session every other page accepts. - Refusals:
401 UNAUTHORIZED(bad, revoked or non-admin key; unknown identity),403 DEVICE_REVOKED(the device was forgotten),403 DEVICE_UNKNOWN/DEVICE_MISMATCH(the device id is not the one enrolled with this key). Audited aslogin_device. - Protected Login's word is not required here: the word defends the equation path; the launcher never posts an equation, and its key is revocable per device from Settings → Devices.
GET /api/zeq-auth/devices/self — who am I, as a device. Auth: the device key. Returns { ok:true, zid, slug, machine_id, device:{ id, label, platform, last_seen_at, revoked_at } }; 403 DEVICE_REVOKED once forgotten. Use it as the reachability probe instead of GET /session-key, which needs a session.
Phase 1b (2026-09-03): the Chrome extension
The launcher also ships as a Manifest V3 extension, built from the same core as the PWA (launcher-core.js: seal/open the file, PIN lockout, device→session exchange, enrol, forget). Get it at /launcher/zeq-launcher-extension.zip on any ZEQ domain, or load /launcher/extension/ unpacked.
- Storage: the sealed
.zeqkeyinchrome.storage.local; the unlocked session inchrome.storage.session(memory only, cleared when Chrome closes, and after 12 idle hours by the service worker). The PIN never leaves the popup. - Sign-in: the popup calls
POST /api/zeq-auth/device/sessionon the node named in the file withcredentials: "include", so the session cookie lands in Chrome's normal cookie jar for that node; the Vault, machine and Studio tabs open signed in.host_permissionslists the 17 ZEQ domains,machine.zeq.devand localhost. - Create for this browser: sign in on your node in a tab once; the popup mints a device key there (
session-key/mintwithplatform: "extension"), verifies it withdevices/self, seals the payload with your PIN. - Argon2 is the bundled hash-wasm build (
wasm-unsafe-evalin the extension CSP); no remote code. - Signature UI: black, the big display title, signature green — the same
launcher.cssas the page.
Phase 2 (2026-09-03): the native CLI and "share your CPU"
zeq-local 1.2.0 (download: /pulse/cli/zeq-local.tgz on any ZEQ domain) reads the same .zeqkey:
zeq-local whoami --key my.zeqkey # who this launcher is, as the node sees it (PIN asked on the terminal)
zeq-local anchor --key my.zeqkey # seal your local ledger root with the device key (no ZEQ_API_KEY needed)
zeq-local share --key my.zeqkey [--cores N] [--once] [--dry]
The PIN is read from the TTY, never from arguments or the environment; the decrypted payload lives only in that process. The plaintext ~/.zeqrc is no longer needed for anything the launcher covers.
Share your CPU — one switch, three faces. POST /api/contribute/mode (contributing / idle) is the single state, shown and flipped identically by the Vault (Your Node), the launcher (PWA and extension) and the CLI. Numbers come from GET /api/contribute/share:
{ mode, live, share: { jobs_done, jobs_reproduced, jobs_diverged, cpu_zeqonds_supplied, envelopes_earned, last_job_zeqond, queued } }.
What actually runs — contributed replication. The node keeps computing every request itself and mints the envelope exactly as before. For a FREE-FUNDED sealed compute (paid 0), when a live contributing seat exists, the node queues a replication job after the mint; a contributor fetches it with GET /api/contribute/job (204 when none), recomputes it on its attested, byte-identical engine and answers with POST /api/contribute/job/:id/result { value, zeq_proof?, elapsed_zeqonds? }. The node adjudicates against its own value: reproduced → that envelope is transferred from the Foundation to the supplier (supply-neutral, nothing new minted); diverged → nothing moves; expired (120 Zeqonds, or 60 after being taken) → it stays with the Foundation. Paid and private work never enters this queue; nothing private leaves the contributor's machine.
Bond. Since Phase 2 the contribution bond is advice on the switch, not a wall: a seat without the bond is eligible for replication (a wrong answer is detected with certainty, nothing is at risk) and remains excluded from bonded work. GET /api/contribute/status still reports the bond verdict.
Where the loop runs. zeq-local share (any laptop, server or IoT box with Node ≥ 18, attest-gated: it refuses to share with an engine that differs from the node's canonical build); the launcher PWA while it is open (the browser twin engine); the extension popup only flips the switch.
Phase 3 (2026-09-04): biometrics — Face ID, Touch ID, fingerprint, Windows Hello
- The PIN stays the root. Argon2id(PIN) → AES-GCM opens the
.zeqkey; biometrics never touch the file and re-enrolling never changes the ciphertext. - Enrol (home view → Enable Face ID / fingerprint, PIN re-checked first): the launcher creates a platform-authenticator credential (
navigator.credentials.create,authenticatorAttachment: "platform",userVerification: "required",residentKey: "preferred",attestation: "none", extensionprf: {}) and evaluates the PRF once with a fresh 32-byte salt (credentials.get+prf.eval.first). The 32-byte PRF output → HKDF-SHA256 (infozeq-launcher-bio-v1, the same salt) → an AES-GCM-256 key that wraps the PIN. Stored on the device only:{ v, cred_id, salt, iv, ct, label, created, zid }— never the PIN, the PRF or a key. Nothing reaches the node. - Unlock with biometrics = the same PRF evaluation → unwrap the PIN → the ordinary PIN unlock, lockout bookkeeping included. A cancelled or failed prompt is not a wrong PIN.
- Per device. Enrol/disable from the home view; Forget this device wipes the record with the launcher; opening a different
.zeqkeyon the device clears any enrolment (it wrapped another PIN). The relying party is the node's hostname, so an enrolment belongs to one device on one node domain. - Fallbacks, said plainly. No platform authenticator or no PRF (
getClientExtensionResults().prf.enabledis false) → PIN only, stated in the home view; the Chrome extension is PIN only (an extension origin is not a WebAuthn relying party). - Proof (2026-09-04). Chrome 152 driving a virtual CTAP2.1 platform authenticator (user-verified, PRF) through the DevTools protocol against the live
zeq.dev/launcher/: enrol → the stored record holds no PIN in clear → lock → Face ID / fingerprint → the identity opens; authenticator removed → "Cancelled. Enter your PIN.", lockout untouched, the PIN still opens; 0 console errors. The same WebAuthn calls run on a phone's Face ID / fingerprint or a laptop's Touch ID / Windows Hello.
Phase 4 (2026-09-04): device-bound keys — shipped
Section 5 above is now enforced. What a device does:
- Key. On its first session the launcher creates an Ed25519 keypair in WebCrypto (non-extractable) and keeps it in the device's IndexedDB — never in the
.zeqkeyfile, never on the node. Browsers without Ed25519 in WebCrypto (older than Chrome 137 / Safari 17 / Firefox 130) keep the Phase 1 behaviour: the machine key alone, unbound. - Bind.
POST /api/zeq-auth/devices/registerwithAuthorization: Bearer zsm_…and body{ device_id, pubkey }(raw public key, 64 hex). The request itself carries the threex-zeq-device-*headers signed with that key — proof of possession. First registration wins; a different key for the same device is409 DEVICE_BOUND; forget the device to start over.GET /api/zeq-auth/devicesshowshas_device_key,GET /api/zeq-auth/devices/selfshowsbound. - Sign. Every
POST /api/zeq-auth/device/sessionfrom a bound device carriesx-zeq-device-id,x-zeq-device-zeqond,x-zeq-device-sig= Ed25519 overdevice|<ZID>|<device_id>|<zeqond>|POST|/api/zeq-auth/device/session|sha256(body)wherebodyis the compact JSON the client sent. The Zeqond is the node's own: readzeqond_nowfromGET /api/health(the genesis tick the node verifies against), not the pulse counter. Window: 300 Zeqonds either side; every signature is single-use inside the window (replay →403 DEVICE_SIGNATURE_REPLAY). - Refuse. Once bound, a session request without a signature is
403 DEVICE_BOUND; with another device's key it is403 DEVICE_SIGNATURE_BAD_SIGNATURE; outside the window…_STALE. So a copied.zeqkeyplus the right PIN opens the file but not the node. - Launcher-only sign-in.
GET/POST /api/zeq-auth/devices/policy{ launcher_only }— switchable only by a request signed by one of the identity's bound devices (a stolen session cannot lock the owner out). While on,POST /api/zeq-auth/login-v3refuses the equation/word login with403 LAUNCHER_ONLYunless the request is signed by a bound device; the recovery password, equation reissue,/spin-up/and the Admin clear stay open (the Admin clear also switches it off). The flag lives in the Protected Login policy row (zeq_login_policy.launcher_only, migration 0097) and replicates with the same signed peer write. - Proof (2026-09-04,
infra/tests/launcher-devbind-cdp.mjs). Chrome 152 over DevTools against the livezeqproof.com: a throwaway identity through the auth page → launcher created → the device binds on the first session (Device key · bound,has_device_key: true) → lock/unlock → signed session accepted → the same.zeqkeyand PIN in a second browser context (its own fresh key) →refused (DEVICE_SIGNATURE_BAD_SIGNATURE)→ launcher-only ON from the bound device → email+password login from the other context403 LAUNCHER_ONLY→ the same login signed by the bound device200→ OFF again; 0 console errors.
Phase 5 (2026-09-04): approve on device
A device with no password signs in by asking a device you trust.
- Ask.
POST /api/zeq-auth/approve/start { who }(who= ZID or machine slug; never reveals whether it exists) →{ request_id, user_code, device_code, expires_in: 300, interval: 5 }. Theuser_code(XXXX-XXXX, an alphabet without look-alike letters) is shown on the asking screen; thedevice_codeis the requester's secret (only its SHA-256 is stored). - Poll.
POST /api/zeq-auth/approve/poll { request_id, device_code }everyintervalseconds →pending(429slow_downwhen faster) ·approved(returns the session exactly likelogin-v3, once) ·denied(403) ·expired/consumed(410). - Approve. The trusted launcher lists
GET /api/zeq-auth/approve/pendingwhile unlocked and answers withPOST /api/zeq-auth/approve/decide { request_id, approve }. When the identity has bound devices, the decide request must carry the Phase 4 device signature from one of them — a stolen session cookie cannot approve. The person compares the code on both screens first. A push notification goes out on request when the identity has a push subscription (best effort). - Under launcher-only sign-in (Phase 4) this is the intended way onto a new device: the approval is a bound-device act.
- TVs and IoT (RFC 8628). The OIDC issuer advertises
device_authorization_endpointand the granturn:ietf:params:oauth:grant-type:device_code.POST /oauth/device_authorization { client_id, scope }→{ device_code, user_code, verification_uri: <issuer>/approve/, verification_uri_complete, expires_in, interval }; the TV pollsPOST /oauth/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codeand getsauthorization_pending/slow_down/expired_token/access_denieduntil the person approves the code at/approve/(signed by their bound device when they have one on that node) — then the same token set as the code grant, redeemable once. The issuer router only mounts on the node whoseZEQ_ORIGINiszeq-me; the mesh does not run an OIDC issuer today, so the device grant is deployed code without a live host until the owner enables the issuer on zeq.me (staged as one command). - Proof (2026-09-04,
infra/tests/launcher-approve-cdp.mjs). Chrome 152 over DevTools on the livezeqproof.com: a throwaway identity with a bound launcher (context A, "the phone"); a second browser context (B, "the laptop") on/auth/→ Ask my other device → code4W8C-DS8X; A's launcher listed "Sign in · Chrome on Mac · from 82.132.230.· 4W8C-DS8X" → Approve → "Request approved · signed by this device." → B received the session for the same identity and landed in/vault/; a second request denied on A → B "Denied on your other device."; 0 console errors.