Skip to main content

car_sync/
org_key_resolver.rs

1//! `resolve_org_root` — the out-of-band resolver that turns a member's published
2//! wraps into their org master key `K_org`, for feeding
3//! [`crate::org_key_provider::OrgAwareKeyProvider`]. This runs OFF the hot path
4//! (at subsystem open), never inside `cipher_for`.
5//!
6//! ## What it does — and the trust it holds (audit item D)
7//!
8//! It fetches the candidate set addressed to the member
9//! ([`OrgKeyDirectory::fetch_wrapped_for`], newest-epoch-first) and returns the
10//! first wrap that [`crate::crypto::unwrap_org_key`] accepts under the caller's
11//! `trusted` granter keys. The primitive carries all the security: it
12//! `verify_strict`s the publisher signature against `trusted` BEFORE any decrypt
13//! and binds the CALLER's `my_user_id` (not the blob's advisory `recipient`), so
14//! the resolver stays dumb and lets the primitive judge.
15//!
16//! Its safety therefore reduces ENTIRELY to two things it cannot itself check,
17//! and which the cryptographer audit still owns:
18//! 1. the correctness of the `trusted` slice (admin-designated granters — keep it
19//!    small; a compromised granter can sign a wrap of a bogus `K_org'`), and
20//! 2. backend publish-authz — in particular **pubkey-table poisoning** (an active
21//!    publisher who overwrote a victim's published pubkey can recover the REAL
22//!    `K_org`; see the [`crate::org_key_directory`] module threat model).
23//! `verify_strict` does not close #2 — do not read this resolver as doing so.
24//!
25//! ## Fail-closed return shape (NOT a bare `Option`)
26//!
27//! - `Err(..)` — the directory was UNREACHABLE (fetch failed). The caller MUST
28//!   fail closed (org scope → `DenyCipher`), never silently drop org scope.
29//! - `Ok(None)` — the directory was reachable but holds no trusted grant for this
30//!   member (not yet granted). Also `DenyCipher`, but a distinct, benign state.
31//! - `Ok(Some(ResolvedOrgRoot))` — the root plus the epoch it came from.
32//!
33//! Collapsing the first two into one `None` would let a transient fetch blip
34//! silently disable org encryption — a fail-open. They are kept distinct.
35
36use zeroize::Zeroizing;
37
38use crate::crypto::unwrap_org_key;
39use crate::org_key_directory::{OrgKeyDirectory, OrgKeyDirectoryError};
40use ed25519_dalek::VerifyingKey;
41use x25519_dalek::StaticSecret;
42
43/// A resolved org master key and the epoch it was granted at. The epoch is
44/// surfaced so a future rotation-aware caller (or audit log) can see WHICH
45/// generation was selected without retrofitting the API.
46pub struct ResolvedOrgRoot {
47    pub root: Zeroizing<[u8; 32]>,
48    pub epoch: u64,
49}
50
51/// Resolve `org`'s `K_org` for a member from their published wraps. See the module
52/// docs for the fail-closed return contract and the trust this holds.
53///
54/// Selection: newest epoch first (the directory's contract), and within an epoch
55/// the first wrap that unwraps under a `trusted` key wins — safe because every
56/// trusted granter wraps the IDENTICAL `K_org` (it is the org master, not a
57/// per-granter secret), so which trusted-signed candidate is chosen cannot change
58/// the bytes. A lower epoch is selected only when every newer wrap fails to unwrap
59/// (i.e. the member was not re-granted at the newer epoch) — correct fail-closed
60/// selection.
61pub fn resolve_org_root(
62    directory: &dyn OrgKeyDirectory,
63    org: &str,
64    my_secret: &StaticSecret,
65    my_user_id: &str,
66    trusted: &[VerifyingKey],
67) -> Result<Option<ResolvedOrgRoot>, OrgKeyDirectoryError> {
68    // Err propagates → the caller fails closed. Do NOT map this to Ok(None).
69    let candidates = directory.fetch_wrapped_for(my_user_id)?;
70    for w in candidates {
71        // Defense-in-depth: the directory is single-org by construction, but the
72        // signature + KDF bind the wrap's OWN `org`, so a trusted granter's wrap
73        // for org A misrouted into org B's directory would verify+decrypt cleanly
74        // to A's key. Refuse to adopt it as B's root.
75        if w.org != org {
76            continue;
77        }
78        if let Ok(k) = unwrap_org_key(&w, my_secret, my_user_id, trusted) {
79            return Ok(Some(ResolvedOrgRoot {
80                root: Zeroizing::new(k),
81                epoch: w.epoch,
82            }));
83        }
84    }
85    Ok(None)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::crypto::{ed25519_verifying, wrap_org_key, x25519_public, StretchedMaster};
92    use crate::org_key_directory::{InMemoryOrgKeyDirectory, OrgKeyDirectory};
93
94    // Fast test identity helpers (issued-high-entropy skips Argon2id); same
95    // (secret, user) shape as the old raw-bytes fns → call sites are a pure rename.
96    fn x25519_id(secret: &[u8], user: &str) -> StaticSecret {
97        crate::crypto::derive_x25519_identity(
98            &StretchedMaster::from_issued_high_entropy(secret, user),
99            user,
100        )
101    }
102    fn ed25519_id(secret: &[u8], user: &str) -> ed25519_dalek::SigningKey {
103        crate::crypto::derive_ed25519_identity(
104            &StretchedMaster::from_issued_high_entropy(secret, user),
105            user,
106        )
107    }
108
109    fn granter() -> ed25519_dalek::SigningKey {
110        ed25519_id(b"granter-login", "acc_granter")
111    }
112
113    // Publish a wrap of `k_org` for `recipient` in `org` at `epoch`, signed by `signer`.
114    #[allow(clippy::too_many_arguments)]
115    fn publish(
116        dir: &mut InMemoryOrgKeyDirectory,
117        k_org: &[u8; 32],
118        org: &str,
119        epoch: u64,
120        recipient: &str,
121        recipient_pub: &x25519_dalek::PublicKey,
122        publisher: &str,
123        signer: &ed25519_dalek::SigningKey,
124    ) {
125        let w = wrap_org_key(
126            k_org,
127            org,
128            epoch,
129            recipient,
130            recipient_pub,
131            publisher,
132            signer,
133        )
134        .unwrap();
135        dir.publish_wrapped(&w).unwrap();
136    }
137
138    #[test]
139    fn resolves_the_root_for_a_granted_member() {
140        let mut dir = InMemoryOrgKeyDirectory::new();
141        let k_org = [7u8; 32];
142        let alice = x25519_id(b"alice", "acc_alice");
143        publish(
144            &mut dir,
145            &k_org,
146            "acme",
147            1,
148            "acc_alice",
149            &x25519_public(&alice),
150            "acc_granter",
151            &granter(),
152        );
153
154        let trusted = [ed25519_verifying(&granter())];
155        let got = resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
156            .unwrap()
157            .expect("granted member resolves");
158        assert_eq!(*got.root, k_org);
159        assert_eq!(got.epoch, 1);
160    }
161
162    #[test]
163    fn ungranted_member_is_ok_none_not_err() {
164        let dir = InMemoryOrgKeyDirectory::new(); // reachable, empty
165        let alice = x25519_id(b"alice", "acc_alice");
166        let trusted = [ed25519_verifying(&granter())];
167        // Reachable + no grant → Ok(None), the benign DenyCipher state.
168        assert!(
169            resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
170                .unwrap()
171                .is_none()
172        );
173    }
174
175    #[test]
176    fn wrap_from_untrusted_publisher_is_not_resolved() {
177        let mut dir = InMemoryOrgKeyDirectory::new();
178        let k_org = [9u8; 32];
179        let alice = x25519_id(b"alice", "acc_alice");
180        let mallory = ed25519_id(b"mallory", "acc_mallory");
181        publish(
182            &mut dir,
183            &k_org,
184            "acme",
185            1,
186            "acc_alice",
187            &x25519_public(&alice),
188            "acc_mallory",
189            &mallory,
190        );
191
192        // Alice trusts only the granter, not Mallory → the poisoned wrap is skipped.
193        let trusted = [ed25519_verifying(&granter())];
194        assert!(
195            resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
196                .unwrap()
197                .is_none()
198        );
199    }
200
201    #[test]
202    fn newest_grantable_epoch_wins() {
203        let mut dir = InMemoryOrgKeyDirectory::new();
204        let alice = x25519_id(b"alice", "acc_alice");
205        let trusted = [ed25519_verifying(&granter())];
206        publish(
207            &mut dir,
208            &[1u8; 32],
209            "acme",
210            1,
211            "acc_alice",
212            &x25519_public(&alice),
213            "acc_granter",
214            &granter(),
215        );
216        publish(
217            &mut dir,
218            &[2u8; 32],
219            "acme",
220            3,
221            "acc_alice",
222            &x25519_public(&alice),
223            "acc_granter",
224            &granter(),
225        );
226
227        let got = resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
228            .unwrap()
229            .unwrap();
230        assert_eq!(got.epoch, 3, "newest resolvable epoch selected");
231        assert_eq!(*got.root, [2u8; 32]);
232    }
233
234    #[test]
235    fn wrap_for_a_different_org_is_skipped() {
236        // A trusted granter's wrap for org "other" (would unwrap fine on its own)
237        // must NOT be adopted as "acme"'s root — defense-in-depth against a
238        // misrouted blob in the wrong org's directory.
239        let mut dir = InMemoryOrgKeyDirectory::new();
240        let alice = x25519_id(b"alice", "acc_alice");
241        let trusted = [ed25519_verifying(&granter())];
242        publish(
243            &mut dir,
244            &[5u8; 32],
245            "other",
246            1,
247            "acc_alice",
248            &x25519_public(&alice),
249            "acc_granter",
250            &granter(),
251        );
252
253        assert!(
254            resolve_org_root(&dir, "acme", &alice, "acc_alice", &trusted)
255                .unwrap()
256                .is_none()
257        );
258    }
259
260    #[test]
261    fn directory_error_propagates_as_err_not_ok_none() {
262        // Fail-closed: an UNREACHABLE directory must surface as Err (the wiring
263        // then fails closed to DenyCipher), never collapse into Ok(None) which a
264        // caller might read as "reachable, ungranted" and silently drop org scope.
265        struct FailingDir;
266        impl OrgKeyDirectory for FailingDir {
267            fn publish_wrapped(
268                &mut self,
269                _: &crate::crypto::WrappedOrgKey,
270            ) -> Result<(), OrgKeyDirectoryError> {
271                unimplemented!()
272            }
273            fn fetch_wrapped(
274                &self,
275                _: u64,
276                _: &str,
277            ) -> Result<Option<crate::crypto::WrappedOrgKey>, OrgKeyDirectoryError> {
278                unimplemented!()
279            }
280            fn fetch_wrapped_for(
281                &self,
282                _: &str,
283            ) -> Result<Vec<crate::crypto::WrappedOrgKey>, OrgKeyDirectoryError> {
284                Err(OrgKeyDirectoryError::Io(std::io::Error::other(
285                    "unreachable",
286                )))
287            }
288            fn publish_pubkey(&mut self, _: &str, _: &str) -> Result<(), OrgKeyDirectoryError> {
289                unimplemented!()
290            }
291            fn fetch_pubkeys(
292                &self,
293            ) -> Result<Vec<crate::org_key_directory::MemberPublicKey>, OrgKeyDirectoryError>
294            {
295                unimplemented!()
296            }
297        }
298        let alice = x25519_id(b"alice", "acc_alice");
299        let trusted = [ed25519_verifying(&granter())];
300        assert!(resolve_org_root(&FailingDir, "acme", &alice, "acc_alice", &trusted).is_err());
301    }
302}