Skip to content

fix(collab): purge the pre-enable plaintext base on E2E enable — reseal-as-replace (#171) - #185

Merged
cuttlefisch merged 2 commits into
mainfrom
fix/171-purge-pre-enable-plaintext
Jun 28, 2026
Merged

fix(collab): purge the pre-enable plaintext base on E2E enable — reseal-as-replace (#171)#185
cuttlefisch merged 2 commits into
mainfrom
fix/171-purge-pre-enable-plaintext

Conversation

@cuttlefisch

@cuttlefisch cuttlefisch commented Jun 28, 2026

Copy link
Copy Markdown
Owner

What

Closes the residual #171 confidentiality gap (the last open item on the E2E milestone, #152). Enabling E2E on a previously-plaintext-shared KB re-sealed every node but left the pre-enable plaintext kb:{node} snapshot+WAL on the key-blind daemon at rest — recoverable by anyone with disk access.

Root cause

The plaintext share and the #171 re-seal shared one kb:{node} yrs doc under the owner's content client_id, so the seal had to graft op 0 onto the plaintext clocks (a fresh op-set at clock 0 would overlap and be dropped). The op-set therefore stacked on top of the readable plaintext via apply_update (merge), and the plaintext stayed at rest.

Fix — reseal-as-replace

  • Editor: the enable re-seal now builds a FRESH op-set (empty seed → op 0 at clock 0, self-contained) and ships it reseal:true.
  • Daemon kb/node_update: on reseal, owner-gate (Manage, not Edit), skip the epoch fence (a full-doc replace, not an SV merge), and share_doc-REPLACE the node doc — atomically deleting the plaintext snapshot+WAL and recreating it from the ciphertext-only op.
  • Storage: PRAGMA secure_delete=ON + a TRUNCATE checkpoint on delete_document so freed pages / the WAL sidecar are scrubbed, not just unlinked (SQLite leaves deleted content legible in free pages otherwise).

reseal is additive on the wire (an old daemon ignores it → harmless merge). A joiner after enable reads only ciphertext; the §D3 reseal/join path is unchanged.

Adversarial coverage (principle #14)

  • e2e: a pre-enable PLAINTEXT PRECANARY must be ABSENT from the daemon store after enable (default encrypted gate). Teeth-checked: forcing the daemon back to merge makes it LEAK (FAIL(#171)); the fix purges it.
  • unit: delete_document_scrubs_plaintext_from_the_db_file_at_rest reads the raw DB file (+ -wal/-shm sidecars) and asserts the canary is gone after delete, with a non-vacuous precondition that it was on disk first.

Residual limitation (documented)

A member who legitimately pulled the plaintext before enable keeps their own local copy (like §D3 history). Enable protects the relay + future joiners, not peers who already read in the clear — for that, enable E2E before sharing.

Verification

🤖 Generated with Claude Code

Closes #171

…al-as-replace (#171)

Closes the residual #171 confidentiality gap: enabling E2E on a
previously-plaintext-shared KB re-sealed every node but left the pre-enable
plaintext `kb:{node}` snapshot+WAL sitting on the key-blind daemon at rest.

Root of the residual: the plaintext share and the #171 re-seal shared ONE
`kb:{node}` yrs doc under the owner's content client_id, so the seal had to graft
op 0 onto the plaintext clocks (a fresh op-set at clock 0 would overlap and get
dropped) — the op-set stacked ON TOP of the readable plaintext via `apply_update`
(merge), and the plaintext stayed recoverable.

Fix — reseal-as-replace:
- Editor: the enable re-seal now builds a FRESH op-set (empty seed → op 0 at
  clock 0, self-contained) and ships it `reseal:true`.
- Daemon `kb/node_update`: on `reseal`, owner-gate (Manage, not Edit), SKIP the
  epoch fence (a full-doc replace, not an SV merge), and `share_doc`-REPLACE the
  node doc — atomically deleting the plaintext snapshot+WAL and recreating it from
  the ciphertext-only op (vs the merge that left plaintext behind).
- Storage: `PRAGMA secure_delete=ON` + a TRUNCATE checkpoint on `delete_document`
  so the freed pages / WAL sidecar are SCRUBBED, not just unlinked (SQLite leaves
  deleted content legible in free pages otherwise).

The `reseal` param is additive on the wire (an old daemon ignores it → harmless
merge). A joiner after enable reads only ciphertext; the §D3 reseal/join path is
unchanged (still green).

Adversarial coverage (principle #14):
- e2e: a pre-enable PLAINTEXT `PRECANARY` must be ABSENT from the daemon store
  after enable. Teeth-checked — forcing the daemon back to merge makes it LEAK
  (FAIL(#171)); the fix PURGES it. Runs in the default encrypted gate.
- unit: `delete_document_scrubs_plaintext_from_the_db_file_at_rest` reads the raw
  DB file (+ -wal/-shm sidecars) and asserts the canary is gone after delete, with
  a non-vacuous precondition that it was on disk first.

Verified: encrypted e2e default + §D3 removal + negative control all rc=0; daemon
clippy + 117 storage / 51 collab_handler tests green; `clippy -p mae -- -D
warnings` clean. Updates E2E_ENCRYPTION.md item 8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cuttlefisch
cuttlefisch merged commit 7558487 into main Jun 28, 2026
14 checks passed
@cuttlefisch
cuttlefisch deleted the fix/171-purge-pre-enable-plaintext branch June 28, 2026 21:43
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.

E2e: enabling encryption does not re-seal already-shared content (re-encryption-on-enable)

1 participant