eleven / messenger Public

home: enrolment reuses the tab's live session β€” no re-ceremony after create (#1378) (#1382)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d2c4092728cc9a1614afc4357f55d8a331cf4c7f parent 97d318a
5 files changed, +101 −3
  • changelog/2026-07-28-enrol-session-reuse.md +5 −0
  • home/test/sessionreuse.test.mjs +35 −0
  • home/web/static/enroll.js +43 −2
  • home/web/static/sessionreuse.js +17 −0
  • package.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"
}
}