Expand description
End-to-end payload encryption boundary (slice B6 of
docs/proposals/multi-device-sync.md, §“Transport: Parslee-hosted relay,
E2E for personal scope”).
The proposal’s trust posture: the relay is a dumb, untrusted ordered-log
store. Personal-scope payloads are encrypted end-to-end, always; the
relay stores only ciphertext and “can route and dedup on op_id and hlc
(which stay cleartext) but cannot read conversations, memory, or
secrets.” This module is the encrypt/decrypt boundary that realizes it.
§The design that keeps the shipped oplog intact
An op’s op_id is the SHA-256 content address over device_id ‖ seq ‖ prev ‖ hlc ‖ scope ‖ surface ‖ canonical(payload) (see crate::oplog). To
keep op_id/seq/prev/hlc/scope/surface cleartext metadata —
exactly what B3’s relay chain-verification and dedup rely on — the
encryption is applied to the payload only, at authoring time: a device
that wants E2E authors its op with cipher.encrypt(plaintext) as the
payload, so the canonical op the whole system carries is ciphertext-native.
The chain hashes over ciphertext, crate::oplog::verify_log verifies it,
and the relay sees only the Envelope. A peer holding the same key
recovers the plaintext with PayloadCipher::decrypt. No change to the
OpRecord shape, the journal, the relay, or the fold — the ciphertext is
just a serde_json::Value like any other payload.
§Real crypto, not a placeholder
LocalKeyCipher is a genuine AEAD: ChaCha20-Poly1305 with a random
96-bit nonce per op (RustCrypto chacha20poly1305). Confidentiality AND
integrity — a tampered ciphertext fails the Poly1305 tag and
PayloadCipher::decrypt returns CryptoError::Decrypt, never silently
wrong plaintext. The key is a user-held 256-bit secret
(LocalKeyCipher::load_or_generate persists it 0600 under
~/.car/sync/), never transmitted — the proposal’s “the key is user-held,
derived at Parslee login, never transmitted.”
§Honest boundary — what lands here, what is a documented follow-up
This slice ships the boundary primitive + a local-key reference, tested (round-trip, relay-sees-ciphertext, tamper-rejected, wrong-key-rejected). What remains, called out so no one mistakes this for a finished E2E story:
- Decrypt-before-fold wiring. The per-surface fold rules group on
payload["id"]/fold_key(fact_id dedup, registry LWW-per-record), which are hidden under ciphertext. A live E2E device must therefore decrypt each op’s payload after the ciphertext chain verifies and before the fold groups it (op identity stays the cleartext-metadataop_id; only the payload is swapped). That decrypt-then-fold step incrate::session::SyncSessionis the remaining engine wiring. - Key distribution.
LocalKeyCipheris a single local key — the single-user multi-device case. Deriving it from the Parslee login secret, and the org-key distribution via the entitlements layer, is the key-management follow-up (proposal §“Open questions / Key recovery, Org-key rotation”). - Scopes are encryption audiences (B4 pin, binding on B6). A whole-chain
checkpoint that mixes
Personal+Shared{org}payloads mixes different key audiences; it must be split per scope key or encrypted to the personal key only with org-shared state re-derived from the org op-stream — it must NOT ship a single-key whole-chain ciphertext to an org audience.encryption_audiencesurfaces a scope’s audience tag so a caller can enforce single-audience-per-ciphertext; the per-scope checkpoint split is the follow-up (it lands with the per-scope relay streams B4 also deferred).
Structs§
- Envelope
- The ciphertext form of a payload — what the relay stores and sees. Cleartext
op_id/seq/hlc/scope/surfacemetadata lives outside this, on thecrate::oplog::OpRecord; the envelope hides only the payload body. - Local
KeyCipher - The single-user reference cipher: a local 256-bit ChaCha20-Poly1305 key.
Enums§
- Crypto
Error - A crypto-boundary failure.
Constants§
- ALG_
CHACH A20POL Y1305 - The frozen algorithm tag written into every
Envelope— lets a future cipher upgrade coexist (a decryptor rejects an unknown tag rather than mis-decoding).
Traits§
- Payload
Cipher - The encrypt/decrypt boundary. A device authors an E2E op with
cipher.encrypt(plaintext)as its payload; a peer holding the key recovers it withcipher.decrypt(&op.payload). Object-safe so a daemon can hold anArc<dyn PayloadCipher>(a null/local reference now, a login-derived key later) without a type change.
Functions§
- encryption_
audience - The encryption audience a scope maps to — the set of principals whose
key a payload under this scope is encrypted to.
Personal→ the user’s own key;Shared{org}→ the org key. The B4-pinned rule (“scopes are encryption audiences”) uses this: a single ciphertext must have a single audience, so a whole-chain checkpoint mixing scopes cannot be one ciphertext.