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}