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.”
§What’s wired, what remains
Shipped + tested here and in the session:
- Decrypt-before-fold —
crate::session::SyncSession::with_key_providerencrypts each payload atappend(ciphertext-native,op_idover ciphertext) and decrypts atstate()after the chain verifies and before the fold groups onpayload["id"]/fold_key. Op identity stays the cleartext-metadataop_id. - Login-derived key distribution —
DerivedKeyProviderHKDF-derives per-audience keys from one master;DerivedKeyProvider::from_passphraseis the zero-knowledge cross-device source (same passphrase → same keys on every device, never transmitted).LocalKeyCipherremains the raw single-key reference. Per-audience isolation viaencryption_audience. - Checkpoints under E2E —
crate::session::SyncSession::publish_checkpointis guarded off under a key provider: a ciphertext-folded checkpoint would form an inconsistent decrypt base, and a cleartext one would leak. The encrypted op log is retained and cold bootstrap replays it.
Remaining: per-scope encrypted checkpoint push — a whole-chain checkpoint
mixes Personal + Shared{org} audiences, so it must be split per scope key
(or encrypted to the personal key with org state re-derived from the org
stream) before it can be pushed to an untrusted relay to restore relay-side
GC; and org-key distribution via the entitlements layer.
Structs§
- Derived
KeyProvider - Derives per-audience
LocalKeyCiphers from one login-derived master secret (HKDF-SHA256), caching by audience. The master comes from the Parslee login (per-user) / entitlements (per-org); it is NEVER sent to the relay. - 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. - Sync
KeyProvider - Supplies the
PayloadCipherfor a scope’s encryption audience. The daemon holds one and asks for a cipher per op-scope, so a remote relay only ever sees ciphertext under the right (personal / org) key.
Functions§
- derive_
key - Derive a 256-bit AEAD key for
audiencefrom a login/entitlementmastersecret via HKDF-SHA256. Deterministic: the same(master, audience)yields the same key on every device (a user’s Mac and phone decrypt each other’s ops); distinct audiences (“personal” vs “org:”) yield independent keys. - 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.