Expand description
OrgAwareKeyProvider — the SyncKeyProvider that encrypts org-scoped ops
under the SHARED org key K_org (mutually readable across an org’s members)
while personal ops keep the per-user key. This is the activation core of the
org shared brain: swapping it in is what makes Scope::Shared { org } ops
readable by every member instead of only their author.
§How it works (and why the wire form doesn’t change)
For Scope::Personal it delegates to an inner per-user provider (e.g.
crate::crypto::DerivedKeyProvider). For Scope::Shared { org } it takes
the org’s K_org root and derives the audience key through the SAME HKDF the
per-user path uses — derive_key(K_org, "org:<org>") → LocalKeyCipher.
The only thing that differs from the per-user provider is the master: a
shared K_org instead of a per-user secret. Same audience string, same
crate::crypto::Envelope shape — so the ciphertext becomes mutually
readable without any wire change. That is exactly the bug being fixed: today
two members derive DIFFERENT org keys from DIFFERENT per-user masters and
cannot read each other; a shared K_org master makes them converge.
§No directory, no identity secret on the hot path (option C)
SyncKeyProvider::cipher_for is called per-op on both the write path
(SyncSession::append) and the read path (the fold’s decrypted_tail). So
this provider holds ONLY pre-resolved K_org roots — no
crate::org_key_directory::OrgKeyDirectory and no X25519 identity secret.
Fetching wraps, unwrapping with the identity secret, and picking the newest
epoch all happen OUT OF BAND (a builder that runs at open time, in the
post-audit activation slice), never inside cipher_for. HKDF for a given org
runs once and is cached, so the per-op cost is a map lookup.
§Fail-closed
A member with no K_org for an org (not yet granted the key, or org-scope not
activated) gets a [DenyCipher] for that scope — encrypt AND decrypt error.
Never a personal cipher, never a different org’s cipher, never a freshly
minted key. On write that rolls the op back (a non-member cannot author an org
op); on read the ciphertext stays an opaque blob (fail-closed, never
plaintext, never wrong-key plaintext).
§Single-newest epoch (structural limit)
An crate::oplog::OpRecord’s envelope carries no key-id/epoch, and
cipher_for sees only &Scope, so per-op multi-epoch key selection is
UNEXPRESSIBLE through this trait today. This slice therefore resolves ONE
K_org per org (the newest the member can unwrap): correct for writes (always
the current key) and inert for reads (nothing rotates yet). Multi-epoch
decryption is a future ADDITIVE change (an authenticated key-id on the
envelope) — do NOT trial-decrypt across a keyring as a stand-in (guessing keys
until an AEAD opens is a wrong-key-acceptance surface). The internal map is
keyed by org so it can grow to (org, epoch) without a public-API change.
§Migration seam (for the activation slice)
Because the audience string is identical in both providers, the envelope
carries NO signal of which master produced it. Org ops written under the
OLD per-user-derived org key will not open under K_org after activation.
That is a one-time migration concern for whoever flips the switch — named
here, not silently ignored.
§INERT — no wiring, no flag
Org-scope E2E must not be enabled in prod before a cryptographer audit (the
login_secret → derive_x25519_identity entropy dependency). So this type is
BUILT, exported, and unit-tested, but referenced from ZERO call sites in the
sync-subsystem construction. Inertness is grep-provable: search the type name
and only tests answer. A default-OFF flag is deliberately NOT used — a flag is
still an activation path (env drift, a copied config), which is precisely what
the audit exists to gate. Absence is the proof a flag cannot give.
Structs§
- OrgAware
KeyProvider - A
SyncKeyProviderthat keysScope::Shared { org }ops on a sharedK_organd delegatesScope::Personalto an inner per-user provider. See the module docs for the hot-path, fail-closed, epoch, and inertness contracts.