Skip to content

feat: content-key rotation on member removal (ADR-037 §D3 Phase 3c, #152) - #164

Merged
cuttlefisch merged 3 commits into
mainfrom
feat/e2e-removal-rotation
Jun 27, 2026
Merged

feat: content-key rotation on member removal (ADR-037 §D3 Phase 3c, #152)#164
cuttlefisch merged 3 commits into
mainfrom
feat/e2e-removal-rotation

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

Closes #152. Part of #131 (ADR-037 E2E). Builds on 3b (#160/#161).

What

Removing a member from an E2e KB now rotates the content key (§D3 eager re-wrap) instead of a plain
daemon-authored remove. The owner authors ONE combined, signed collection delta — a Remove of the departed
member + a fresh-key re-wrap to every remaining member — and ships it key-blind via kb/collection_op.
The removed member gets no new wrap, so it keeps only the old key and cannot read post-rotation content.

Commits

  • a9c30e30 (sync)KbCollectionDoc::author_rotate_on_remove: the authoring core. Re-key ops re-assert
    each member's current derived role/can_invite/epoch verbatim (a re-admit overwrites the entry — "later
    re-admit wins" — so preserving them avoids a silent downgrade) and do not bump the epoch (no forced
    rebase; the removed member is dropped from membership anyway).
  • bb68f088 (collab) — editor wiring: the remove path derives members, generates k', wraps once per
    remaining member (owner via its own key; others via the pubkey stored on admit), authors the delta, ships
    it key-blind, persists k'. Adds + non-E2e removes fall through unchanged. Members with no stored pubkey
    are skipped with a loud warn (no silent gap).

Tests (principle #14 — adversarial, the §D3 SELECTIVE oracle)

rotate_on_remove_rekeys_remaining_members_and_strands_the_removed_one: 3 members share k; remove B with a
fresh k'. Asserts —

  • the two remaining members converge on k';
  • the removed B keeps only the old k (not k', not nothing) — proving k' is denied specifically,
    not that B's pipeline was severed;
  • B is dropped from derived membership while owner + C retain unchanged role/can_invite/epoch (the
    clobber guard) and C's epoch is not bumped;
  • a pre-rotation replica applying the relayed delta alone agrees on every point (convergence);
  • the Remove op carries a verifiable owner signature.

The removed member's write-denial follows by composition (they become a non-member → verify_content_op
rejects, existing behavior). The full wired daemon e2e (two editors + daemon, docker) lands in 3d (#153).

Scope / safety

  • Daemon stays key-blind (relays the owner-signed delta; re-derives membership itself).
  • Unencrypted + legacy-remove paths byte-identical. make ci-all should stay green.
  • release:none — no version bump (dev velocity; the deliverable bump comes when 3c/3d close the story).

🤖 Generated with Claude Code

cuttlefisch and others added 2 commits June 27, 2026 17:30
…removal (ADR-037 §D3, #152)

The kb.rs authoring core for Phase 3c. On removing a member from an E2e KB, the
owner authors ONE combined collection delta: a signed `Remove` of the departed
member (+ member_roles mirror with #72 epoch tombstoning), then one owner-authored
wrap-only `Admit` per REMAINING member carrying a freshly-wrapped new key.

Design:
- Re-key ops re-assert each member's CURRENT derived role/can_invite/epoch verbatim
  (a re-admit overwrites the derived entry — "later re-admit wins", membership.rs:855
  — so preserving them avoids a silent downgrade). Attributes come from the
  authoritative op-log derivation, not the legacy mirror.
- Epoch is NOT bumped: re-keying must not force the remaining members to rebase. The
  removed member is dropped from derived membership, so their stale lineage is refused
  regardless of epoch (the fence, #157).
- The removed member receives no new wrapped op → find_wrapped_content_key returns
  only their OLD key. They decrypt pre-rotation content but no post-rotation ciphertext.
- Daemon stays key-blind: it relays the owner-signed delta via kb/collection_op.

Adversarial test (the §D3 SELECTIVE oracle): 3 members share k; remove B with fresh
k'. Asserts the two remaining converge on k', the removed B keeps ONLY the old k (not
k', not nothing — proving k' is denied specifically, not B's pipeline severed), B is
dropped from membership while owner+C retain unchanged role/can_invite/epoch (the
clobber guard), a pre-rotation replica applying the relayed delta agrees on all points,
and the Remove op carries a verifiable owner signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ADR-037 §D3, #152)

Wires the editor side of Phase 3c onto author_rotate_on_remove. Removing a member
from an E2e KB (one with a registered content key) now ROTATES the key instead of
issuing a plain daemon-authored kb/remove_member:

- The KbRemoveMember intent carries the main thread's cached collection replica
  (mirrors KbApprove / KbSetEncryption), so the network task — which holds the
  identity secret + the content key — can author the rotation.
- The network task: derives the current members from the op-log, generates a fresh
  k', wraps it once per REMAINING member (the owner re-keys itself via its own
  pubkey; others via the pubkey stored on admit, ADR-038), authors the combined
  signed Remove + per-member re-key delta, ships it KEY-BLIND via kb/collection_op,
  then persists k' and registers it so the owner's next edits seal under it.
- Members with no stored pubkey are skipped with a loud warn (they keep the OLD key
  until a re-share re-wraps them) — no silent coverage gap (principle #14).
- Adds and non-E2e removes fall through to the legacy kb/add_member / kb/remove_member
  path unchanged. The daemon stays key-blind and re-derives membership (the signed
  Remove drops the member) from the relayed delta.

Data-layer convergence + the §D3 security oracle (removed member stranded on the old
key; remaining members converge on k') are covered by the author_rotate_on_remove
adversarial test (a9c30e3); the full wired daemon e2e lands in 3d (#153, docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cuttlefisch cuttlefisch added area:collab Collaborative editing / sync / CRDT release:none Skip version bump on merge security Security / auth / access control labels Jun 27, 2026
@cuttlefisch
cuttlefisch merged commit 79a29f5 into main Jun 27, 2026
14 checks passed
@cuttlefisch
cuttlefisch deleted the feat/e2e-removal-rotation branch June 27, 2026 16:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:collab Collaborative editing / sync / CRDT release:none Skip version bump on merge security Security / auth / access control

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ADR-037 Phase 3c: removal rotation (§D3 eager re-wrap to remaining members)

1 participant