Skip to content

fix(collab): fail-closed E2e seal on BOTH write paths (CRITICAL #168 + #170) - #172

Merged
cuttlefisch merged 4 commits into
mainfrom
fix/e2e-seal-fail-closed
Jun 27, 2026
Merged

fix(collab): fail-closed E2e seal on BOTH write paths (CRITICAL #168 + #170)#172
cuttlefisch merged 4 commits into
mainfrom
fix/e2e-seal-fail-closed

Conversation

@cuttlefisch

Copy link
Copy Markdown
Owner

Closes #168 and #170. Two CRITICAL confidentiality bugs found in the impl-pass security review over merged 3b/3c — an E2e KB could ship plaintext to the key-blind daemon on both write paths. This makes both fail closed.

#168kb/node_update fail-open

build_kb_node_update_request decided seal-vs-plaintext on content_key.is_some() with no E2e knowledge → a missing key (e.g. post-restart, before a share/join response repopulated content_keys) or a seal failure shipped plaintext.

  • New e2e: bool param + Option return: None = refuse (never plaintext), for both the no-key and Err(_) arms.
  • New kb_collection_is_e2e() reads the signed derive_encryption (anchor-pinned, not the relay-flippable flag); the editor stamps it on CollabCommand::KbNodeUpdate.e2e at drain time.
  • Handler lazily reloads the persisted key (restart liveness), then refuses + requeues with a loud warn! if still absent — retried when the key arrives. Observable via tracing.

#170kb/share / reconnect-re-share fail-open

ShareKb sent node_states as raw plaintext; reconnect re-shares durably-shared KBs → an already-E2e KB leaked its full plaintext content every reconnect.

  • New pure select_share_node_states(kb_id, e2e, node_states, op_sets): E2e ships the already-sealed op-set we hold (idempotent) or skips the node (loud warn) — never the plaintext snapshot. Unencrypted path byte-identical.

Tests (principle #14 — attacker oracles)

  • build_kb_node_update_request_fails_closed_on_e2e_without_key — E2e+no-key ⇒ None (signed + unsigned); selective control: unencrypted ⇒ Some; E2e+key ⇒ seals.
  • select_share_node_states_never_ships_plaintext_on_e2e — plaintext canary in NO E2e wire payload; selective control: unencrypted ships plaintext.
  • kb_collection_is_e2e_reads_signed_mode_not_the_flippable_flag — downgrade can't fool the gate.
  • 117 collab_bridge tests green; clippy clean; full cargo test -p mae green.

Scope / follow-ups (filed, accurate picture)

🤖 Generated with Claude Code

cuttlefisch and others added 3 commits June 27, 2026 18:52
…es (CRITICAL #168)

build_kb_node_update_request decided seal-vs-plaintext purely on content_key.is_some()
with no E2e knowledge, so an E2e KB with a missing key (e.g. after restart, before a
share/join response repopulated content_keys) — or a seal failure — shipped PLAINTEXT
to the key-blind daemon, which stores + relays it. Silent confidentiality breach.

Fix (fail closed):
- build_kb_node_update_request takes `e2e: bool` and returns Option: None = REFUSE.
  Both the no-key arm and the Err(_) seal-failure arm return None when e2e — never the
  cleartext.
- New `kb_collection_is_e2e(collection_state)` reads the AUTHORITATIVE signed
  derive_encryption (F1 anchor pin), not the relay-flippable unsigned flag — a downgrade
  can't trick us into plaintext. The editor stamps this onto CollabCommand::KbNodeUpdate.e2e
  at drain time (the editor thread is the authority; it holds the collection replica).
- The handler lazily reloads the persisted content key from content_key_store on an E2e KB
  with no in-memory key (restart liveness), then refuses + REQUEUES with a loud warn! if
  still absent — the edit retries when the key arrives (owner: on reload; member: on
  approve). No silent plaintext, no lost edit; observable via tracing.

Tests (principle #14, the attacker oracle):
- build_kb_node_update_request_fails_closed_on_e2e_without_key — E2e + no key ⇒ None on
  BOTH the signed and unsigned paths; selective control: the same inputs on an UNENCRYPTED
  KB still ship (Some), proving the refusal is the e2e gate working, not a dead function;
  E2e + key ⇒ Some (seals).
- kb_collection_is_e2e_reads_signed_mode_not_the_flippable_flag — plain ⇒ false; signed
  enable ⇒ true; a relay forging the unsigned flag back to None does NOT downgrade.

Sibling leak in the share/re-share path (raw plaintext node snapshots) filed as #170.
117 collab_bridge tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ntext snapshots (CRITICAL #170)

The ShareKb handler sent node_states as RAW PLAINTEXT (update_to_base64(state)). The
editor re-shares durably-shared KBs on reconnect, so an already-E2e KB leaked its full
plaintext node content to the key-blind daemon every reconnect — worse than #168 (full
content, not a delta).

Fix (fail closed, principle #8 extraction):
- New pure `select_share_node_states(kb_id, e2e, node_states, op_sets)` decides the wire
  states. On an UNENCRYPTED KB it is byte-identical to before (base64 the plaintext). On an
  E2e KB it NEVER ships the plaintext snapshot: it sends the already-sealed op-set we hold
  for the node (idempotent — the daemon stores the same op-set kb/node_update produces) and
  SKIPS a node we have no op-set for (loud warn), rather than leak its plaintext.
- ShareKb computes `e2e = kb_collection_is_e2e(&collection_state)` (the authoritative signed
  mode, #168 helper) and routes through the new helper.

Test (principle #14): select_share_node_states_never_ships_plaintext_on_e2e — E2e ships the
sealed op-set for a held node, SKIPS a bare node, and a plaintext canary appears in NO wire
payload; selective control: the unencrypted path still ships every plaintext node.

Residual (documented separately): content shared BEFORE encryption was enabled stays
plaintext on the daemon — retroactive re-encryption-on-enable is a known limitation, not
this leak. This fix stops the re-share from RE-leaking post-enable content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…encryption-on-enable limitation (#171)

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
…r-op client_id (#167/#168)

Proves WHY #168's always-seal closes #167's deletion-fence gap for E2e KBs: a deletion
sealed into the op-set rides a client-id-stamped outer op, so update_new_op_authors
attributes it to the (stale) seal client_id and the ADR-023 fence rejects it — even though
the inner op is a pure, otherwise-unattributable yrs delete. Documents the residual: a
PLAINTEXT pure-delete remains unattributable (the unencrypted-path #167 gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cuttlefisch
cuttlefisch merged commit 692a1ef into main Jun 27, 2026
14 checks passed
@cuttlefisch
cuttlefisch deleted the fix/e2e-seal-fail-closed branch June 27, 2026 18:54
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.

CRITICAL: fail-OPEN seal — E2e KB ships PLAINTEXT to the key-blind daemon when content key absent / seal fails

1 participant