eleven / messenger Public

home: E2EE labels for sign-in methods (#1140)

'Apple, added 19 Jul' doesn't say which Apple ID. Labels live in the vault
blob (vault.obj.methods, keyed by the anchors listing's kind/id) โ€” never
server metadata, so the we-don't-store-your-email promise holds. Email adds
auto-label with the address (listing diff โ€” the client can't compute the
blind index); every row's kebab offers Add/Edit label via an inline form;
labels survive saveVault's retry merge and ride the combine union.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e3ae695ea8a38beccdff52d44111e0d6f38b0ead parent e857b08
3 files changed, +119 −4
  • changelog/2026-07-19-method-labels.md +7 −0
  • home/web/pages/index.html +3 −0
  • home/web/static/home.js +109 −4
diff --git a/changelog/2026-07-19-method-labels.md b/changelog/2026-07-19-method-labels.md
new file mode 100644
index 0000000..29b28b5
--- /dev/null
+++ b/changelog/2026-07-19-method-labels.md
@@ -0,0 +1,7 @@
+๐Ÿค– Your sign-in methods can now carry names. "Apple, added 19 Jul" doesn't
+say which Apple ID โ€” so each row in Sign-in options takes a label: an email
+you add is labelled with its address automatically, and any row's โ‹ฎ menu
+offers "Add a label" ("personal", "work", whatever tells you apart). Labels
+are sealed into your encrypted backup like your space names โ€” Eleven's
+servers can't read them, so the no-spam promise stands: we still can't see
+your email address.
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 0b99e89..a8e4f9d 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -307,6 +307,9 @@
ul.methods .method-name { font-weight: 600; line-height: 1.25; }
ul.methods .method-added { color: var(--muted); font-size: .72rem; line-height: 1.3; }
#methods-list > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 14px; text-align: left; }
+ /* The row's inline label editor (the sub-line become a small form). */
+ ul.methods .label-edit { display: flex; gap: 6px; margin-top: 4px; }
+ ul.methods .label-edit input { flex: 1; min-width: 0; font-size: .85rem; padding: 6px 8px; }
/* The per-row kebab anchors its own .menu-pop (the account menu's
vocabulary, one wrap per row). */
.row-kebab-wrap { position: relative; flex: none; }
diff --git a/home/web/static/home.js b/home/web/static/home.js
index aafce43..c19b053 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -195,11 +195,16 @@ async function saveVault() {
// keeps its tombstone and wins over the other writer's copy).
const removed = mergeRemoved(opened.obj.removed, vault.obj.removed)
.filter((t) => !vault.obj.servers.some((s) => s.url === t.url));
+ // Method labels merge the same way: the other writer's base, ours
+ // overlaid (last-writer-wins per key; a raced deletion may resurrect a
+ // label โ€” they're a nicety, that's fine at this stage).
+ const methods = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) };
const obj = {
...opened.obj,
servers: [...merged.values()].filter((s) => !removed.some((t) => t.url === s.url)),
};
if (removed.length) obj.removed = removed; else delete obj.removed;
+ if (Object.keys(methods).length) obj.methods = methods; else delete obj.methods;
vault = { obj, keys: opened.keys, version: cur.version };
}
throw new Error("could not save after retry");
@@ -1179,6 +1184,43 @@ function methodAdded(ts) {
return "added " + d.toLocaleDateString(undefined, opts);
}
+// ---- method labels --------------------------------------------------------------
+
+// A method row's human label ("personal", the email address) is E2EE state,
+// never server metadata: vault.obj.methods = { "<kind>/<id>": { label } },
+// keyed the way the anchors listing names rows, riding the sealed blob like
+// space names do. Sealed sessions (no DEK) neither show nor offer labels.
+let editingLabel = "";
+
+function canLabel() { return !!(vault && dekKey); }
+
+function methodLabel(key) {
+ const m = vault && vault.obj && vault.obj.methods;
+ return (m && m[key] && m[key].label) || "";
+}
+
+async function setMethodLabel(key, label) {
+ if (!canLabel()) return;
+ const m = { ...(vault.obj.methods || {}) };
+ if (label) m[key] = { label }; else delete m[key];
+ if (Object.keys(m).length) vault.obj.methods = m; else delete vault.obj.methods;
+ await saveVault();
+}
+
+// labelNewEmail stores a just-verified address as its anchor's label. The
+// client can't compute the anchor id (that's the server's peppered blind
+// index), so diff the listing: the email id that wasn't there before the
+// add is the one just created.
+async function labelNewEmail(email) {
+ if (!canLabel()) return;
+ const before = new Set((anchors || []).filter((x) => x.kind === "email").map((x) => x.id));
+ try {
+ const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } });
+ const fresh = (r.anchors || []).filter((x) => x.kind === "email" && !before.has(x.id));
+ if (fresh.length === 1) { await setMethodLabel("email/" + fresh[0].id, email); renderMethods(); }
+ } catch { /* the label is a nicety โ€” the anchor itself is already in */ }
+}
+
function renderMethods() {
closeRowMenu(false);
const box = $("methods-list");
@@ -1224,8 +1266,9 @@ function methodRow(a) {
li.dataset.key = key;
const def = METHOD_KINDS.find((k) => k.kind === a.kind);
const label = def ? def.name : a.kind;
+ const tag = methodLabel(key); // "" when unlabelled, or the vault is sealed
const added = methodAdded(a.created_at);
- const longName = label + (added ? ", " + added : ""); // for the aria labels
+ const longName = label + (tag ? ", " + tag : "") + (added ? ", " + added : ""); // for the aria labels
const main = document.createElement("div");
main.className = "method-main";
@@ -1233,10 +1276,12 @@ function methodRow(a) {
name.className = "method-name";
name.textContent = label;
main.append(name);
- if (added) {
+ if (editingLabel === key) {
+ main.append(labelEditor(key, tag, longName));
+ } else if (tag || added) {
const sub = document.createElement("span");
sub.className = "method-added";
- sub.textContent = added;
+ sub.textContent = tag && added ? tag + " ยท " + added : tag || added;
main.append(sub);
}
li.append(main);
@@ -1266,12 +1311,50 @@ function methodRow(a) {
focusMethodRow(key, "button");
});
li.append(yes, no);
- } else {
+ } else if (editingLabel !== key) {
li.append(rowKebab(key, def, longName, a));
}
return li;
}
+// The inline label editor (never a browser prompt): the row's sub-line
+// becomes a small form. Enter saves, Escape or Cancel keeps the old label.
+function labelEditor(key, current, longName) {
+ const form = document.createElement("form");
+ form.className = "label-edit";
+ const input = document.createElement("input");
+ input.type = "text";
+ input.maxLength = 60;
+ input.value = current;
+ input.placeholder = "e.g. personal, work";
+ input.setAttribute("aria-label", "Label for " + longName);
+ const save = document.createElement("button");
+ save.type = "submit";
+ save.className = "pill";
+ save.textContent = "Save";
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "pill";
+ cancel.textContent = "Cancel";
+ cancel.setAttribute("aria-label", "Cancel editing the label for " + longName);
+ const done = () => { editingLabel = ""; renderMethods(); focusMethodRow(key, ".icon-kebab"); };
+ form.addEventListener("submit", async (e) => {
+ e.preventDefault();
+ save.disabled = cancel.disabled = true;
+ try {
+ await setMethodLabel(key, input.value.trim());
+ done();
+ } catch {
+ save.disabled = cancel.disabled = false;
+ methodsStatus("Couldnโ€™t save the label โ€” try again.", "error");
+ }
+ });
+ cancel.addEventListener("click", done);
+ input.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.stopPropagation(); done(); } });
+ form.append(input, save, cancel);
+ return form;
+}
+
// rowKebab is the row's own menu โ€” the account kebab's vocabulary scaled
// down: aria-haspopup/expanded, focus onto the first item, Escape and
// outside-click both close (the shared manager keeps one open at a time).
@@ -1302,6 +1385,21 @@ function rowKebab(key, def, longName, a) {
add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); });
menu.append(add);
}
+ if (canLabel()) {
+ const lab = document.createElement("button");
+ lab.type = "button";
+ lab.setAttribute("role", "menuitem");
+ lab.textContent = methodLabel(key) ? "Edit label" : "Add a label";
+ lab.setAttribute("aria-label", (methodLabel(key) ? "Edit the label for " : "Add a label for ") + longName);
+ lab.addEventListener("click", () => {
+ closeRowMenu(false);
+ editingLabel = key;
+ armedRemove = "";
+ renderMethods();
+ focusMethodRow(key, "input");
+ });
+ menu.append(lab);
+ }
const rm = document.createElement("button");
rm.type = "button";
rm.setAttribute("role", "menuitem");
@@ -1311,6 +1409,7 @@ function rowKebab(key, def, longName, a) {
rm.addEventListener("click", () => {
closeRowMenu(false);
armedRemove = key;
+ editingLabel = "";
renderMethods();
focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove
});
@@ -1381,6 +1480,7 @@ async function doRemoveAnchor(a, proof) {
headers: { "X-Ceremony-Proof": proof },
});
methodsStatus("Removed.", "ok");
+ setMethodLabel(a.kind + "/" + a.id, "").catch(() => { /* orphaned label โ€” harmless */ });
} catch (e) {
methodsStatus((e && e.message) || "Couldn't remove it.", "error");
}
@@ -1611,6 +1711,7 @@ async function finishEmailAdd(email, code, proof) {
body: JSON.stringify({ email, code, link: true }),
});
if (res.ok) {
+ labelNewEmail(email); // fire-and-forget: the address becomes the row's E2EE label
backToMethodsList("Email added โ€” you can sign in with it now.", "ok");
return;
}
@@ -1896,6 +1997,10 @@ async function combineUnlock() {
.filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url));
vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url));
if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed;
+ // Method labels ride the union too: absorbed first, survivor overlays โ€”
+ // the moved-over anchors keep the names their old account gave them.
+ const labels = { ...(opened.obj.methods || {}), ...(vault.obj.methods || {}) };
+ if (Object.keys(labels).length) vault.obj.methods = labels;
const movedSpaces = vault.obj.servers.length - before;
// The absorbed passkey just proved AND unlocked โ€” after the merge it
// signs into the survivor, so wrap the survivor's DEK for it too: it