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. -
Client-side org-key agreement (authenticated) —
wrap_org_key/unwrap_org_key(ECIES over X25519,derive_x25519_identity) share ONE org master keyK_orgacross all members so org-scoped ops are mutually readable, while the relay/platform never seesK_org. Each wrap is SIGNED by the publisher’s Ed25519 identity (derive_ed25519_identity) and unwrap REFUSES any wrap not signed by a caller-trusted holder — closing the key-substitution hole. The directory (crate::org_key_directory), transport, and the consumingcrate::org_key_provider::OrgAwareKeyProviderare built on top. It all stays inert; thelogin_secret → identityentropy dependency MUST be reviewed by a cryptographer before production (see the security notes above the identity functions).
Remaining before activation: per-scope encrypted checkpoint push — a
whole-chain checkpoint mixes Personal + Shared{org} audiences, so it must
be split per scope key before it can be pushed to an untrusted relay to
restore relay-side GC; the oplog epoch field + fold key-selection (so old
epochs decrypt after rotation); the out-of-band org → K_org resolver
that wires OrgAwareKeyProvider into the subsystem; and the cryptographer
audit itself.
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.
- Multi
Epoch OrgCipher - A multi-epoch org cipher: holds the per-epoch derived audience keys and, on
decrypt, selects EXACTLY ONE by the envelope’s
kid— it NEVER iterates the keyring (no trial-decrypt / wrong-key-acceptance). Encrypt always uses the NEWEST epoch held and stampskid. This is what makes rotation expressible: a remaining member holding {N, N+1} decrypts old ops under N and new ops under N+1, deterministically. Fail-closed: an unknownkid, a missingkid, or an empty keyring all error (the op stays opaque) — never a wrong key. - Stretched
Master - A high-entropy 32-byte master for ALL of a user’s key derivations — the
per-audience AEAD keys AND the X25519/Ed25519 identities derive from THIS (via
HKDF), never from a raw passphrase. The type is the gate: an identity or
audience key cannot be derived from an unstretched password, because those
functions take
&StretchedMaster, and the only ways to build one are the two constructors below. - Wrapped
OrgKey - A shared org master key wrapped for one member — AUTHENTICATED ECIES over
X25519. Sealed to the member’s X25519 key (only their secret unwraps it) AND
signed by the publisher’s Ed25519 identity (only a caller-trusted publisher is
accepted). Every member unwraps the SAME
k_organd derives the org-audience AEAD key from it, so org-scoped ops become mutually readable.
Enums§
- Crypto
Error - A crypto-boundary failure.
- KdfProfile
- The KDF profile a
StretchedMasterwas minted under. Versioned so that RAISING the Argon2id params later is an explicit re-key epoch (it changes every downstream key, so it folds into org-key rotation) — never a silent key fork. A device records which profile minted its current keys.
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). - ALG_
ORG_ KEY_ WRAP - Wire tag for a wrapped org-key blob. v2 adds the publisher signature:
every wrap is signed by the publisher’s Ed25519 identity, and
unwrap_org_keyREFUSES any wrap not signed by a caller-trusted holder — closing the key SUBSTITUTION hole (v1 sealed a key TO a recipient but authenticated NO ONE, so any member could wrap an attacker-chosenK_org'to a victim). There is NO v1 accept path in live unwrap: a downgrade to an unsigned wrap is structurally impossible, not policy-gated.
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_
ed25519_ identity - Derive a user’s deterministic Ed25519 SIGNING identity from their
StretchedMaster, under a domain distinct from the X25519 identity (see [KDF_INFO_ED25519_ID]). The publisher signs each wrap with this key so a recipient can reject wraps not authored by a trusted holder. The verifying key (ed25519_verifying) is published beside the X25519 public key. - 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. - derive_
x25519_ identity - Derive a user’s deterministic X25519 identity secret from their
StretchedMaster, so every device reconstructs the SAME keypair (the public key is published; the secret never leaves the device). 32 bytes of HKDF-SHA256 output are a valid X25519 scalar (dalek clamps at DH time). - ed25519_
verifying - The publishable Ed25519 verifying key for a signing identity.
- 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. - generate_
org_ key - Mint a fresh org master key
K_orgfrom the OS CSPRNG. Returned inZeroizingso it wipes on drop. - parse_
ed25519_ verifying - Parse a 64-hex-char Ed25519 verifying (public) key — e.g. a configured trusted
granter key for the
trustedset ofunwrap_org_key. Rejects a bad length or a non-canonical / small-order point (VerifyingKey::from_bytesvalidates). - parse_
x25519_ pub - Parse a 64-hex-char X25519 public key — e.g. a member’s published identity
pubkey (
crate::org_key_directory::MemberPublicKey::public_hex) that a granter wrapsK_orgagainst. - require_
canonical_ org - An org id usable in a wrap: rejected (never rewritten) unless it is a strict
ASCII slug
[A-Za-z0-9._-]+. This blocks Unicode confusables and delimiter-injection at the crypto boundary and pins the exact bytes bound into BOTH the KDF transcript and the signature — so a caller cannot sign a different transcript for “Acme” than for “acme”. Case-folding is deliberately NOT done here (the Turkish-İ / locale trap is itself a canonicalization bypass); the platform MUST mint ONE canonical opaque org id upstream (a tenant id), and this only enforces that it is well-formed and bound verbatim. - unwrap_
org_ key - Unwrap a
WrappedOrgKeyaddressed to this member with their identity secret — ONLY if it is signed by a key intrusted. Order is verify-BEFORE-decrypt: the signature (over the full transcript, bound to THIS caller’smy_user_id) is checked withverify_strictagainst each trusted verifying key FIRST; if none accept it, the blob is refused and no decrypt runs. Then the transcript-bound key is re-derived from the PUBLIC blob andmy_user_id, so a wrong recipient / tampered ephemeral or recipient pubkey / altered org/epoch also yield a different key and the AEAD open fails. - wrap_
org_ key - Wrap the shared org master key
k_orgforrecipient_pubin(org, epoch), SIGNED bypublisher_user_id’ssigner. A fresh ephemeral keypair per wrap; the full transcript is bound into the KDF; a low-order (degenerate) recipient key is rejected via the contributory check; the whole blob (incl. the AEAD envelope + publisher id) is signed so a recipient can reject wraps not authored by a trusted holder.orgmust be a canonical ASCII slug (seerequire_canonical_org). - x25519_
public - The publishable public identity for a user (the platform maps
account_id → thisso a wrapper can find each member’s key).