home: CLI handoff mode on the restore page (loopback + paste code) (#483)
A third caller shape for the passkey ceremony, {cli:1, nonce, ephPub,
port?}: after unlock the home session is ECIES-wrapped to the caller's
ephemeral key (exactly the native-app handoff) and returned via a
redirect to http://127.0.0.1:<port> β host hardcoded, port validated β
or, with no port (SSH'd terminals), shown as a copy-paste code. Payload
shape rules move to restore-payload.js, pure and Node-tested.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+181
−12
home/test/restore-payload.test.mjs+58 −0home/web/pages/restore.html+8 −0home/web/static/home.css+17 −0home/web/static/restore-payload.js+52 −0home/web/static/restore.js+45 −11package.json+1 −1
diff --git a/home/test/restore-payload.test.mjs b/home/test/restore-payload.test.mjs| new file mode 100644 |
| index 0000000..1a24df7 |
| --- /dev/null |
| +++ b/home/test/restore-payload.test.mjs |
| @@ -0,0 +1,58 @@ |
| +// π€ Tests for the restore page's payload validation + CLI return URL β the |
| +// pure half of the passkey handoff (home/web/static/restore-payload.js). |
| +// node home/test/restore-payload.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { validRestorePayload, validCLIPort, cliReturnURL } from "../web/static/restore-payload.js"; |
| + |
| +// ---- instance shape --------------------------------------------------------- |
| + |
| +assert.deepEqual( |
| + validRestorePayload({ ret: "https://chat.example.com/", nonce: "n", ephPub: "P" }), |
| + { ret: "https://chat.example.com/", nonce: "n", ephPub: "P" }, |
| + "instance payload passes", |
| +); |
| +assert.equal(validRestorePayload({ ret: "not a url", nonce: "n", ephPub: "P" }), null, "unparseable ret rejected"); |
| +assert.equal(validRestorePayload({ ret: "https://x.com", nonce: "n" }), null, "missing ephPub rejected"); |
| +assert.equal(validRestorePayload(null), null, "null rejected"); |
| + |
| +// ---- app shape -------------------------------------------------------------- |
| + |
| +assert.deepEqual( |
| + validRestorePayload({ app: 1, nonce: "n", ephPub: "P" }), |
| + { app: 1, nonce: "n", ephPub: "P" }, |
| + "app payload passes", |
| +); |
| +// Only the expected fields survive β a smuggled ret must not ride along on an |
| +// app/cli payload (their return targets are fixed / loopback-only). |
| +assert.equal(validRestorePayload({ app: 1, nonce: "n", ephPub: "P", ret: "https://evil.example" }).ret, |
| + undefined, "app payload drops extra fields"); |
| + |
| +// ---- cli shape -------------------------------------------------------------- |
| + |
| +assert.deepEqual( |
| + validRestorePayload({ cli: 1, nonce: "n", ephPub: "P", port: 49152 }), |
| + { cli: 1, nonce: "n", ephPub: "P", port: 49152 }, |
| + "cli payload with port passes", |
| +); |
| +assert.deepEqual( |
| + validRestorePayload({ cli: 1, nonce: "n", ephPub: "P" }), |
| + { cli: 1, nonce: "n", ephPub: "P", port: undefined }, |
| + "cli payload without port (paste mode) passes", |
| +); |
| +for (const port of [0, 80, 1023, 65536, -1, 1.5, "8080", null]) { |
| + assert.equal(validRestorePayload({ cli: 1, nonce: "n", ephPub: "P", port }), null, `bad port ${port} rejected`); |
| +} |
| +assert.equal(validRestorePayload({ cli: 1, ephPub: "P", port: 49152 }), null, "cli missing nonce rejected"); |
| +assert.equal(validRestorePayload({ cli: 1, nonce: "n", ephPub: "P", port: 49152, ret: "https://evil.example" }).ret, |
| + undefined, "cli payload drops extra fields"); |
| + |
| +// ---- loopback URL ----------------------------------------------------------- |
| + |
| +assert.equal(cliReturnURL(49152, "abc-_123"), "http://127.0.0.1:49152/cb?d=abc-_123", "loopback URL shape"); |
| +assert.throws(() => cliReturnURL(80, "x"), "privileged port refused"); |
| +assert.throws(() => cliReturnURL("49152", "x"), "string port refused"); |
| +assert.equal(validCLIPort(1024), true); |
| +assert.equal(validCLIPort(65535), true); |
| +assert.equal(validCLIPort(1023), false); |
| + |
| +console.log("restore-payload.test.mjs: all passed"); |
diff --git a/home/web/pages/restore.html b/home/web/pages/restore.html| index 96d4481..09ed256 100644 |
| --- a/home/web/pages/restore.html |
| +++ b/home/web/pages/restore.html |
| @@ -40,6 +40,14 @@ |
| <!-- Inline status only β never a floating toast (product-wide rule). --> |
| <p class="status" id="status"></p> |
| + |
| + <!-- CLI paste handoff: a terminal with no reachable loopback (SSH) gets the |
| + sealed session as a code to copy back. It's ciphertext to everyone but |
| + the terminal holding the ephemeral private key, so showing it is safe. --> |
| + <div class="code-wrap hidden" id="code-wrap"> |
| + <textarea id="code" class="code" readonly rows="4" spellcheck="false" aria-label="Sign-in code for your terminal"></textarea> |
| + <button id="copy" class="secondary">Copy code</button> |
| + </div> |
| </main> |
| <script type="module" src="/static/restore.js"></script> |
| </body> |
diff --git a/home/web/static/home.css b/home/web/static/home.css| index 8ea38ac..eabcb09 100644 |
| --- a/home/web/static/home.css |
| +++ b/home/web/static/home.css |
| @@ -110,6 +110,23 @@ input { |
| } |
| input:focus-visible { outline: 2px solid var(--accent); border-color: transparent; } |
| +/* CLI paste handoff: the sealed sign-in code a terminal user copies back. |
| + Monospace + break-anywhere so the base64url block wraps cleanly. */ |
| +.code-wrap { display: grid; gap: 10px; margin-top: 14px; justify-items: center; } |
| +.code { |
| + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; |
| + width: 100%; |
| + box-sizing: border-box; |
| + border: 1px solid var(--border); |
| + border-radius: 12px; |
| + padding: 10px 12px; |
| + background: var(--panel); |
| + color: var(--ink); |
| + word-break: break-all; |
| + resize: none; |
| +} |
| +.code:focus-visible { outline: 2px solid var(--accent); border-color: transparent; } |
| + |
| /* Inline status only β never a floating toast (product-wide rule). */ |
| .status { margin: 14px 0 0; font-size: 14px; color: var(--muted); } |
| .status.error { color: var(--danger); } |
diff --git a/home/web/static/restore-payload.js b/home/web/static/restore-payload.js| new file mode 100644 |
| index 0000000..717d734 |
| --- /dev/null |
| +++ b/home/web/static/restore-payload.js |
| @@ -0,0 +1,52 @@ |
| +// π€ Validation of the restore page's #fragment payload β pure and |
| +// dependency-free so it's unit-testable in Node |
| +// (home/test/restore-payload.test.mjs). Three callers ask this page to run the |
| +// passkey ceremony, distinguished by shape: |
| +// |
| +// { ret, nonce, ephPub } an instance page: gets back ONE entry's |
| +// credentials, matched by ret's origin |
| +// { app: 1, nonce, ephPub } the native app: gets the home session, |
| +// returned via the FIXED custom scheme |
| +// { cli: 1, nonce, ephPub, port? } the eleven CLI: gets the home session, |
| +// returned via a loopback redirect when |
| +// port is set, else shown as a copy-paste |
| +// code (a terminal SSH'd into another box |
| +// can't receive a local redirect) |
| +// |
| +// Every shape carries an ephemeral public key: whatever comes back is |
| +// ECIES-wrapped to it, so secrets are never plaintext in a URL or on screen. |
| + |
| +// A loopback listener needs an unprivileged, real port. The CLI binds |
| +// 127.0.0.1:0 and reports what it got. |
| +export function validCLIPort(port) { |
| + return Number.isInteger(port) && port >= 1024 && port <= 65535; |
| +} |
| + |
| +// validRestorePayload returns the payload if it's one of the three shapes |
| +// above (with only the expected fields trusted), else null. |
| +export function validRestorePayload(p) { |
| + if (!p || typeof p.nonce !== "string" || typeof p.ephPub !== "string") return null; |
| + if (p.app === 1) return { app: 1, nonce: p.nonce, ephPub: p.ephPub }; |
| + if (p.cli === 1) { |
| + if (p.port !== undefined && !validCLIPort(p.port)) return null; |
| + return { cli: 1, nonce: p.nonce, ephPub: p.ephPub, port: p.port }; |
| + } |
| + if (typeof p.ret !== "string") return null; |
| + try { |
| + new URL(p.ret); // must parse β it's the redirect target |
| + } catch { |
| + return null; |
| + } |
| + return { ret: p.ret, nonce: p.nonce, ephPub: p.ephPub }; |
| +} |
| + |
| +// cliReturnURL builds the loopback redirect for a CLI handoff. The host is |
| +// HARDCODED to 127.0.0.1 (like the app path's custom scheme): a crafted |
| +// payload can vary only the port, so the sealed session can only ever be |
| +// delivered to a listener on the user's own machine β and it's ciphertext to |
| +// everyone but the process holding the ephemeral private key. |
| +export function cliReturnURL(port, backB64) { |
| + if (!validCLIPort(port)) throw new Error("bad CLI port"); |
| + // backB64 is base64url β URL-safe by construction, no encoding needed. |
| + return `http://127.0.0.1:${port}/cb?d=${backB64}`; |
| +} |
diff --git a/home/web/static/restore.js b/home/web/static/restore.js| index 38f288a..7b91acc 100644 |
| --- a/home/web/static/restore.js |
| +++ b/home/web/static/restore.js |
| @@ -15,6 +15,7 @@ import { |
| b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, unpackBlob, openBlob, wrapToPublicKey, |
| } from "/static/crypto.js"; |
| import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js"; |
| +import { validRestorePayload, cliReturnURL } from "/static/restore-payload.js"; |
| const $ = (id) => document.getElementById(id); |
| function setStatus(msg, kind) { |
| @@ -27,15 +28,13 @@ function readPayload() { |
| const raw = location.hash.slice(1); |
| if (!raw) return null; |
| try { |
| - const p = JSON.parse(new TextDecoder().decode(b64urlToBytes(raw))); |
| - // Native-app handoff: the app generated {nonce, ephPub} and opened this |
| - // page because its own passkey provider can't do PRF (e.g. 1Password's |
| - // native macOS provider). No ret URL β the return target is the FIXED |
| - // custom scheme in restore(), never caller-supplied. |
| - if (p && p.app === 1 && typeof p.nonce === "string" && typeof p.ephPub === "string") return p; |
| - if (!p || typeof p.ret !== "string" || typeof p.nonce !== "string" || typeof p.ephPub !== "string") return null; |
| - new URL(p.ret); // must parse β it's the redirect target |
| - return p; |
| + // Shape rules live in restore-payload.js (pure, Node-tested). App handoff: |
| + // the native app generated {nonce, ephPub} and opened this page because its |
| + // own passkey provider can't do PRF (e.g. 1Password's native macOS |
| + // provider) β its return target is the FIXED custom scheme in restore(), |
| + // never caller-supplied. CLI handoff: the same idea from a terminal, |
| + // returned to a loopback port (or shown as a paste code for SSH sessions). |
| + return validRestorePayload(JSON.parse(new TextDecoder().decode(b64urlToBytes(raw)))); |
| } catch { |
| return null; |
| } |
| @@ -95,6 +94,40 @@ async function restore() { |
| bytesToB64url(new TextEncoder().encode(back))), 600); |
| return; |
| } |
| + |
| + // CLI handoff: same session hand-over as the app path, different last hop β |
| + // a redirect to the loopback listener `eleven signin` opened (127.0.0.1 is |
| + // hardcoded in cliReturnURL, so a crafted payload can only pick the port), |
| + // or, when the payload carries no port (a terminal SSH'd into another |
| + // machine can't receive a local redirect), a code the user copies back into |
| + // the terminal. Either way the session is ciphertext to everything but the |
| + // process holding the ephemeral private key. |
| + if (payload.cli === 1) { |
| + const dekB64 = await exportDEK(dek); |
| + const session = JSON.stringify({ v: 1, token, credId, dek: dekB64 }); |
| + const sealed = await wrapToPublicKey(bytesToB64url(new TextEncoder().encode(session)), payload.ephPub); |
| + const back = bytesToB64url(new TextEncoder().encode( |
| + JSON.stringify({ nonce: payload.nonce, wrapped: sealed }))); |
| + $("actions").classList.add("hidden"); |
| + $("badge").classList.add("sealed"); |
| + if (payload.port) { |
| + setStatus("Unlocked. Handing back to your terminalβ¦", "ok"); |
| + setTimeout(() => location.replace(cliReturnURL(payload.port, back)), 600); |
| + return; |
| + } |
| + setStatus("Unlocked. Paste this code into your terminal:", "ok"); |
| + $("code").value = back; |
| + $("code-wrap").classList.remove("hidden"); |
| + $("copy").addEventListener("click", async () => { |
| + try { |
| + await navigator.clipboard.writeText(back); |
| + $("copy").textContent = "Copied"; |
| + } catch { |
| + $("code").select(); // clipboard blocked β leave the text selected to copy by hand |
| + } |
| + }); |
| + return; |
| + } |
| const { obj } = await openBlob(got.bytes, dek); |
| // Only the entry whose origin matches the requester (ret) is ever returned. |
| @@ -138,9 +171,10 @@ const onError = (err) => { |
| // ---- boot --------------------------------------------------------------------- |
| // The tile names what's being signed in: the requesting chat's host, or the |
| -// native app for an app handoff (which carries no ret URL β reading .ret |
| +// native app / terminal for a handoff (those carry no ret URL β reading .ret |
| // unguarded here threw before the ceremony could start). |
| -const entryName = payload && payload.ret ? new URL(payload.ret).host : "the Eleven app"; |
| +const entryName = payload && payload.ret ? new URL(payload.ret).host |
| + : payload && payload.cli === 1 ? "your terminal" : "the Eleven app"; |
| if (!payload) { |
| setStatus("This page was opened without a valid request. Go back to your chat and tap Sign in.", "error"); |
diff --git a/package.json b/package.json| index abc1b47..437da4c 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/event-vectors.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/event-vectors.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs" |
| } |
| } |