Skip to content

feat: E2E enable flow — owner key lifecycle, daemon key-blind (ADR-037/038/039, #151 Phase 3b PR A) - #160

Merged
cuttlefisch merged 7 commits into
mainfrom
feat/e2e-key-lifecycle
Jun 27, 2026
Merged

feat: E2E enable flow — owner key lifecycle, daemon key-blind (ADR-037/038/039, #151 Phase 3b PR A)#160
cuttlefisch merged 7 commits into
mainfrom
feat/e2e-key-lifecycle

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

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.
  • F6 — reject non-contributory (low-order) DH in wrap_to_member/unwrap_as_member.
  • F2MembershipAction::SetEncryption + monotonic derive_encryption (E2e latches permanently; no downgrade by forged/later-none op).
  • KbCollectionDoc::author_e2e_genesis — authors the genesis self-admit (self-wrapped key) + the signed SetEncryption op in one combined delta.

Editor enable surface (human + AI parity)

  • (kb-set-encryption KB "e2e") Scheme primitive + kb_set_encryption MCP tool → KbCollabAction::SetEncryptionCollabIntentCollabCommand::KbSetEncryption{kb_id, mode, collection_state}.
  • Network-task handler (holds the secret; daemon key-blind): owner-only; F4 refuse on non-SingleOwner governance; generate-or-load + persist the content key; self-wrap; author_e2e_genesis; ship via the key-blind kb/collection_op; register content_keys so the owner's edits seal.
  • F1/A3 read-pathderive_kb_content_key cross-checks the genesis anchor against the authenticated owner (COLL_OWNER_KEY) and gates on the signed derive_encryption, not the unsigned flag (fail-closed).

Tests (adversarial, per Principle #14)

Low-order ephemeral rejected; signed-mode latch + downgrade/forgery resistance; author_e2e_genesis self-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

cuttlefisch and others added 7 commits June 27, 2026 14:32
… (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>
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