fix(collab): purge the pre-enable plaintext base on E2E enable — reseal-as-replace (#171) - #185
Merged
Merged
Conversation
…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>
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.
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 viaapply_update(merge), and the plaintext stayed at rest.Fix — reseal-as-replace
reseal:true.kb/node_update: onreseal, owner-gate (Manage, not Edit), skip the epoch fence (a full-doc replace, not an SV merge), andshare_doc-REPLACE the node doc — atomically deleting the plaintext snapshot+WAL and recreating it from the ciphertext-only op.PRAGMA secure_delete=ON+ a TRUNCATE checkpoint ondelete_documentso freed pages / the WAL sidecar are scrubbed, not just unlinked (SQLite leaves deleted content legible in free pages otherwise).resealis 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)
PRECANARYmust 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.delete_document_scrubs_plaintext_from_the_db_file_at_restreads the raw DB file (+-wal/-shmsidecars) 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
cargo clippy -p mae -- -D warningsclean. UpdatesE2E_ENCRYPTION.mditem 8.🤖 Generated with Claude Code
Closes #171