eleven / messenger Public

server+web: orphaned instance key — honest copy + admin break-glass reset (#785) (#798)

Detect: a keyless member's GET /api/instance-key now carries holders_online
(live sockets) and holders_last_active (newest read watermark among holders
— durable, unlike the in-memory presence map) — metadata the server already
holds; no key material rides on it. The invite sheet's keyless reason (#786)
upgrades to 'the members who hold this space's shared key haven't been
online since May… an admin can reset the key from the admin console.'

Recover: POST /api/admin/instance-key/reset (requireAdmin) drops all holder
rows so the next member to boot mints a fresh key via the existing bootstrap
branch. Honest about the cost, in code and copy: dead-key-sealed user
columns clear (names republish themselves; photos/abouts re-add), instance-
key open groups demote to invite-only with open_key dropped (re-opening
wraps under the NEW key — full heal), tier-gated groups and messages
untouched, member apps left inert rather than deleted. Admin console gains
the control behind a typed RESET confirmation.

Prevent: the verify skill now warns that harness fixtures must never be a
fresh space's first boot (the first booter mints the key — how this bug was
found live).

Tests: TestInstanceKeyOrphanAndReset (liveness fields present only for
keyless-with-holders reads; auth-gated reset; sealed columns cleared; open
group demoted, tier-gated untouched; fresh bootstrap works after). Browser
E2E 4/4: dated honest copy in the sheet, admin reset round-trip, space heals
(the same invite mints after reload). Changelog entry included.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 38935e6deda390dd399b334d7fb7aba3be742b5e parent 2748a4c
8 files changed, +282 −3
  • .claude/skills/verify/SKILL.md +10 −0
  • changelog/2026-07-12-shared-key-reset.md +8 −0
  • db.go +60 −0
  • handlers_test.go +111 −0
  • main.go +40 −1
  • web/pages/admin.html +8 −0
  • web/static/admin.js +25 −0
  • web/static/shell.js +20 −2
diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md
index 2202257..c05d9f1 100644
--- a/.claude/skills/verify/SKILL.md
+++ b/.claude/skills/verify/SKILL.md
@@ -47,3 +47,13 @@ await ctx.addInitScript(([sess, tid]) => {
Worked example (provision + 14-assertion drive): the scripts from the
send-waits-for-upload verification — re-create from this recipe.
+
+## ⚠️ Fixtures must never be a fresh space's first boot
+
+The FIRST member to boot a fresh instance mints its shared instance key
+(#785). A throwaway harness member doing that — then vanishing — orphans the
+key: real members who join later stay keyless forever (invites break, names
+don't decrypt). So when a canary will be handed to a person afterwards, let
+THEM claim first; or reset the instance DB before handover; or check
+`GET /api/instance-key` shows your fixture isn't the sole holder you're about
+to destroy.
diff --git a/changelog/2026-07-12-shared-key-reset.md b/changelog/2026-07-12-shared-key-reset.md
new file mode 100644
index 0000000..36c0618
--- /dev/null
+++ b/changelog/2026-07-12-shared-key-reset.md
@@ -0,0 +1,8 @@
+🤖 Spaces can't get permanently stuck anymore when the members holding the
+shared key are gone for good. If that happens, newer members now see plainly
+what's wrong when inviting ("the members who hold this space's shared key
+haven't been online since May…"), and admins get a way out: a "Reset the
+shared key" control in the admin console mints a fresh key the next time
+anyone opens the app. Resetting is honest about its cost — old profile photos
+and member-built apps can't be recovered — and messages are never touched:
+chats have their own keys.
diff --git a/db.go b/db.go
index 465673f..d7e8808 100644
--- a/db.go
+++ b/db.go
@@ -1911,6 +1911,66 @@ func (db *DB) instanceKeyHolders() ([]string, error) {
return out, rows.Err()
}
+// lastReadActivity is the newest read watermark across a set of users — the
+// durable "when were they last around" proxy (#785): read marks persist and
+// advance with any real use, unlike the in-memory presence map that resets on
+// restart. 0 when none of them ever read anything.
+func (db *DB) lastReadActivity(userIDs []string) (int64, error) {
+ if len(userIDs) == 0 {
+ return 0, nil
+ }
+ args := make([]any, len(userIDs))
+ marks := make([]string, len(userIDs))
+ for i, u := range userIDs {
+ args[i] = u
+ marks[i] = "?"
+ }
+ var at sql.NullInt64
+ err := db.sql.QueryRow(
+ "SELECT MAX(last_read_at) FROM read_marks WHERE user_id IN ("+strings.Join(marks, ",")+")",
+ args...,
+ ).Scan(&at)
+ if err != nil {
+ return 0, err
+ }
+ return at.Int64, nil
+}
+
+// resetInstanceKey is the admin break-glass for an ORPHANED instance key
+// (#785): every holder gone for good, so no one can ever be granted it and
+// every newer member stays keyless. Dropping all holder rows lets the next
+// member to boot mint a fresh key (setupInstanceKey's bootstrap branch) and
+// grant it outward as people come online. The old key's sealed blobs are
+// unreadable forever, so this also clears what can regrow and demotes what
+// can't:
+// - members' sealed name/photo/about columns → NULL (each republishes their
+// name on next load; photos/abouts are re-entered)
+// - open groups gated under the instance key → invite-only, open_key
+// dropped (members keep their thread keys; an admin can re-open the
+// group afterwards, which wraps its key under the NEW instance key)
+// - member apps/bots stay as rows but skip as undecryptable — deleting
+// user content outright is worse than leaving it inert
+//
+// The server never sees any key either way — this deletes wrapped blobs it
+// could never open.
+func (db *DB) resetInstanceKey() error {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, q := range []string{
+ "DELETE FROM instance_keys",
+ "UPDATE users SET label_blob = NULL, avatar_blob = NULL, avatar_alt_blob = NULL, description_blob = NULL",
+ "UPDATE threads SET visibility = 'invite', open_key = NULL, default_join = 0 WHERE visibility = 'open' AND (open_tier IS NULL OR open_tier = '')",
+ } {
+ if _, err := tx.Exec(q); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
// grantInstanceKey wraps the instance key to a member who doesn't yet hold it.
// DO NOTHING (not overwrite): any member may call this for any user id, so
// overwriting would let one member replace another's good wrapped key with junk
diff --git a/handlers_test.go b/handlers_test.go
index 40de7f2..039bc7d 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -2878,3 +2878,114 @@ func TestTierVisibility(t *testing.T) {
t.Fatalf("user + Bea hidden → admin still sees Bea: %v", m)
}
}
+
+// The orphaned-instance-key story (#785), full circle: a keyless member sees
+// holder liveness on the key read (honest copy needs it); the admin reset
+// clears the holders, the dead-key-sealed user columns, and demotes
+// instance-key open groups; afterwards a fresh bootstrap works — the space
+// heals instead of staying wedged forever.
+func TestInstanceKeyOrphanAndReset(t *testing.T) {
+ ts, srv := newTestServer(t)
+
+ // Holder Anna bootstrapped the key once; Bea joined later and never got it.
+ annaTok, annaUID, _ := joinedMember(t, srv, "Anna")
+ beaTok, beaUID, _ := joinedMember(t, srv, "Bea")
+ grant := `{"token":"` + annaTok + `","grants":[{"user_id":"` + annaUID + `","wrapped":"wrapped-to-anna"}]}`
+ if r, _ := http.Post(ts.URL+"/api/instance-key", "application/json", strings.NewReader(grant)); r.StatusCode != http.StatusOK {
+ t.Fatalf("bootstrap grant: %d", r.StatusCode)
+ }
+ if _, err := srv.db.setThreadRead(annaUID, "t-any", 1751000000); err != nil {
+ t.Fatalf("setThreadRead: %v", err)
+ }
+ srv.db.setUserLabelBlob(beaUID, "sealed-name")
+
+ // Bea's key read carries holder liveness: Anna holds it, isn't connected,
+ // and was last around at her read mark.
+ r, _ := http.Get(ts.URL + "/api/instance-key?token=" + beaTok)
+ var got struct {
+ Wrapped string `json:"wrapped"`
+ Have []string `json:"have"`
+ HoldersOnline *bool `json:"holders_online"`
+ HoldersLastActive int64 `json:"holders_last_active"`
+ }
+ json.NewDecoder(r.Body).Decode(&got)
+ if got.Wrapped != "" || len(got.Have) != 1 {
+ t.Fatalf("keyless read: %+v", got)
+ }
+ if got.HoldersOnline == nil || *got.HoldersOnline {
+ t.Fatalf("holders_online: %+v (want present and false)", got.HoldersOnline)
+ }
+ if got.HoldersLastActive != 1751000000 {
+ t.Fatalf("holders_last_active: %d", got.HoldersLastActive)
+ }
+
+ // A holder's OWN read carries no liveness noise.
+ r, _ = http.Get(ts.URL + "/api/instance-key?token=" + annaTok)
+ var holder map[string]any
+ json.NewDecoder(r.Body).Decode(&holder)
+ if _, present := holder["holders_online"]; present {
+ t.Fatal("holder's read must not carry liveness fields")
+ }
+
+ // An open group gated under the instance key, plus one under a tier —
+ // reset demotes only the former.
+ openTid, err := srv.db.createThreadVis("group", "n", []ThreadMemberKey{{UserID: annaUID, Wrapped: "w"}}, "open", "open-key-blob", "")
+ if err != nil {
+ t.Fatalf("createThreadVis: %v", err)
+ }
+ tierTid, err := srv.db.createThreadVis("group", "n2", []ThreadMemberKey{{UserID: annaUID, Wrapped: "w2"}}, "open", "tier-open-key", "tier-1")
+ if err != nil {
+ t.Fatalf("createThreadVis tier: %v", err)
+ }
+
+ // The reset is admin-gated.
+ if r, _ := http.Post(ts.URL+"/api/admin/instance-key/reset", "application/json", nil); r.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("unauthenticated reset: want 401, got %d", r.StatusCode)
+ }
+ if r := adminPost(t, ts.URL+"/api/admin/instance-key/reset", `{}`); r.StatusCode != http.StatusOK {
+ t.Fatalf("admin reset: %d", r.StatusCode)
+ }
+
+ // Holders gone; Bea's next read shows a bootstrappable space (no liveness
+ // fields — there are no holders to wait for).
+ r, _ = http.Get(ts.URL + "/api/instance-key?token=" + beaTok)
+ var after map[string]any
+ json.NewDecoder(r.Body).Decode(&after)
+ if have := after["have"].([]any); len(have) != 0 {
+ t.Fatalf("holders after reset: %v", have)
+ }
+ if _, present := after["holders_online"]; present {
+ t.Fatal("no-holder read must not carry liveness fields")
+ }
+
+ // Dead-key-sealed columns cleared; instance-key open group demoted with
+ // its open_key dropped; tier-gated one untouched.
+ entries, _ := srv.db.roster()
+ for _, e := range entries {
+ if e.LabelBlob != "" {
+ t.Fatalf("sealed label survived reset: %+v", e)
+ }
+ }
+ threads, _ := srv.db.threadsForUser(annaUID)
+ for _, th := range threads {
+ switch th.ID {
+ case openTid:
+ if th.Visibility == "open" || th.OpenKey != "" {
+ t.Fatalf("instance-key open group not demoted: %+v", th)
+ }
+ case tierTid:
+ if th.Visibility != "open" || th.OpenKey != "tier-open-key" {
+ t.Fatalf("tier-gated group wrongly touched: %+v", th)
+ }
+ }
+ }
+
+ // Full circle: Bea bootstraps a fresh key.
+ grant = `{"token":"` + beaTok + `","grants":[{"user_id":"` + beaUID + `","wrapped":"wrapped-to-bea"}]}`
+ if r, _ := http.Post(ts.URL+"/api/instance-key", "application/json", strings.NewReader(grant)); r.StatusCode != http.StatusOK {
+ t.Fatalf("re-bootstrap: %d", r.StatusCode)
+ }
+ if w, ok, _ := srv.db.instanceKeyFor(beaUID); !ok || w != "wrapped-to-bea" {
+ t.Fatalf("fresh key not held after reset: %q ok=%v", w, ok)
+ }
+}
diff --git a/main.go b/main.go
index 868d44b..d940edb 100644
--- a/main.go
+++ b/main.go
@@ -412,6 +412,7 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("GET /api/instance-key", s.getInstanceKey)
mux.HandleFunc("POST /api/instance-key", s.postInstanceKey)
mux.HandleFunc("POST /api/instance-key/nudge", s.nudgeInstanceKey)
+ mux.HandleFunc("POST /api/admin/instance-key/reset", s.resetInstanceKeyHandler)
// Tiers: admin-curated member sets with their own keys (docs/community-and-tiers.md).
mux.HandleFunc("GET /api/tiers", s.getTiers)
mux.HandleFunc("POST /api/tiers", s.postTier)
@@ -1476,7 +1477,45 @@ func (s *Server) getInstanceKey(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
- writeJSON(w, http.StatusOK, map[string]any{"wrapped": wrapped, "have": have})
+ out := map[string]any{"wrapped": wrapped, "have": have}
+ // A keyless member waiting on holders gets liveness alongside (#785):
+ // whether any holder is connected right now, and the newest read
+ // watermark among them — enough for the client to say "the members who
+ // hold this space's key haven't been online since May" instead of an
+ // eternal "still setting up". Metadata the server already holds (it owns
+ // the sockets and the read marks); no key material rides on it.
+ if wrapped == "" && len(have) > 0 {
+ online := false
+ for _, h := range have {
+ if s.presence.connected(h) {
+ online = true
+ break
+ }
+ }
+ lastActive, err := s.db.lastReadActivity(have)
+ if err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ out["holders_online"] = online
+ out["holders_last_active"] = lastActive
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// resetInstanceKeyHandler is the break-glass for an orphaned instance key
+// (#785): admin-gated, destructive by design (the old key's sealed blobs are
+// gone for good — the web admin console spells that out before calling). The
+// next member to load the app bootstraps a fresh key.
+func (s *Server) resetInstanceKeyHandler(w http.ResponseWriter, r *http.Request) {
+ if !s.requireAdmin(w, r) {
+ return
+ }
+ if err := s.db.resetInstanceKey(); err != nil {
+ httpError(w, http.StatusInternalServerError, "db error")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// postInstanceKey stores the (server-blind) instance key wrapped to one or more
diff --git a/web/pages/admin.html b/web/pages/admin.html
index fe43977..be4d3fd 100644
--- a/web/pages/admin.html
+++ b/web/pages/admin.html
@@ -189,6 +189,14 @@
<div class="row"><button class="secondary" id="testPush">Send test notification</button></div>
</section>
+ <section>
+ <h2>Shared key</h2>
+ <p class="sub" style="margin:0 0 8px">Every member's name and photo is locked with one shared key, passed member to member. If everyone who held it is gone for good, newer members stay locked out — invites included. Resetting mints a fresh key the next time anyone opens the app.</p>
+ <p class="sub" style="margin:0 0 8px"><strong>Resetting is permanent:</strong> profile photos, "about me" notes and member-built apps locked with the old key can't be recovered — people re-add them. Names come back on their own the next time each member opens the app. Open groups close to invite-only (reopen them afterwards). Messages are untouched — chats have their own keys.</p>
+ <div class="row"><button class="secondary" id="resetInstanceKey">Reset the shared key…</button></div>
+ <p id="resetKeyStatus" class="sub" style="margin:8px 0 0" aria-live="polite"></p>
+ </section>
+
<section>
<h2>Delegate admin role</h2>
<p class="sub" style="margin:0 0 10px">Create an invite that makes the claimer an admin. They can then access this console with their device token (no need to share your admin link). Use this to share admin duties or to recover access if you lose your token.</p>
diff --git a/web/static/admin.js b/web/static/admin.js
index c585d78..dfcab64 100644
--- a/web/static/admin.js
+++ b/web/static/admin.js
@@ -43,6 +43,7 @@ async function init() {
wireUpdates();
wireCustomApps();
wireTestPush();
+ wireResetInstanceKey();
wireCreateAdmin();
wireCreate();
wireCreateAgent();
@@ -432,6 +433,30 @@ function wireCustomApps() {
// ---- notifications ------------------------------------------------------
+// The #785 break-glass: reset an orphaned shared (instance) key. Guarded by a
+// typed confirmation — this is the one admin action that permanently orphans
+// content (old-key photos/abouts/member apps), so a misclick must not fire it.
+function wireResetInstanceKey() {
+ const status = document.getElementById("resetKeyStatus");
+ document.getElementById("resetInstanceKey").onclick = async () => {
+ const word = prompt(
+ "Reset this space's shared key?\n\n" +
+ "Profile photos, about-me notes and member-built apps locked with the old key are lost for good. " +
+ "Names return automatically; photos and apps must be re-added. Open groups close to invite-only. " +
+ "Messages are untouched.\n\nType RESET to confirm.",
+ );
+ if (word === null) return;
+ if (word.trim().toUpperCase() !== "RESET") {
+ status.textContent = "Not reset — confirmation didn't match.";
+ return;
+ }
+ const res = await api("POST", "/api/admin/instance-key/reset");
+ status.textContent = res.ok
+ ? "Shared key reset. The next member to open the app mints a fresh one."
+ : "Couldn't reset — try again.";
+ };
+}
+
function wireTestPush() {
document.getElementById("testPush").onclick = async () => {
const res = await api("POST", "/api/admin/test-push");
diff --git a/web/static/shell.js b/web/static/shell.js
index 43e3f8b..a79f85d 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -1800,6 +1800,11 @@ function pickerSection(title) {
// pubkey (server-blind, reusing the thread-key wrap). On boot: adopt our copy if
// granted; bootstrap it if nobody has it yet; and grant it to any member missing
// it (so it propagates as people join). Never used for messages.
+// Holder liveness from the last keyless /api/instance-key read (#785): are
+// any key holders online, and when was one last around. Feeds the honest
+// invite copy below; null until a keyless read reports it.
+let keyHolderState = null;
+
async function setupInstanceKey() {
let r;
try {
@@ -1807,6 +1812,7 @@ async function setupInstanceKey() {
} catch {
return;
}
+ if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 };
if (r.wrapped) {
bootRaw.instance = { wrapped: r.wrapped };
scheduleStoreSnapshot();
@@ -1856,6 +1862,7 @@ async function pollForInstanceKey(tries = 24) {
} catch {
continue;
}
+ if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 };
if (r.wrapped) {
try {
instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv);
@@ -9737,9 +9744,20 @@ function friendlyError(message) {
}
// Why an invite can't mint yet: the invitee's name seals under the shared
-// instance key, which hasn't reached this device. Honest on its own; #785
-// upgrades it with holder liveness ("…haven't been online since May").
+// instance key, which hasn't reached this device. When the server reported
+// holder liveness (#785), say the honest thing — including the way out when
+// the holders look gone for good (the admin console's key reset).
function keylessInviteReason() {
+ const h = keyHolderState;
+ if (h && !h.online) {
+ const since = h.lastActive
+ ? ` since ${new Date(h.lastActive * 1000).toLocaleDateString([], { month: "long", year: "numeric" })}`
+ : "";
+ return (
+ `The members who hold this space's shared key haven't been online${since}. ` +
+ `Invites need that key. If those members are gone for good, an admin can reset the shared key from the admin console.`
+ );
+ }
return "This space's shared key hasn't reached your device yet — it arrives automatically when another member who holds it comes online. Until then, invites can't be created.";
}