Skip to content

feat: record a buddies inbox's successor after a key change - #76

Merged
leviFrosty merged 1 commit into
web-launchfrom
inbox-successor
Oct 31, 2026
Merged

leviFrosty merged 1 commit into
web-launchfrom
inbox-successor

Conversation

@leviFrosty

@leviFrosty leviFrosty commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Adds a relay-attested successor for a Buddies inbox after an account key change (witness-work ADR 0039 §5), so a buddy can check a pair.reconnect that anyone holding the old key could otherwise forge.

Wire contract

  • Link epoch. account_inboxes.key_epoch is the epoch of the key that verified the link's proof. It's read together with that key, so a key change landing in between leaves the link at the old epoch. A later inbox/register whose proof the current key signed moves an existing link up to the current epoch (never down). Migration 0008_inbox-successor.sql backfills each existing link with the epoch its account had when it was linked: one past the last key retired before the link's created_at, else 0, and a link made within a second after a change counts as the old key's. That gives 0 for every account that never changed its key, and it never gives an old-key link the current epoch.
  • inbox/delete { inboxId, successor? }. Requests without successor behave as before. With it:
    • Bearer only. The legacy signed form gets 403 forbidden.
    • The inbox must be linked to the token's account (403 forbidden otherwise), and the account must not be suspended (403 suspended; deleting without a successor still works).
    • The token's session must still be live at the account's key epoch (401 unauthorized otherwise). A key change ends every other session, but their access tokens still verify for minutes.
    • successor must be another registered inbox (not deleted or wiped), linked to the same account at its current key epoch, with no successor of its own (no chains or cycles). Otherwise it's 409 conflict and nothing is deleted.
    • Once the inbox is gone its account link is dropped, and it's never registered again: inbox/register for it is 409 conflict, bearer or legacy, proof or not, while the record is kept. Repeats still work for the recording account.
    • The record is written in one insert-select gated on all of the above, then the inbox DO deletes the inbox. If the DO refuses or the call throws, the record is dropped again; a retry records it once more.
    • Existing record: the first one stays while it still qualifies. The same successor again refreshes it (idempotent ok, including after the inbox is gone). A different successor is 409. A record that no longer qualifies (made before a key change) is replaced.
    • buddies.deleted is logged only for the deletion itself.
    • Records are kept 90 days; the hourly cron prunes them.
  • POST /buddies/v1/inbox/successor { inboxId } → { ok: true, successor: string | null }.
    • Auth: bearer token from any account, plain JSON. No legacy form: without a token it's 401 unauthorized.
    • Counts against the read edge tier; the kill switch applies (503 disabled).
    • Checked at every read: null unless the recorded successor is still linked to the same account at the account's current key_epoch, and the account isn't suspended. A successor a K1 holder recorded before the change is never vouched for after it.
    • Anyone with a token who knows the old inbox id learns that it was replaced, and by which inbox.
    • Also null for an inbox nobody replaced and for records over 90 days old.

Why a thief can't pass

After the change, K1 opens no session (key_retired), and its sessions have ended, so a leftover token gets 401. K1's link proofs no longer verify. An inbox K1 linked earlier stays at epoch 0, so naming it is 409, and a record K1 made before the change reads as null until the User's own successor replaces it.

What it doesn't cover

The guarantee holds only after the key change. Buddies who accept a K1 holder's reconnect before it aren't recovered. The old inbox must be linked to the account before the change (the app links it first and skips invites if it can't): another account holding the old key could link an unlinked old inbox and name its own successor.

Tests

  • src/buddies/successor.test.ts (31):
    • linking and confirm epochs, and the migration backfill;
    • recorded for a same-account current-epoch link;
    • refused for: another account's inbox, an unknown inbox, an inbox linked before a key change, the inbox itself, no bearer, another account's token, a malformed successor;
    • idempotent repeat that logs the deletion once; first wins vs. replace-when-stale;
    • a replaced inbox can't be registered again (legacy without proof, legacy with proof, bearer) and its link is dropped; a deleted successor, a cycle, and a successor whose own deletion is in flight are refused;
    • the DO refusing (not_linked) or throwing drops the record (read null), and a retry records it; a suspended account gets 403 and its successor reads null until reinstated;
    • read: null cases, auth, kill switch, 90-day prune;
    • thief end to end; an epoch-0 record reads null after a rekey, then the User's own successor reads back.
  • Mutation-checked: removing the link-epoch check, the live-session check, the confirm step, the read-time epoch check, or the first-wins/replace clause each fails at least one test.
  • src/e2e/inboxSuccessor.e2e.test.ts (5) runs over HTTP against scripts/verify/dev.mjs (local worker on :8796, migration applied by wrangler to local D1).
  • pnpm test 1973 passed, pnpm typecheck clean, pnpm fuzz:buddies --runs 300 --seed 5007 ok after the black-box fixes; the black-box scripts (H, E) rerun against this branch show L1 and L2 refused.

Docs: OpenAPI, AGENTS.md, docs/buddies-protocol.md (new "Inbox successor" section), and the verify skill's tier list. No architecture diagram change (new endpoint in an existing route group, new table in the existing D1).

@leviFrosty
leviFrosty merged commit b7410b1 into web-launch Oct 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant