Skip to content

feat: E2E member key delivery — wrap-on-approve (ADR-037/038, #151 Phase 3b PR B) - #161

Merged
cuttlefisch merged 5 commits into
mainfrom
feat/e2e-member-wrap
Jun 27, 2026
Merged

feat: E2E member key delivery — wrap-on-approve (ADR-037/038, #151 Phase 3b PR B)#161
cuttlefisch merged 5 commits into
mainfrom
feat/e2e-member-wrap

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

Phase 3b PR B — member key delivery (wrap-on-approve)

Completes the multi-user half of E2E KB sharing (#131/#151): a joining member now receives the per-KB content key, so the full enable → join → approve → decrypt flow works end to end, daemon key-blind throughout. Stacks on PR A (#160, merged).

The flow

  1. A member runs kb-join; the daemon records their Ed25519 pubkey in the pending request (from the authenticated session) — it rides the kbc: broadcast the owner mirrors.
  2. The owner runs kb-approve; the network task wraps the content key to that member's pubkey, authors a signed Admit carrying the wrapped_key (via KbCollectionDoc::author_member_admit), and ships it via the key-blind kb/collection_op.
  3. The member derives the key from the relayed signed op-log and decrypts.

Changes

  • PendingRequest.pubkey + PENDING_PUBKEY_KEY; daemon kb/join captures session.peer_identity.pubkey (threaded through handle_doc_request_inner as auth_pubkey). Backward-compatible (absent ⇒ None).
  • KbCollectionDoc::author_member_admit — signed Admit + key wrapped to the member + member_roles dual-write (role + epoch + the member's pubkey, stored for 3c re-wrap), in one combined delta. The op's epoch == the member_roles epoch (keeps the ADR-023 fence consistent). member_pubkey() reader.
  • CollabCommand::KbApprove carries collection_state; the send arm routes an E2e approve through the above (legacy kb/approve_member fallback for unencrypted KBs / a pubkey-less pending record).

Tests (adversarial, Principle #14)

A pending record round-trips the joiner's pubkey (absent ⇒ None); after enable + admit, the member recovers the same content key on both the owner's and a peer's replica, their pubkey is stored, the signed Admit's epoch equals the member_roles epoch, and a non-member recovers nothing. 242 mae-sync tests green; editor + daemon compile + clippy clean.

Follow-up

Direct add-by-bare-fingerprint (kb-add-member with no prior join) has no pubkey to wrap; for an E2e KB that member is keyless until a re-wrap. The deferred-wrap (keyless Admit + a later wrap-only op, latest-wins) is the noted next step; the default invite→approve policy is fully supported here. The end-to-end approve test lands with 3d's docker e2e.

🤖 Generated with Claude Code

cuttlefisch and others added 5 commits June 27, 2026 15:50
…on-admit (ADR-038, #151 PR B)

To wrap the content key to a member on approval the owner needs their Ed25519 PUBLIC key,
but it holds only the fingerprint (a one-way hash). The daemon — which has the joiner's
pubkey from the authenticated session — now records it in the pending request, which rides
the kbc: broadcast the owner already mirrors.

- `PendingRequest.pubkey: Option<[u8;32]>` + `PENDING_PUBKEY_KEY`; `add_pending` takes +
  writes it (hex), `pending()` reads it back. Backward-compatible: absent ⇒ None (a v1
  pending record is unaffected).
- Daemon `kb/join`: capture `session.peer_identity.pubkey` (threaded through
  handle_doc_request_inner as `auth_pubkey`) and pass it to `add_pending`.

Test: a pending record round-trips the joiner's pubkey; a pubkey-less record reads back
None. Both workspaces compile + clippy clean; the non-daemon callers pass None.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…bkey storage (ADR-037/038, #151 PR B)

The owner-side authoring for delivering the content key to a member: author a signed
Admit op carrying the key wrapped to the member, mirror them into member_roles (role +
epoch + their pubkey), all in one combined collection delta. The op's epoch == the
member_roles epoch (the dual-write keeps the ADR-023 fence consistent for the hub flow).
The member's pubkey is persisted (MEMBER_PUBKEY_KEY) so 3c can re-wrap on rotation;
`member_pubkey()` reads it back.

Adversarial test: after enable + admit, the member recovers the SAME content key
(derive_content_key) on both the owner's and a peer's replica; the member's pubkey is
stored; the signed Admit's epoch equals the member_roles epoch; a non-member recovers
nothing. The editor-side KbApprove rewiring (route E2e approves through this + the pending
pubkey) lands next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…key on E2e KB (ADR-037/038, #151 PR B)

Routes `kb-approve` through the owner-authoring path for an E2e KB: the network task wraps
the content key to the approved member (whose pubkey rode the pending request — ADR-038),
authors a signed Admit via `KbCollectionDoc::author_member_admit` (dual-write op-log +
member_roles + pubkey), and ships it via the key-blind `kb/collection_op`. The member then
recovers the key from the relayed op-log. `CollabCommand::KbApprove` now carries the main
thread's `collection_state` (the intent mapping attaches it). Unencrypted KBs (or, defensively,
an E2e KB whose pending request lacks a pubkey) fall back to the legacy daemon `kb/approve_member`.

Builds on the tested foundations (author_member_admit, PendingRequest.pubkey). Editor
compiles + clippy/fmt clean. Deferred-wrap for bare-fingerprint add (KbMember) + the
end-to-end approve test land next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inner test helpers

The PendingRequest.pubkey commit added an `auth_pubkey` param to
`handle_doc_request_inner`; the two daemon test dispatch helpers still passed 9 args, so
the daemon lib-test crate didn't compile (the "daemon / check + test" + "Server-Client
Integration" CI failures). Pass None (these helpers don't exercise key-wrapping). 215
daemon tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cuttlefisch cuttlefisch added the release:none Skip version bump on merge label Jun 27, 2026
@cuttlefisch
cuttlefisch merged commit fedc62c into main Jun 27, 2026
14 checks passed
@cuttlefisch
cuttlefisch deleted the feat/e2e-member-wrap branch June 27, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:none Skip version bump on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant