Skip to main content

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`](crate::crypto::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}