home: enrolment reuses the tab's live session β no re-ceremony after create (#1378) (#1382)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+101
−3
changelog/2026-07-28-enrol-session-reuse.md+5 −0home/test/sessionreuse.test.mjs+35 −0home/web/static/enroll.js+43 −2home/web/static/sessionreuse.js+17 −0package.json+1 −1
diff --git a/changelog/2026-07-28-enrol-session-reuse.md b/changelog/2026-07-28-enrol-session-reuse.md| new file mode 100644 |
| index 0000000..d7bd6a4 |
| --- /dev/null |
| +++ b/changelog/2026-07-28-enrol-session-reuse.md |
| @@ -0,0 +1,5 @@ |
| +π€ Create a space from your Eleven account and, when Eleven asks you to sign |
| +in during setup, one tap now finishes it β no second Face ID for the account |
| +you're already signed into, and no wrong turn toward accidentally creating a |
| +second one. Everything still checks out against your account first; if the |
| +sign-in isn't live any more, the usual ceremony runs exactly as before. |
diff --git a/home/test/sessionreuse.test.mjs b/home/test/sessionreuse.test.mjs| new file mode 100644 |
| index 0000000..569ba61 |
| --- /dev/null |
| +++ b/home/test/sessionreuse.test.mjs |
| @@ -0,0 +1,35 @@ |
| +// Tests for the enrolment live-session fast-path decision (#1378), run in |
| +// Node. Same harness style as home/test/serverlist.test.mjs. |
| +// node home/test/sessionreuse.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { reuseCandidate } from "../web/static/sessionreuse.js"; |
| + |
| +const store = (entries) => ({ getItem: (k) => (k in entries ? entries[k] : null) }); |
| + |
| +let pass = 0; |
| +function test(name, fn) { |
| + fn(); |
| + pass++; |
| + console.log("PASS ", name); |
| +} |
| + |
| +test("both halves present β candidate with token + dek", () => { |
| + assert.deepEqual( |
| + reuseCandidate(store({ home_token: "tok", home_dek: "dek64" })), |
| + { token: "tok", dek: "dek64" }, |
| + ); |
| +}); |
| + |
| +test("either half missing β null (ceremony path)", () => { |
| + assert.equal(reuseCandidate(store({ home_token: "tok" })), null, "no DEK β a persisted token alone never skips the ceremony"); |
| + assert.equal(reuseCandidate(store({ home_dek: "dek64" })), null, "no token"); |
| + assert.equal(reuseCandidate(store({})), null, "cold tab"); |
| + assert.equal(reuseCandidate(store({ home_token: "", home_dek: "" })), null, "empty strings are absent"); |
| +}); |
| + |
| +test("throwing or absent storage β null, never a crash", () => { |
| + assert.equal(reuseCandidate({ getItem: () => { throw new Error("denied"); } }), null); |
| + assert.equal(reuseCandidate(null), null); |
| +}); |
| + |
| +console.log("\nALL PASSED (" + pass + ")"); |
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js| index c502b32..675d999 100644 |
| --- a/home/web/static/enroll.js |
| +++ b/home/web/static/enroll.js |
| @@ -14,10 +14,11 @@ |
| // below β the user sees what's being added before anything is written. |
| import { |
| - b64urlToBytes, kekFromPRF, generateDEK, wrapDEK, unwrapDEK, |
| + b64urlToBytes, kekFromPRF, generateDEK, importDEK, wrapDEK, unwrapDEK, |
| sealBlob, unpackBlob, openBlob, |
| } from "/static/crypto.js"; |
| import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js"; |
| +import { reuseCandidate } from "/static/sessionreuse.js"; |
| import { formatCeremonyError } from "/static/ceremony-errors.js"; |
| import { clearTombstone } from "/static/serverlist.js"; |
| @@ -122,6 +123,15 @@ function prefetch() { |
| // up-front "I'm new" button (createAndAdd) runs the register path directly. |
| async function saveSecurely() { |
| disable(); |
| + // The live-session fast path (#1378): the createβsetupβenrol journey is |
| + // same-tab by design, so the tab that just created this space usually still |
| + // holds a live session β the link token plus the tab-scoped DEK |
| + // (home.js Β§ session state). Both were minted by a full ceremony in this |
| + // very tab, and both are validated against the server below, so reusing |
| + // them grants nothing a fresh assertion wouldn't β it just spares the user |
| + // a Face ID for an account this tab is already signed into, and closes the |
| + // second-account trapdoor a dismissed assertion opened (offerCreate). |
| + if (await trySessionReuse()) return; |
| setStatus("Looking for your sign-inβ¦", "busy"); |
| let login; |
| try { |
| @@ -136,6 +146,30 @@ async function saveSecurely() { |
| await addWithLogin(login); |
| } |
| +// trySessionReuse: false = no usable live session, run the ceremony. True = |
| +// the save was handled end-to-end (or genuinely failed β those errors |
| +// propagate to onError like the ceremony path's). Every soft failure β |
| +// missing halves, a dead token, a stale DEK that can't open the blob β falls |
| +// back to the ceremony rather than guessing. |
| +async function trySessionReuse() { |
| + let cand = null; |
| + try { cand = reuseCandidate(sessionStorage); } catch { return false; } |
| + if (!cand) return false; |
| + let got, dek, opened; |
| + try { |
| + setStatus("Checking your sign-inβ¦", "busy"); |
| + got = await fetchBlob(cand.token); // throws on a dead token (401) |
| + if (!got) return false; // signed in but no backup yet β the ceremony path knows that case |
| + dek = await importDEK(cand.dek); |
| + opened = await openBlob(got.bytes, dek); |
| + } catch { |
| + setStatus("", ""); |
| + return false; |
| + } |
| + await applyEntry({ token: cand.token, dek, got, opened }); |
| + return true; |
| +} |
| + |
| // createBlobFor writes a fresh backup whose only server is this entry, keyed to |
| // the given credential. Shared by "create a new passkey" and the "signed in but |
| // no backup yet" case, so both start a backup rather than fragmenting. |
| @@ -194,7 +228,14 @@ async function addWithLogin({ token, credId, prf }) { |
| return; |
| } |
| const dek = await unwrapDEK(wrapped, kek); |
| + await applyEntry({ token, dek, got, opened: await openBlob(got.bytes, dek) }); |
| +} |
| +// applyEntry merges the payload entry into an unlocked blob and saves it β |
| +// the shared tail of the ceremony path (addWithLogin) and the live-session |
| +// fast path (trySessionReuse). Ends in succeed(), a terminal keep-what-I-have |
| +// message, or a thrown error for onError. |
| +async function applyEntry({ token, dek, got, opened }) { |
| // If an entry with DIFFERENT credentials already exists for this host, adding |
| // would overwrite the credentials the user already trusts. Anyone can open |
| // this page with an arbitrary fragment, so a page that knows the host could |
| @@ -204,7 +245,7 @@ async function addWithLogin({ token, credId, prf }) { |
| // and an existing entry with NO credentials β the address-only stub the |
| // create-a-space flow saves. There's nothing to clobber; enrolment upgrades |
| // the stub with this space's key (and its real name). |
| - let cur = got, opened = await openBlob(got.bytes, dek); |
| + let cur = got; |
| const existing = (opened.obj.servers || []).find((s) => s.url === payload.entry.url); |
| if (existing && existing.credentials && credFingerprint(existing) !== credFingerprint(payload.entry)) { |
| const ok = await confirmReplace(existing); |
diff --git a/home/web/static/sessionreuse.js b/home/web/static/sessionreuse.js| new file mode 100644 |
| index 0000000..5303c3f |
| --- /dev/null |
| +++ b/home/web/static/sessionreuse.js |
| @@ -0,0 +1,17 @@ |
| +// The enrolment live-session fast path's DECISION, pure and dependency-free |
| +// so it's unit-testable in Node (#1378; the harness style of serverlist.js). |
| +// A candidate exists only when both halves of a live home session are present |
| +// in the given store β the link token AND the tab-scoped DEK (home.js |
| +// Β§ session state; the DEK never outlives the tab by design). The caller |
| +// (enroll.js trySessionReuse) still validates both against the server β fetch |
| +// the blob, unseal it β before trusting them; this only decides whether |
| +// there's anything to try instead of a fresh passkey ceremony. |
| +export function reuseCandidate(store) { |
| + try { |
| + const token = (store && store.getItem("home_token")) || ""; |
| + const dek = (store && store.getItem("home_dek")) || ""; |
| + return token && dek ? { token, dek } : null; |
| + } catch { |
| + return null; // storage unavailable (private mode) β ceremony it is |
| + } |
| +} |
diff --git a/package.json b/package.json| index 1934512..4aaaa19 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.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/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs" |
| + "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.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/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs" |
| } |
| } |