car_sync/org_key_provider.rs
1//! `OrgAwareKeyProvider` — the [`SyncKeyProvider`] that encrypts org-scoped ops
2//! under the SHARED org key `K_org` (mutually readable across an org's members)
3//! while personal ops keep the per-user key. This is the activation core of the
4//! org shared brain: swapping it in is what makes `Scope::Shared { org }` ops
5//! readable by every member instead of only their author.
6//!
7//! ## How it works (and why the wire form doesn't change)
8//!
9//! For `Scope::Personal` it delegates to an inner per-user provider (e.g.
10//! [`crate::crypto::DerivedKeyProvider`]). For `Scope::Shared { org }` it takes
11//! the org's `K_org` root and derives the audience key through the SAME HKDF the
12//! per-user path uses — [`derive_key`]`(K_org, "org:<org>")` → [`LocalKeyCipher`].
13//! The only thing that differs from the per-user provider is the *master*: a
14//! shared `K_org` instead of a per-user secret. Same audience string, same
15//! [`crate::crypto::Envelope`] shape — so the ciphertext becomes mutually
16//! readable without any wire change. That is exactly the bug being fixed: today
17//! two members derive DIFFERENT org keys from DIFFERENT per-user masters and
18//! cannot read each other; a shared `K_org` master makes them converge.
19//!
20//! ## No directory, no identity secret on the hot path (option C)
21//!
22//! [`SyncKeyProvider::cipher_for`] is called per-op on both the write path
23//! (`SyncSession::append`) and the read path (the fold's `decrypted_tail`). So
24//! this provider holds ONLY pre-resolved `K_org` roots — no
25//! [`crate::org_key_directory::OrgKeyDirectory`] and no X25519 identity secret.
26//! Fetching wraps, unwrapping with the identity secret, and picking the newest
27//! epoch all happen OUT OF BAND (a builder that runs at open time, in the
28//! post-audit activation slice), never inside `cipher_for`. HKDF for a given org
29//! runs once and is cached, so the per-op cost is a map lookup.
30//!
31//! ## Fail-closed
32//!
33//! A member with no `K_org` for an org (not yet granted the key, or org-scope not
34//! activated) gets a [`DenyCipher`] for that scope — encrypt AND decrypt error.
35//! Never a personal cipher, never a different org's cipher, never a freshly
36//! minted key. On write that rolls the op back (a non-member cannot author an org
37//! op); on read the ciphertext stays an opaque blob (fail-closed, never
38//! plaintext, never wrong-key plaintext).
39//!
40//! ## Single-newest epoch (structural limit)
41//!
42//! An [`crate::oplog::OpRecord`]'s envelope carries no key-id/epoch, and
43//! `cipher_for` sees only `&Scope`, so per-op multi-epoch key selection is
44//! UNEXPRESSIBLE through this trait today. This slice therefore resolves ONE
45//! `K_org` per org (the newest the member can unwrap): correct for writes (always
46//! the current key) and inert for reads (nothing rotates yet). Multi-epoch
47//! decryption is a future ADDITIVE change (an authenticated key-id on the
48//! envelope) — do NOT trial-decrypt across a keyring as a stand-in (guessing keys
49//! until an AEAD opens is a wrong-key-acceptance surface). The internal map is
50//! keyed by org so it can grow to `(org, epoch)` without a public-API change.
51//!
52//! ## Migration seam (for the activation slice)
53//!
54//! Because the audience string is identical in both providers, the envelope
55//! carries NO signal of which master produced it. Org ops written under the
56//! OLD per-user-derived org key will not open under `K_org` after activation.
57//! That is a one-time migration concern for whoever flips the switch — named
58//! here, not silently ignored.
59//!
60//! ## INERT — no wiring, no flag
61//!
62//! Org-scope E2E must not be enabled in prod before a cryptographer audit (the
63//! `login_secret → derive_x25519_identity` entropy dependency). So this type is
64//! BUILT, exported, and unit-tested, but referenced from ZERO call sites in the
65//! sync-subsystem construction. Inertness is grep-provable: search the type name
66//! and only tests answer. A default-OFF flag is deliberately NOT used — a flag is
67//! still an activation path (env drift, a copied config), which is precisely what
68//! the audit exists to gate. Absence is the proof a flag cannot give.
69
70use std::collections::{BTreeMap, HashMap};
71use std::sync::{Arc, Mutex};
72
73use serde_json::Value;
74use zeroize::Zeroizing;
75
76use crate::crypto::{
77 derive_key, encryption_audience, CryptoError, MultiEpochOrgCipher, PayloadCipher,
78 SyncKeyProvider,
79};
80use crate::oplog::Scope;
81
82/// A [`PayloadCipher`] that always fails — the fail-closed cipher for a scope
83/// this provider has no key for. Both directions error, so it can never yield
84/// plaintext or accept a wrong-key ciphertext.
85struct DenyCipher {
86 reason: String,
87}
88
89impl PayloadCipher for DenyCipher {
90 fn encrypt(&self, _plaintext: &Value) -> Result<Value, CryptoError> {
91 Err(CryptoError::Key(self.reason.clone()))
92 }
93 fn decrypt(&self, _envelope: &Value) -> Result<Value, CryptoError> {
94 Err(CryptoError::Key(self.reason.clone()))
95 }
96}
97
98/// A [`SyncKeyProvider`] that keys `Scope::Shared { org }` ops on a shared
99/// `K_org` and delegates `Scope::Personal` to an inner per-user provider. See the
100/// module docs for the hot-path, fail-closed, epoch, and inertness contracts.
101pub struct OrgAwareKeyProvider {
102 personal: Arc<dyn SyncKeyProvider>,
103 /// `org → { epoch → K_org@epoch }` — every generation of the org master the
104 /// member can currently unwrap, not just the newest. Holding the full set is
105 /// what makes rotation NON-LOSSY: after a member-removal rotation bumps the
106 /// epoch, remaining members still hold the older epochs and can decrypt ops
107 /// authored before the bump (selected per-op by the envelope's `kid`). Roots
108 /// arrive in [`Zeroizing`] and are wiped on drop.
109 org_roots: HashMap<String, BTreeMap<u64, Zeroizing<[u8; 32]>>>,
110 /// `audience → derived cipher` — HKDF for an org runs once, not per op.
111 ciphers: Mutex<HashMap<String, Arc<dyn PayloadCipher>>>,
112}
113
114impl std::fmt::Debug for OrgAwareKeyProvider {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 // Never print key material; show only which orgs are resolved.
117 f.debug_struct("OrgAwareKeyProvider")
118 .field("orgs", &self.org_roots.keys().collect::<Vec<_>>())
119 .finish_non_exhaustive()
120 }
121}
122
123impl OrgAwareKeyProvider {
124 /// Build over an inner per-user `personal` provider and a set of
125 /// pre-resolved `org → K_org` roots (each the newest epoch the member can
126 /// unwrap). Resolution — fetch wraps, `unwrap_org_key`, pick newest — happens
127 /// OUT OF BAND before construction; this type never touches the directory or
128 /// the identity secret.
129 ///
130 /// Roots arrive already in [`Zeroizing`] so the root secret is never a bare
131 /// `Copy` array in a heap map on the way in: a `HashMap<_, [u8;32]>` would be
132 /// freed WITHOUT wiping (`[u8;32]: Copy` → wrapping copies and drops the
133 /// original un-zeroized). The out-of-band builder must wrap each
134 /// `unwrap_org_key` result at the source.
135 pub fn new(
136 personal: Arc<dyn SyncKeyProvider>,
137 org_roots: HashMap<String, BTreeMap<u64, Zeroizing<[u8; 32]>>>,
138 ) -> Self {
139 Self {
140 personal,
141 org_roots,
142 ciphers: Mutex::new(HashMap::new()),
143 }
144 }
145
146 fn org_cipher(&self, org: &str, audience: &str) -> Arc<dyn PayloadCipher> {
147 let mut cache = self.ciphers.lock().expect("org cipher cache poisoned");
148 if let Some(c) = cache.get(audience) {
149 return c.clone();
150 }
151 let cipher: Arc<dyn PayloadCipher> = match self.org_roots.get(org) {
152 // Derive the org-audience AEAD key for EACH held epoch through the SAME
153 // HKDF the per-user path uses (only the master differs), and hand the
154 // per-epoch keyring to a cipher that selects one by the envelope's kid.
155 // An empty map (org known but no epoch keys) is treated as no key.
156 Some(epochs) if !epochs.is_empty() => {
157 let keys: BTreeMap<u64, Zeroizing<[u8; 32]>> = epochs
158 .iter()
159 .map(|(&epoch, k_org)| (epoch, Zeroizing::new(derive_key(&k_org[..], audience))))
160 .collect();
161 Arc::new(MultiEpochOrgCipher::new(audience, keys))
162 }
163 // fail closed: no key for this org → a cipher that errors both ways.
164 _ => Arc::new(DenyCipher {
165 reason: format!(
166 "no org key for {audience}: member not granted K_org, or org-scope not activated"
167 ),
168 }),
169 };
170 cache.insert(audience.to_string(), cipher.clone());
171 cipher
172 }
173}
174
175impl SyncKeyProvider for OrgAwareKeyProvider {
176 fn cipher_for(&self, scope: &Scope) -> Arc<dyn PayloadCipher> {
177 match scope {
178 Scope::Personal => self.personal.cipher_for(scope),
179 Scope::Shared { org } => self.org_cipher(org, &encryption_audience(scope)),
180 }
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::crypto::DerivedKeyProvider;
188
189 fn personal(user: &str) -> Arc<dyn SyncKeyProvider> {
190 // from_login_secret (issued path, no Argon2id) keeps these provider tests
191 // fast — the passphrase stretch is covered by a dedicated crypto test.
192 Arc::new(DerivedKeyProvider::from_login_secret(b"pass", user))
193 }
194
195 // Single-epoch (epoch 1) convenience — most tests don't care about rotation.
196 fn provider(user: &str, roots: &[(&str, [u8; 32])]) -> OrgAwareKeyProvider {
197 OrgAwareKeyProvider::new(
198 personal(user),
199 roots
200 .iter()
201 .map(|(o, k)| {
202 let m = BTreeMap::from([(1u64, Zeroizing::new(*k))]);
203 (o.to_string(), m)
204 })
205 .collect(),
206 )
207 }
208
209 // Multi-epoch: each org carries a set of `(epoch, K_org)` roots.
210 fn provider_multi(user: &str, roots: &[(&str, &[(u64, [u8; 32])])]) -> OrgAwareKeyProvider {
211 OrgAwareKeyProvider::new(
212 personal(user),
213 roots
214 .iter()
215 .map(|(o, epochs)| {
216 let m: BTreeMap<u64, Zeroizing<[u8; 32]>> = epochs
217 .iter()
218 .map(|(e, k)| (*e, Zeroizing::new(*k)))
219 .collect();
220 (o.to_string(), m)
221 })
222 .collect(),
223 )
224 }
225
226 #[test]
227 fn personal_scope_round_trips_via_the_inner_provider() {
228 let p = provider("alice", &[]);
229 let msg = serde_json::json!({"note": "personal"});
230 let ct = p.cipher_for(&Scope::Personal).encrypt(&msg).unwrap();
231 let pt = p.cipher_for(&Scope::Personal).decrypt(&ct).unwrap();
232 assert_eq!(pt, msg);
233 }
234
235 #[test]
236 fn shared_org_ops_are_mutually_readable_across_members() {
237 // THE point of the feature: two DIFFERENT members (different personal
238 // masters) holding the SAME K_org read each other's org-scoped ops.
239 let k_org = [9u8; 32];
240 let alice = provider("alice", &[("acme", k_org)]);
241 let bob = provider("bob", &[("acme", k_org)]);
242 let scope = Scope::Shared { org: "acme".into() };
243
244 let msg = serde_json::json!({"shared": "brain"});
245 let ct = alice.cipher_for(&scope).encrypt(&msg).unwrap();
246 // Bob, a different member, recovers Alice's org op.
247 assert_eq!(bob.cipher_for(&scope).decrypt(&ct).unwrap(), msg);
248 }
249
250 #[test]
251 fn shared_org_without_key_fails_closed_both_ways() {
252 // A member not granted K_org for the org: encrypt AND decrypt error —
253 // never plaintext, never a wrong key.
254 let p = provider("alice", &[]);
255 let scope = Scope::Shared { org: "acme".into() };
256 assert!(matches!(
257 p.cipher_for(&scope).encrypt(&serde_json::json!({"x": 1})),
258 Err(CryptoError::Key(_))
259 ));
260 assert!(matches!(
261 p.cipher_for(&scope)
262 .decrypt(&serde_json::json!({"car_enc": "x"})),
263 Err(CryptoError::Key(_))
264 ));
265 }
266
267 #[test]
268 fn distinct_orgs_derive_independent_keys() {
269 let acme = provider("alice", &[("acme", [1u8; 32])]);
270 let globex = provider("alice", &[("globex", [2u8; 32])]);
271 let msg = serde_json::json!({"secret": "acme-only"});
272 let ct = acme
273 .cipher_for(&Scope::Shared { org: "acme".into() })
274 .encrypt(&msg)
275 .unwrap();
276 // A different org's key cannot open it (wrong key → Decrypt error).
277 assert!(matches!(
278 globex
279 .cipher_for(&Scope::Shared {
280 org: "globex".into()
281 })
282 .decrypt(&ct),
283 Err(CryptoError::Decrypt)
284 ));
285 }
286
287 #[test]
288 fn personal_and_org_audiences_are_independent() {
289 let p = provider("alice", &[("acme", [7u8; 32])]);
290 let msg = serde_json::json!({"m": 1});
291 let org_ct = p
292 .cipher_for(&Scope::Shared { org: "acme".into() })
293 .encrypt(&msg)
294 .unwrap();
295 // The personal cipher must not open an org ciphertext.
296 assert!(matches!(
297 p.cipher_for(&Scope::Personal).decrypt(&org_ct),
298 Err(CryptoError::Decrypt)
299 ));
300 }
301
302 #[test]
303 fn org_cipher_is_cached_per_audience() {
304 let p = provider("alice", &[("acme", [3u8; 32])]);
305 let scope = Scope::Shared { org: "acme".into() };
306 let a = p.cipher_for(&scope);
307 let b = p.cipher_for(&scope);
308 assert!(
309 Arc::ptr_eq(&a, &b),
310 "HKDF should run once per org, not per op"
311 );
312 }
313
314 #[test]
315 fn debug_never_prints_key_material() {
316 let p = provider("alice", &[("acme", [0xabu8; 32])]);
317 let dbg = format!("{p:?}");
318 assert!(dbg.contains("acme"), "shows which orgs are resolved");
319 assert!(!dbg.contains("abab"), "must not leak key bytes");
320 }
321
322 #[test]
323 fn encrypt_uses_newest_epoch_and_older_ops_still_decrypt() {
324 // Rotation is non-lossy: a member holding {1, 2} encrypts under the NEWEST
325 // epoch (2), yet can still decrypt an op authored under the OLD epoch (1) —
326 // the kid on each envelope selects the right key.
327 let scope = Scope::Shared { org: "acme".into() };
328 let old_only = provider_multi("alice", &[("acme", &[(1, [1u8; 32])])]);
329 let both = provider_multi("alice", &[("acme", &[(1, [1u8; 32]), (2, [2u8; 32])])]);
330
331 // Op authored while only epoch 1 existed.
332 let old_msg = serde_json::json!({"gen": 1});
333 let old_ct = old_only.cipher_for(&scope).encrypt(&old_msg).unwrap();
334
335 // A post-rotation member still holds epoch 1 → reads the old op...
336 assert_eq!(both.cipher_for(&scope).decrypt(&old_ct).unwrap(), old_msg);
337
338 // ...and NEW ops are stamped with epoch 2.
339 let new_ct = both
340 .cipher_for(&scope)
341 .encrypt(&serde_json::json!({"gen": 2}))
342 .unwrap();
343 assert_eq!(new_ct.get("kid").and_then(|v| v.as_u64()), Some(2));
344 }
345
346 #[test]
347 fn dropped_epoch_can_no_longer_decrypt_its_ops() {
348 // The removal case: an op authored under epoch 1, then the member's keyring
349 // rotates to hold ONLY epoch 2 (epoch-1 key withdrawn). The old op is now
350 // opaque — kid=1 is not held → fail closed, never a wrong-key open.
351 let scope = Scope::Shared { org: "acme".into() };
352 let e1 = provider_multi("alice", &[("acme", &[(1, [1u8; 32])])]);
353 let old_ct = e1
354 .cipher_for(&scope)
355 .encrypt(&serde_json::json!({"gen": 1}))
356 .unwrap();
357
358 let e2_only = provider_multi("alice", &[("acme", &[(2, [2u8; 32])])]);
359 assert!(matches!(
360 e2_only.cipher_for(&scope).decrypt(&old_ct),
361 Err(CryptoError::Key(_))
362 ));
363 }
364
365 #[test]
366 fn org_envelope_missing_kid_fails_closed() {
367 // A personal-shaped envelope (no kid) must NOT be accepted on the org path —
368 // the org cipher requires an explicit epoch, never a default.
369 let scope = Scope::Shared { org: "acme".into() };
370 let p = provider("alice", &[("acme", [4u8; 32])]);
371 let no_kid = serde_json::json!({
372 "car_enc": "chacha20poly1305",
373 "nonce": "000000000000000000000000",
374 "ct": "00",
375 });
376 assert!(matches!(
377 p.cipher_for(&scope).decrypt(&no_kid),
378 Err(CryptoError::BadEnvelope(_))
379 ));
380 }
381
382 #[test]
383 fn org_envelopes_carry_a_kid_personal_ones_do_not() {
384 // The wire distinction: org ciphertext stamps kid; personal stays kid-less
385 // (byte-identical to pre-org-scope envelopes — see Envelope::kid docs).
386 let p = provider("alice", &[("acme", [5u8; 32])]);
387 let org_ct = p
388 .cipher_for(&Scope::Shared { org: "acme".into() })
389 .encrypt(&serde_json::json!({"x": 1}))
390 .unwrap();
391 assert!(org_ct.get("kid").is_some(), "org envelope stamps epoch");
392
393 let personal_ct = p
394 .cipher_for(&Scope::Personal)
395 .encrypt(&serde_json::json!({"x": 1}))
396 .unwrap();
397 assert!(
398 personal_ct.get("kid").is_none(),
399 "personal envelope omits kid (wire-compatible)"
400 );
401 }
402}