feat: E2E enable flow — owner key lifecycle, daemon key-blind (ADR-037/038/039, #151 Phase 3b PR A) - #160
Merged
Merged
Conversation
… (ADR-037 #151, Phase 3b) The owner of an E2E KB generates a per-KB symmetric content key on enabling encryption and must hold it durably to wrap it to future members and decrypt its own content after a restart. This is the persistence layer for that key. - New `mae_mcp::content_key_store`: `save`/`load`/`remove` a 32-byte content key at `$XDG_DATA_HOME/mae/collab/content_keys/{hex(kb_id)}.key`, 0600 in a 0700 dir, XDG-first (cross-OS) — same posture as the PSK keystore, reusing `keystore::write_secure` + `identity::default_collab_dir`. kb_id is hex-encoded into the filename so an arbitrary id can never traverse out of the dir or collide. - Lives in mae-mcp (it owns secret storage + XDG helpers; mae-sync is pure CRDT, no I/O). Operates on raw `[u8;32]` so it needs no mae-sync dep; the editor converts `ContentKey`<->bytes. The DAEMON never calls it — it stays key-blind. - Adds `hex` to mae-mcp (auth.rs has only a private local `mod hex`). Tests (adversarial): two DISTINCT keys for two KBs round-trip and are not crossed; an absent KB => None; a `../../etc/evil` id stays inside the dir with a traversal-free filename; a wrong-length file => None (no panic/truncation); the file is 0600. Per-test dirs (parallel-safe — no shared/address-derived paths). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p (ADR-039 F6, #131) Security-review finding F6: `wrap_to_member`/`unwrap_as_member` did not reject an all-zero / low-order X25519 shared secret. On unwrap the blob's `ephemeral_pub` is fully attacker-controlled, so a low-order point would force a known shared secret (hence a known wrap key) — letting a malicious relay seal an attacker-known content key to a victim (caught downstream only because wraps ride a signature-verified membership op). Reject a non-contributory DH (`x25519_dalek::SharedSecret::was_contributory`) on both sides. Adversarial test: a blob with the canonical all-zero (low-order) ephemeral point is rejected with Malformed before the wrap key is derived; a legitimate wrap to the same member still round-trips (the check is selective). 8/8 content_crypto tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+ derive_encryption (ADR-039 F2, #131) Security-review finding F2: the per-KB encryption mode was an UNSIGNED collection-map flag, so a malicious relay could flip e2e→none and coax a victim into emitting plaintext (downgrade-to-plaintext attack). Move the authoritative mode into the SIGNED membership op-log (mirroring SetGovernance): - New `MembershipAction::SetEncryption` (owner-authored; `subject` carries the mode); owner-only in `authorized()`; inert to membership derivation. - `derive_encryption(ops, anchor)` — owner-rooted and **monotonic**: any owner-authored SetEncryption("e2e") latches E2e PERMANENTLY, so neither a forged op nor a later owner-authored "none" can downgrade. The seal path reads this and stays fail-closed. Adversarial test: absent ⇒ None; owner e2e ⇒ E2e; a later owner "none" does NOT downgrade (one-way); a non-owner's e2e is ignored; an op whose signature ≠ its claimed author key is ignored. The editor will read the mode from this (not the unsigned flag) — next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ta (ADR-037/038, #131) The owner-editor's enable authoring (Phase 3b PR A): in ONE combined collection delta (state-vector diff) it authors the genesis owner self-admit (the trust anchor, carrying the owner's self-wrapped content key), the signed SetEncryption(e2e) op (the monotonic anti-downgrade mode source, ADR-039 F2), and the backward-compat unsigned flag — skipping the genesis if one already exists (idempotent). Returns the delta to ship via the key-blind kb/collection_op RPC (ADR-038); the daemon never sees the key. Adversarial test: after enable, the SIGNED log latches e2e and the owner recovers its self-wrapped key (derive_content_key); a peer applying the relayed delta derives the same e2e state + key; a non-member identity recovers nothing; re-enable drops no ops and stays e2e. Reuses build_membership_op/append_signed_op/state_vector/doc() (no daemon change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…/self-wrap (ADR-037/038/039, #151) Phase 3b PR A — the live enable flow that turns on E2E for an editor-owned KB, human + AI parity, with the security-review fixes baked in. The chain (mirrors kb-set-policy): Scheme (kb-set-encryption KB "e2e") + MCP tool kb_set_encryption -> KbCollabAction::SetEncryption -> CollabIntent::KbSetEncryption -> CollabCommand::KbSetEncryption{kb_id, mode, collection_state} (the intent mapping carries the main thread's cached collection replica so the network task can author). Network-task handler (holds the identity secret; daemon stays key-blind): owner-only (coll.owner() == fingerprint); F4 — refuse on non-SingleOwner governance (a quorum-removable owner would freeze the key); generate-or-load + persist the content key (content_key_store); self-wrap; KbCollectionDoc::author_e2e_genesis (signed genesis + SetEncryption op in one delta); ship via the key-blind kb/collection_op; register content_keys[kb_id] so the owner's subsequent edits seal (Phase 2b). KbCollectionOp command + send/disconnect handling ready (producer = PR B wrap-on-admit). Builds on the tested foundations (F6 low-order DH, F2 signed mode, author_e2e_genesis). Full editor compiles clean; clippy + fmt clean. Read-path (derive_encryption + F1/A3 anchor cross-check) + the end-to-end test land next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… in derive_kb_content_key (ADR-039, #131) The security-critical read/seal enforcement for Phase 3b PR A: - **F1/A3 (anchor pinning):** the genesis anchor MUST be the AUTHENTICATED owner (`COLL_OWNER_KEY`, which the daemon binds to the verified mTLS principal) — refuse a forged genesis a relay substituted, instead of TOFU-trusting whatever genesis the collection carries. (Mesh node-id pinning remains the #158 follow-up.) - **F2 (signed mode, fail-closed):** gate on the SIGNED, monotonic `derive_encryption` (op-log), not the unsigned collection flag a relay could flip to `none`. The seal path stays gated on the resulting `content_keys` entry, so it never reverts to plaintext on a flag downgrade. Adversarial test: a KB whose E2e is asserted only by the unsigned flag (no signed SetEncryption op) derives nothing; a genesis NOT authored by the attested owner is refused even when the key is wrapped to the victim. The Phase 2b `build_e2e_collection` helper now authors a signed SetEncryption op (real-enable shape). 115 collab_bridge tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jun 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 3b PR A — E2E enable flow (owner key lifecycle, daemon key-blind)
Closes the owner-side half of E2E KB sharing (#131/#151): an owner can enable encryption on an editor-owned KB, its content seals, and the daemon stays key-blind — with all four security-review fixes (F1/F2/F4/F6) baked in. (Member key delivery = PR B; the #157 unified fence + 3c/3d follow.)
Crypto/protocol foundations (shared/sync + mcp)
content_key_store— durable per-KB owner key, 0600/XDG, traversal-safe.wrap_to_member/unwrap_as_member.MembershipAction::SetEncryption+ monotonicderive_encryption(E2e latches permanently; no downgrade by forged/later-noneop).KbCollectionDoc::author_e2e_genesis— authors the genesis self-admit (self-wrapped key) + the signedSetEncryptionop in one combined delta.Editor enable surface (human + AI parity)
(kb-set-encryption KB "e2e")Scheme primitive +kb_set_encryptionMCP tool →KbCollabAction::SetEncryption→CollabIntent→CollabCommand::KbSetEncryption{kb_id, mode, collection_state}.author_e2e_genesis; ship via the key-blindkb/collection_op; registercontent_keysso the owner's edits seal.derive_kb_content_keycross-checks the genesis anchor against the authenticated owner (COLL_OWNER_KEY) and gates on the signedderive_encryption, not the unsigned flag (fail-closed).Tests (adversarial, per Principle #14)
Low-order ephemeral rejected; signed-mode latch + downgrade/forgery resistance;
author_e2e_genesisself-wrap + peer-relay + non-member exclusion + idempotent re-enable; F1 (substituted genesis refused even with a wrap to the victim) + F2 (unsigned flag insufficient). 305 mae + full mae-sync green; clippy + fmt clean; unencrypted path unchanged; daemon never holds a key.ADRs: 037 (encryption), 038 (editor-authored membership), 039 (identity+authz hardening). Reviews:
docs/E2E_ENCRYPTION.md,docs/SECURITY_REVIEW.md.🤖 Generated with Claude Code