Skip to main content

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

StepCallNotes
ProbeGET /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.
MintPOST /api/zeq-auth/session-key/mintbody { 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.
RevokeSettings → Your devices → Forget (PL2 Phase 1) or the admin key routesimmediate; the next request with that key gets 401

3. Storage rules

  • The portable credential (.zeqkey, an extension of the .ZEQ envelope) 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 in chrome.storage.local / IndexedDB.
  • Re-mint on 401 rather 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 by POST /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 .ZEQ recovery 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_key is the per-device zsm_ 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 as login_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 .zeqkey in chrome.storage.local; the unlocked session in chrome.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/session on the node named in the file with credentials: "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_permissions lists the 17 ZEQ domains, machine.zeq.dev and localhost.
  • Create for this browser: sign in on your node in a tab once; the popup mints a device key there (session-key/mint with platform: "extension"), verifies it with devices/self, seals the payload with your PIN.
  • Argon2 is the bundled hash-wasm build (wasm-unsafe-eval in the extension CSP); no remote code.
  • Signature UI: black, the big display title, signature green — the same launcher.css as 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", extension prf: {}) and evaluates the PRF once with a fresh 32-byte salt (credentials.get + prf.eval.first). The 32-byte PRF output → HKDF-SHA256 (info zeq-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 .zeqkey on 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.enabled is 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 .zeqkey file, 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/register with Authorization: Bearer zsm_… and body { device_id, pubkey } (raw public key, 64 hex). The request itself carries the three x-zeq-device-* headers signed with that key — proof of possession. First registration wins; a different key for the same device is 409 DEVICE_BOUND; forget the device to start over. GET /api/zeq-auth/devices shows has_device_key, GET /api/zeq-auth/devices/self shows bound.
  • Sign. Every POST /api/zeq-auth/device/session from a bound device carries x-zeq-device-id, x-zeq-device-zeqond, x-zeq-device-sig = Ed25519 over device|<ZID>|<device_id>|<zeqond>|POST|/api/zeq-auth/device/session|sha256(body) where body is the compact JSON the client sent. The Zeqond is the node's own: read zeqond_now from GET /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 is 403 DEVICE_SIGNATURE_BAD_SIGNATURE; outside the window …_STALE. So a copied .zeqkey plus 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-v3 refuses the equation/word login with 403 LAUNCHER_ONLY unless 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 live zeqproof.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 .zeqkey and 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 context 403 LAUNCHER_ONLY → the same login signed by the bound device 200 → 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 }. The user_code (XXXX-XXXX, an alphabet without look-alike letters) is shown on the asking screen; the device_code is the requester's secret (only its SHA-256 is stored).
  • Poll. POST /api/zeq-auth/approve/poll { request_id, device_code } every interval seconds → pending (429 slow_down when faster) · approved (returns the session exactly like login-v3, once) · denied (403) · expired / consumed (410).
  • Approve. The trusted launcher lists GET /api/zeq-auth/approve/pending while unlocked and answers with POST /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_endpoint and the grant urn: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 polls POST /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code and gets authorization_pending / slow_down / expired_token / access_denied until 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 whose ZEQ_ORIGIN is zeq-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 live zeqproof.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 → code 4W8C-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.