eleven / messenger Public

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>
Paul Campbell d6b36ba58f5812b9760d13027d9fd8a82ddda611 parent ca9c618
6 files changed, +181 −12
  • home/test/restore-payload.test.mjs +58 −0
  • home/web/pages/restore.html +8 −0
  • home/web/static/home.css +17 −0
  • home/web/static/restore-payload.js +52 −0
  • home/web/static/restore.js +45 −11
  • package.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"
}
}