Skip to main content

Module crypto

Module crypto 

Source
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-foldcrate::session::SyncSession::with_key_provider encrypts each payload at append (ciphertext-native, op_id over ciphertext) and decrypts at state() after the chain verifies and before the fold groups on payload["id"]/fold_key. Op identity stays the cleartext-metadata op_id.

  • Login-derived key distributionDerivedKeyProvider HKDF-derives per-audience keys from one master; DerivedKeyProvider::from_passphrase is the zero-knowledge cross-device source (same passphrase → same keys on every device, never transmitted). LocalKeyCipher remains the raw single-key reference. Per-audience isolation via encryption_audience.

  • Checkpoints under E2Ecrate::session::SyncSession::publish_checkpoint is 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 key K_org across all members so org-scoped ops are mutually readable, while the relay/platform never sees K_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 consuming crate::org_key_provider::OrgAwareKeyProvider are built on top. It all stays inert; the login_secret → identity entropy 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§

DerivedKeyProvider
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/surface metadata lives outside this, on the crate::oplog::OpRecord; the envelope hides only the payload body.
LocalKeyCipher
The single-user reference cipher: a local 256-bit ChaCha20-Poly1305 key.
MultiEpochOrgCipher
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 stamps kid. 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 unknown kid, a missing kid, or an empty keyring all error (the op stays opaque) — never a wrong key.
StretchedMaster
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.
WrappedOrgKey
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_org and derives the org-audience AEAD key from it, so org-scoped ops become mutually readable.

Enums§

CryptoError
A crypto-boundary failure.
KdfProfile
The KDF profile a StretchedMaster was 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_CHACHA20POLY1305
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_key REFUSES 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-chosen K_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§

PayloadCipher
The encrypt/decrypt boundary. A device authors an E2E op with cipher.encrypt(plaintext) as its payload; a peer holding the key recovers it with cipher.decrypt(&op.payload). Object-safe so a daemon can hold an Arc<dyn PayloadCipher> (a null/local reference now, a login-derived key later) without a type change.
SyncKeyProvider
Supplies the PayloadCipher for 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 audience from a login/entitlement master secret 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_org from the OS CSPRNG. Returned in Zeroizing so 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 trusted set of unwrap_org_key. Rejects a bad length or a non-canonical / small-order point (VerifyingKey::from_bytes validates).
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 wraps K_org against.
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 WrappedOrgKey addressed to this member with their identity secret — ONLY if it is signed by a key in trusted. Order is verify-BEFORE-decrypt: the signature (over the full transcript, bound to THIS caller’s my_user_id) is checked with verify_strict against 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 and my_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_org for recipient_pub in (org, epoch), SIGNED by publisher_user_id’s signer. 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. org must be a canonical ASCII slug (see require_canonical_org).
x25519_public
The publishable public identity for a user (the platform maps account_id → this so a wrapper can find each member’s key).