car_sync/org_rotation.rs
1//! Org-key ROTATION on member removal (slice 10) — the granter-side primitive
2//! that bumps the epoch, plus the authenticated freshness marker that decides
3//! which epoch is allowed to author the org's *present*.
4//!
5//! ## Inertness — scoped precisely (grep-provable)
6//!
7//! As of slice 11 exactly ONE function here has a live call site:
8//! [`resolve_all_org_roots`], wired into `open_sync_subsystem` behind the
9//! DEFAULT-OFF `PARSLEE_SYNC_ORG_SCOPE` gate (unset → byte-identical personal-only
10//! path; still audit-gated for real tenants). It only READS the epochs a member
11//! can unwrap — provisioning, no rotation authority.
12//!
13//! Everything ELSE remains a leaf primitive with NO live call site — like slice
14//! 9's `MultiEpochOrgCipher`, shipping the MECHANISM and leaving the wiring for
15//! later (audit-gated): [`rotate_org_key`], [`sign_rotation_floor`],
16//! [`verify_rotation_floor`], and in particular [`is_authoritative`], a PURE
17//! predicate deliberately NOT wired into the reducer (`SyncState`/`fold_onto`) —
18//! live-fold enforcement of freshness is deferred to the cryptographer audit. No
19//! floor is consumed anywhere yet, so the anti-rollback caller obligation (see
20//! [`verify_rotation_floor`]) does not attach to any live path.
21//!
22//! ## What rotation is (and is NOT)
23//!
24//! Rotation does not — cannot — take a key away. History was authored under the
25//! old `K_org`, and remaining members must keep reading it ([`crate::org_key_provider::OrgAwareKeyProvider`]
26//! holds every epoch it can unwrap). Rotation is about which key is allowed to
27//! author NEW ops: mint a fresh `K_org@(N+1)` ([`crate::crypto::generate_org_key`],
28//! independent of `N`), wrap it for the REMAINING members only, publish at epoch
29//! N+1, and raise a signed [`RotationFloor`] so remaining members treat post-cut
30//! epoch-N writes as non-authoritative.
31//!
32//! ## The freshness boundary — what it does and does NOT prove (audit note)
33//!
34//! [`is_authoritative`] gates on `kid >= floor_epoch || hlc <= rotation_hlc`. The
35//! `kid` half is UNFORGEABLE: a removed member cannot produce an op at epoch N+1
36//! (they lack `K_org@(N+1)` and cannot derive it). The `hlc` half is only a
37//! best-effort partition of HONEST history — the HLC is client-stamped
38//! ([`crate::oplog::Hlc`]), so a removed member holding `K_org@N` can BACKDATE an
39//! op to `hlc <= rotation_hlc` and slip past the historical side. That is
40//! tolerable ONLY because such a forged op is confined to epoch N (already
41//! readable to them, constraint (c)) and can only ever LOSE last-writer-wins to a
42//! genuine epoch-(N+1) op. Fully refusing a removed member's writes needs per-op
43//! author signatures bound to a membership epoch, or backend admission authz —
44//! both DEFERRED to the audit / later slices. Do not read this module as closing
45//! that residual.
46
47use zeroize::Zeroizing;
48
49use crate::crypto::{
50 from_hex, parse_x25519_pub, to_hex, unwrap_org_key, wrap_org_key, CryptoError, WrappedOrgKey,
51};
52use crate::oplog::Hlc;
53use crate::org_key_directory::{MemberPublicKey, OrgKeyDirectory, OrgKeyDirectoryError};
54use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
55use x25519_dalek::{PublicKey, StaticSecret};
56
57/// Domain tag for the rotation-floor signature transcript. Distinct from the
58/// wrap tag so a floor signature can never be replayed as a wrap signature.
59const ALG_ROTATION_FLOOR: &str = "org-rotation-floor/v1";
60
61/// Failures rotating an org key or handling a rotation floor.
62#[derive(Debug)]
63pub enum RotationError {
64 /// A wrap failed to construct (e.g. a low-order recipient key).
65 Crypto(CryptoError),
66 /// Publishing to the directory failed (I/O or backend authz refusal).
67 Directory(OrgKeyDirectoryError),
68 /// The floor signature did not verify against any trusted granter key.
69 Untrusted,
70 /// The floor is not internally well-formed: `floor_epoch <= prev_floor_epoch`.
71 /// NOTE: this is a self-consistency check, NOT anti-rollback — see
72 /// [`verify_rotation_floor`] for why the caller still owns replay defense.
73 NonMonotonic,
74 /// The `car_floor` algorithm tag was not [`ALG_ROTATION_FLOOR`].
75 WrongAlgorithm(String),
76 /// The stored signature hex was malformed.
77 BadSignature(String),
78}
79
80impl std::fmt::Display for RotationError {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 RotationError::Crypto(e) => write!(f, "rotation crypto error: {e}"),
84 RotationError::Directory(e) => write!(f, "rotation directory error: {e}"),
85 RotationError::Untrusted => {
86 write!(f, "rotation floor not signed by a trusted granter")
87 }
88 RotationError::NonMonotonic => {
89 write!(f, "rotation floor epoch not strictly greater than previous")
90 }
91 RotationError::WrongAlgorithm(t) => {
92 write!(f, "rotation floor has unexpected algorithm tag: {t:?}")
93 }
94 RotationError::BadSignature(m) => write!(f, "rotation floor signature malformed: {m}"),
95 }
96 }
97}
98
99impl std::error::Error for RotationError {}
100
101impl From<CryptoError> for RotationError {
102 fn from(e: CryptoError) -> Self {
103 RotationError::Crypto(e)
104 }
105}
106impl From<OrgKeyDirectoryError> for RotationError {
107 fn from(e: OrgKeyDirectoryError) -> Self {
108 RotationError::Directory(e)
109 }
110}
111
112/// A signed, monotonic marker that org `org` rotated to `floor_epoch` at logical
113/// time `rotation_hlc`. Remaining members use it via [`is_authoritative`] to
114/// stop treating a removed member's post-cut epoch-`<floor_epoch>` writes as the
115/// org's current truth.
116///
117/// The signature (by a TRUSTED granter, the same authority that wraps `K_org`) is
118/// LOAD-BEARING: an unsigned/unverified floor is a censorship primitive — anyone
119/// could publish `floor_epoch = u64::MAX` and mark every legitimate op stale.
120/// Always [`verify_rotation_floor`] before honoring one.
121#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122pub struct RotationFloor {
123 /// Algorithm tag ([`ALG_ROTATION_FLOOR`]).
124 pub car_floor: String,
125 pub org: String,
126 /// The epoch at/after which authorship is required for new ops.
127 pub floor_epoch: u64,
128 /// The logical time the cut happened. Ops with `hlc <= rotation_hlc` are
129 /// treated as pre-cut history (see the module's audit note on forgeability).
130 pub rotation_hlc: Hlc,
131 /// The floor this one supersedes. `verify_rotation_floor` checks
132 /// `floor_epoch > prev_floor_epoch` for internal well-formedness ONLY — that
133 /// does NOT stop an attacker replaying an OLDER validly-signed floor (whose
134 /// own `floor_epoch > prev_floor_epoch` still holds). Anti-rollback is the
135 /// caller's job: persist the max accepted `floor_epoch` and reject anything
136 /// `<=` it. See [`verify_rotation_floor`].
137 pub prev_floor_epoch: u64,
138 /// Ed25519 signature (hex) by a trusted granter over
139 /// [`rotation_floor_transcript`]. Verified with `verify_strict`.
140 pub signature: String,
141}
142
143/// The bytes a [`RotationFloor`] signs — domain-tagged, length-prefixed, all
144/// integers little-endian, matching `wrap_sign_transcript`'s discipline so the
145/// encoding is injective (no field-boundary ambiguity) and one endianness holds
146/// crate-wide. EVERY security-relevant field is bound here; under-binding would
147/// pass functional tests while silently letting a field be tampered.
148fn rotation_floor_transcript(
149 org: &str,
150 floor_epoch: u64,
151 rotation_hlc: &Hlc,
152 prev_floor_epoch: u64,
153) -> Vec<u8> {
154 fn lp(buf: &mut Vec<u8>, field: &[u8]) {
155 buf.extend_from_slice(&(field.len() as u64).to_le_bytes());
156 buf.extend_from_slice(field);
157 }
158 let mut t = Vec::new();
159 lp(&mut t, ALG_ROTATION_FLOOR.as_bytes());
160 lp(&mut t, org.as_bytes());
161 t.extend_from_slice(&floor_epoch.to_le_bytes());
162 // Bind the full HLC triple (wall_ms, counter, device_id).
163 t.extend_from_slice(&rotation_hlc.wall_ms.to_le_bytes());
164 t.extend_from_slice(&rotation_hlc.counter.to_le_bytes());
165 lp(&mut t, rotation_hlc.device_id.as_bytes());
166 t.extend_from_slice(&prev_floor_epoch.to_le_bytes());
167 t
168}
169
170/// Sign a rotation floor as a trusted granter. Callers must ensure
171/// `floor_epoch > prev_floor_epoch` (also re-checked on verify).
172pub fn sign_rotation_floor(
173 org: &str,
174 floor_epoch: u64,
175 rotation_hlc: Hlc,
176 prev_floor_epoch: u64,
177 signer: &SigningKey,
178) -> RotationFloor {
179 let sig = signer.sign(&rotation_floor_transcript(
180 org,
181 floor_epoch,
182 &rotation_hlc,
183 prev_floor_epoch,
184 ));
185 RotationFloor {
186 car_floor: ALG_ROTATION_FLOOR.to_string(),
187 org: org.to_string(),
188 floor_epoch,
189 rotation_hlc,
190 prev_floor_epoch,
191 signature: to_hex(&sig.to_bytes()),
192 }
193}
194
195/// Verify a rotation floor: its `car_floor` tag must be [`ALG_ROTATION_FLOOR`],
196/// its signature must `verify_strict` against one of the caller's `trusted`
197/// granter keys, AND it must be internally well-formed
198/// (`floor_epoch > prev_floor_epoch`). Returns the verified `floor_epoch` on
199/// success. Fail-closed: any tag/parse/verify/well-formedness failure is an
200/// `Err`, never a silently-accepted floor.
201///
202/// ANTI-ROLLBACK IS NOT DONE HERE. This function is stateless, so it CANNOT stop
203/// an attacker replaying an OLDER, legitimately-signed floor: `(floor_epoch=2,
204/// prev=1)` is well-formed and verifies even after the org has advanced to
205/// `(5,4)`, downgrading the boundary from 5 back to 2. The CALLER MUST persist
206/// the maximum `floor_epoch` it has ever accepted for the org and reject any
207/// verified floor whose `floor_epoch <= that maximum`. The
208/// `floor_epoch > prev_floor_epoch` check is only self-consistency, not replay
209/// defense — do not mistake it for one.
210pub fn verify_rotation_floor(
211 floor: &RotationFloor,
212 trusted: &[VerifyingKey],
213) -> Result<u64, RotationError> {
214 // Algorithm tag — reject foreign/garbage floors before touching the signature
215 // (mirrors unwrap_org_key's car_wrap check). The signature domain is bound to
216 // the CONSTANT tag in the transcript, so this also keeps car_floor honest.
217 if floor.car_floor != ALG_ROTATION_FLOOR {
218 return Err(RotationError::WrongAlgorithm(floor.car_floor.clone()));
219 }
220 // Well-formedness only — NOT anti-rollback (see the doc above; the caller
221 // tracks the max accepted floor_epoch).
222 if floor.floor_epoch <= floor.prev_floor_epoch {
223 return Err(RotationError::NonMonotonic);
224 }
225 let sig_bytes: [u8; 64] = from_hex(&floor.signature)
226 .map_err(RotationError::BadSignature)?
227 .try_into()
228 .map_err(|_| RotationError::BadSignature("signature must be 64 bytes".into()))?;
229 let signature = Signature::from_bytes(&sig_bytes);
230 let transcript = rotation_floor_transcript(
231 &floor.org,
232 floor.floor_epoch,
233 &floor.rotation_hlc,
234 floor.prev_floor_epoch,
235 );
236 let ok = trusted
237 .iter()
238 .any(|vk| vk.verify_strict(&transcript, &signature).is_ok());
239 if ok {
240 Ok(floor.floor_epoch)
241 } else {
242 Err(RotationError::Untrusted)
243 }
244}
245
246/// Is an op — identified by its org-cipher epoch `kid` and its `hlc` — still the
247/// org's CURRENT authoritative truth under `floor`?
248///
249/// - `kid >= floor.floor_epoch` → authored at/after the cut with a key only a
250/// remaining member holds. UNFORGEABLE authority.
251/// - `hlc <= floor.rotation_hlc` → pre-cut history, kept authoritative so reads
252/// stay non-lossy (constraint (c)). Best-effort for the honest case only — see
253/// the module audit note; a backdated forgery lands here but can only lose LWW.
254/// - otherwise → a post-cut write under a stale epoch → NOT authoritative.
255///
256/// `kid == None` (a personal-shaped envelope) is never org-authoritative on the
257/// org path — org ciphertext always carries a `kid` (slice 9). Such an op only
258/// stays authoritative via the pre-cut `hlc` allowance.
259///
260/// WIRING CONTRACT (constraint (a) — tolerate-the-window): a `false` here means
261/// "does NOT override a competing op", i.e. an **LWW tiebreak input** — it must
262/// NOT be used as a filter that DROPS the op. An honest lagging member who hasn't
263/// yet received the new-epoch grant keeps writing the old `kid` with post-cut
264/// timestamps and returns `false` here, indistinguishable from a malicious
265/// stale write by `(kid, hlc)` alone. Dropping it would lose that member's work;
266/// instead let it survive UNLESS a genuine higher-epoch op competes, so
267/// convergence heals once the member catches up and re-emits under the new epoch.
268/// Wiring this as a discard violates (a).
269pub fn is_authoritative(kid: Option<u64>, hlc: &Hlc, floor: &RotationFloor) -> bool {
270 if let Some(k) = kid {
271 if k >= floor.floor_epoch {
272 return true;
273 }
274 }
275 *hlc <= floor.rotation_hlc
276}
277
278/// Rotate `org` to `new_epoch`: wrap `k_org_new` for the REMAINING members ONLY
279/// and publish each wrap at `new_epoch`. The removed member is simply absent from
280/// `remaining`, so no wrap is ever authored for them — that omission, not any
281/// revocation, is the whole mechanism.
282///
283/// `k_org_new` MUST be a fresh [`crate::crypto::generate_org_key`] draw
284/// independent of the previous epoch (see that fn's contract). This function does
285/// not generate it, so callers can rotate deterministically in tests, but the
286/// production caller passes a CSPRNG key. The freshness invariant rests on caller
287/// discipline + the deliberate ABSENCE of any derive-from-old API — it is NOT
288/// checked here.
289///
290/// CALLER OBLIGATIONS (unchecked here — a trusted granter is assumed):
291/// - **`new_epoch` monotonicity.** `new_epoch` MUST strictly exceed every epoch
292/// already published for the org. It is NOT validated; publishing at a
293/// colliding epoch LWW-overwrites live wraps with a different key and breaks
294/// existing members.
295/// - **Non-atomic + retry MUST reuse the SAME `k_org_new`.** This loop publishes
296/// per member with no rollback; a mid-loop `publish_wrapped` failure leaves
297/// some members granted and some not. On retry the caller MUST pass the
298/// IDENTICAL `k_org_new` — calling `generate_org_key` again would grant a
299/// DIFFERENT key at the same epoch, so `resolve_all_org_roots` would hand
300/// incompatible bytes for one epoch to different members (split brain: ops one
301/// authors are undecryptable to another). Reuse the key; only bump the epoch to
302/// start a genuinely new generation.
303///
304/// This publishes wraps but does NOT produce or distribute the [`RotationFloor`];
305/// the caller signs one with [`sign_rotation_floor`] and distributes it (its
306/// directory/transport home lands with real provisioning in a later slice).
307pub fn rotate_org_key(
308 directory: &mut dyn OrgKeyDirectory,
309 org: &str,
310 new_epoch: u64,
311 remaining: &[(&str, &PublicKey)],
312 k_org_new: &[u8; 32],
313 granter_user_id: &str,
314 granter_signer: &SigningKey,
315) -> Result<(), RotationError> {
316 for (recipient_id, recipient_pub) in remaining {
317 let wrapped = wrap_org_key(
318 k_org_new,
319 org,
320 new_epoch,
321 recipient_id,
322 recipient_pub,
323 granter_user_id,
324 granter_signer,
325 )?;
326 directory.publish_wrapped(&wrapped)?;
327 }
328 Ok(())
329}
330
331/// The per-member outcome of [`provision_org_members`]. Best-effort: `granted`
332/// are the account ids that received a wrap this round; `skipped` pairs every
333/// other account id with WHY it got none (malformed pubkey, a low-order /
334/// non-contributory key that `wrap_org_key` rejects, or a publish failure). A
335/// non-empty `skipped` is NOT a batch abort — the caller reports both.
336#[derive(Debug, Default, Clone)]
337pub struct ProvisionReport {
338 pub granted: Vec<String>,
339 pub skipped: Vec<(String, String)>,
340}
341
342/// Wrap `k_org` at `epoch` for every member in `members` and publish each wrap —
343/// the granter's provisioning entry point over the DIRECTORY-NATIVE
344/// [`MemberPublicKey`] shape ([`OrgKeyDirectory::fetch_pubkeys`] output), so a
345/// granter tool never has to touch X25519 types.
346///
347/// BEST-EFFORT (returns a [`ProvisionReport`], never aborts the batch): a member
348/// whose pubkey is malformed hex, whose key is low-order (`wrap_org_key`'s
349/// contributory check rejects it — an unauthenticated directory lets anyone
350/// publish such a point, so a batch-abort here would be a cheap DoS), or whose
351/// wrap fails to publish is recorded in `skipped` with a reason; the rest proceed.
352///
353/// CALLER OBLIGATIONS — this carries the SAME non-atomic re-key hazard as
354/// [`rotate_org_key`]. Publishing is per-member with no rollback, and
355/// `publish_wrapped` is LWW keyed on `(epoch, recipient)`. So:
356/// - If `epoch` ALREADY has grants, calling this with a FRESH `k_org` RE-KEYS
357/// every member at that epoch (LWW overwrite), orphaning any data written under
358/// the previous key. There is no re-wrap-the-existing-key path here — to add a
359/// member at a populated epoch you must deliberately re-key everyone. A caller
360/// that must not silently re-key should check for existing grants at `epoch`
361/// first (see `examples/org_granter.rs`).
362/// - A retry after a partial publish MUST reuse the SAME `k_org`; a fresh one
363/// splits the epoch into incompatible keys.
364/// - For a clean rotation, mint a fresh key AND bump `epoch` monotonically.
365pub fn provision_org_members(
366 directory: &mut dyn OrgKeyDirectory,
367 org: &str,
368 epoch: u64,
369 k_org: &[u8; 32],
370 members: &[MemberPublicKey],
371 granter_user_id: &str,
372 granter_signer: &SigningKey,
373) -> ProvisionReport {
374 let mut report = ProvisionReport::default();
375 for m in members {
376 // Every failure mode is a per-member SKIP-with-reason, never a batch abort
377 // — one bad (or maliciously poisoned) entry must not block the others.
378 let recipient_pub = match parse_x25519_pub(&m.public_hex) {
379 Ok(p) => p,
380 Err(e) => {
381 report
382 .skipped
383 .push((m.account_id.clone(), format!("malformed pubkey: {e}")));
384 continue;
385 }
386 };
387 let wrapped = match wrap_org_key(
388 k_org,
389 org,
390 epoch,
391 &m.account_id,
392 &recipient_pub,
393 granter_user_id,
394 granter_signer,
395 ) {
396 Ok(w) => w,
397 Err(e) => {
398 report
399 .skipped
400 .push((m.account_id.clone(), format!("wrap failed: {e}")));
401 continue;
402 }
403 };
404 match directory.publish_wrapped(&wrapped) {
405 Ok(()) => report.granted.push(m.account_id.clone()),
406 Err(e) => report
407 .skipped
408 .push((m.account_id.clone(), format!("publish failed: {e}"))),
409 }
410 }
411 report
412}
413
414/// Resolve EVERY epoch of `org`'s `K_org` a member can currently unwrap — the
415/// plural of [`crate::resolve_org_root`]. This is what populates the multi-epoch
416/// map [`crate::org_key_provider::OrgAwareKeyProvider`] already accepts: after a
417/// rotation a remaining member holds `{N, N+1}` and reads both generations.
418///
419/// A removed member, having no wrap at the new epoch, resolves only the OLD
420/// epoch(s) — exactly the fail-closed outcome rotation intends. Every candidate
421/// is verify-then-decrypt against `trusted` (via [`unwrap_org_key`]); untrusted
422/// or wrong-org wraps are skipped, never adopted.
423///
424/// Fail-closed return contract mirrors [`crate::resolve_org_root`]: `Err` means
425/// the directory was UNREACHABLE (caller fails closed to `DenyCipher`); `Ok` with
426/// an EMPTY map means reachable-but-ungranted (also `DenyCipher`, benign).
427pub fn resolve_all_org_roots(
428 directory: &dyn OrgKeyDirectory,
429 org: &str,
430 my_secret: &StaticSecret,
431 my_user_id: &str,
432 trusted: &[VerifyingKey],
433) -> Result<std::collections::BTreeMap<u64, Zeroizing<[u8; 32]>>, OrgKeyDirectoryError> {
434 let mut roots = std::collections::BTreeMap::new();
435 // Err propagates → caller fails closed. Do NOT map to an empty map.
436 let candidates: Vec<WrappedOrgKey> = directory.fetch_wrapped_for(my_user_id)?;
437 for w in candidates {
438 // Defense-in-depth: a trusted granter's wrap for a DIFFERENT org would
439 // verify+decrypt cleanly to that org's key; refuse to adopt it here.
440 if w.org != org {
441 continue;
442 }
443 // Forward-looking defense: the current directory keys wraps by
444 // (recipient, epoch) so it returns at most ONE wrap per epoch — this guard
445 // is inert today. If a future transport can return multiple wraps at one
446 // epoch, gating on SUCCESSFUL insertion (not iteration) is the safe order:
447 // a leading UNTRUSTED wrap fails unwrap, leaves the epoch unheld, so a
448 // following TRUSTED wrap at the same epoch is still tried and wins (an
449 // untrusted wrap can never shadow the real one). Adopting a genuinely wrong
450 // key would need two DIFFERENT trusted-signed keys at one epoch — a granter
451 // fault, the named audit assumption.
452 if roots.contains_key(&w.epoch) {
453 continue;
454 }
455 if let Ok(k) = unwrap_org_key(&w, my_secret, my_user_id, trusted) {
456 roots.insert(w.epoch, Zeroizing::new(k));
457 }
458 }
459 Ok(roots)
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::crypto::{ed25519_verifying, generate_org_key, x25519_public, StretchedMaster};
466 use crate::org_key_directory::InMemoryOrgKeyDirectory;
467
468 // Fast test identity helpers (issued-high-entropy skips Argon2id).
469 fn x25519_id(secret: &[u8], user: &str) -> StaticSecret {
470 crate::crypto::derive_x25519_identity(
471 &StretchedMaster::from_issued_high_entropy(secret, user),
472 user,
473 )
474 }
475 fn ed25519_id(secret: &[u8], user: &str) -> SigningKey {
476 crate::crypto::derive_ed25519_identity(
477 &StretchedMaster::from_issued_high_entropy(secret, user),
478 user,
479 )
480 }
481 fn granter() -> SigningKey {
482 ed25519_id(b"granter-login", "acc_granter")
483 }
484
485 fn hlc(wall_ms: u64, counter: u32) -> Hlc {
486 Hlc {
487 wall_ms,
488 counter,
489 device_id: "dev-a".into(),
490 }
491 }
492
493 fn hex_of(pk: &x25519_dalek::PublicKey) -> String {
494 pk.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
495 }
496
497 // ---- rotation orchestration + resolve_all_org_roots ----
498
499 #[test]
500 fn rotation_grants_remaining_members_and_omits_the_removed() {
501 // Alice + Bob at epoch 1; Carol is removed on the rotation to epoch 2.
502 let mut dir = InMemoryOrgKeyDirectory::new();
503 let g = granter();
504 let alice = x25519_id(b"alice", "acc_alice");
505 let bob = x25519_id(b"bob", "acc_bob");
506 let carol = x25519_id(b"carol", "acc_carol");
507 let trusted = [ed25519_verifying(&g)];
508
509 // Initial grant at epoch 1 for all three.
510 let k1 = [1u8; 32];
511 for (id, sk) in [
512 ("acc_alice", &alice),
513 ("acc_bob", &bob),
514 ("acc_carol", &carol),
515 ] {
516 let w =
517 wrap_org_key(&k1, "acme", 1, id, &x25519_public(sk), "acc_granter", &g).unwrap();
518 dir.publish_wrapped(&w).unwrap();
519 }
520
521 // Rotate to epoch 2 for the REMAINING members only (Carol excluded).
522 let k2 = generate_org_key();
523 assert_ne!(*k2, k1, "new epoch key is independent of the old");
524 rotate_org_key(
525 &mut dir,
526 "acme",
527 2,
528 &[
529 ("acc_alice", &x25519_public(&alice)),
530 ("acc_bob", &x25519_public(&bob)),
531 ],
532 &k2,
533 "acc_granter",
534 &g,
535 )
536 .unwrap();
537
538 // Remaining members hold BOTH epochs...
539 let a_roots = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
540 assert_eq!(a_roots.keys().copied().collect::<Vec<_>>(), vec![1, 2]);
541 assert_eq!(*a_roots[&1], k1);
542 assert_eq!(*a_roots[&2], *k2);
543
544 // ...the removed member holds ONLY the old epoch (fail-closed on new ops).
545 let c_roots = resolve_all_org_roots(&dir, "acme", &carol, "acc_carol", &trusted).unwrap();
546 assert_eq!(c_roots.keys().copied().collect::<Vec<_>>(), vec![1]);
547 assert!(
548 !c_roots.contains_key(&2),
549 "removed member gets no new epoch"
550 );
551 }
552
553 #[test]
554 fn provision_over_member_pubkeys_grants_and_skips_malformed() {
555 // The granter-tool entry point: wrap for the directory-native MemberPublicKey
556 // set. Well-formed members become resolvable; a malformed pubkey is skipped
557 // (reported via the return list), never a batch abort.
558 let mut dir = InMemoryOrgKeyDirectory::new();
559 let g = granter();
560 let trusted = [ed25519_verifying(&g)];
561 let alice = x25519_id(b"alice", "acc_alice");
562 let bob = x25519_id(b"bob", "acc_bob");
563 let members = vec![
564 MemberPublicKey {
565 account_id: "acc_alice".into(),
566 public_hex: hex_of(&x25519_public(&alice)),
567 },
568 MemberPublicKey {
569 account_id: "acc_bob".into(),
570 public_hex: hex_of(&x25519_public(&bob)),
571 },
572 MemberPublicKey {
573 account_id: "acc_broken".into(),
574 public_hex: "not-hex".into(),
575 },
576 MemberPublicKey {
577 // Hex-valid but LOW-ORDER (all zeros) — parses, then wrap_org_key's
578 // contributory check rejects it. Must SKIP-with-reason, not abort
579 // the batch (else a poisoned pubkey bricks every grant round).
580 account_id: "acc_loworder".into(),
581 public_hex: "0".repeat(64),
582 },
583 ];
584
585 let k = [3u8; 32];
586 let report = provision_org_members(&mut dir, "acme", 1, &k, &members, "acc_granter", &g);
587 assert_eq!(
588 report.granted,
589 vec!["acc_alice", "acc_bob"],
590 "well-formed granted"
591 );
592 // Both the malformed-hex AND the low-order member skip-with-reason — neither
593 // aborts the batch, so the two good members are still granted.
594 let skipped_ids: Vec<&str> = report.skipped.iter().map(|(id, _)| id.as_str()).collect();
595 assert_eq!(skipped_ids, vec!["acc_broken", "acc_loworder"]);
596 assert!(report.skipped.iter().all(|(_, why)| !why.is_empty()));
597
598 // Both well-formed members resolve the granted key; the broken one got none.
599 let a = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
600 assert_eq!(*a[&1], k);
601 let broken = x25519_id(b"broken", "acc_broken");
602 assert!(
603 resolve_all_org_roots(&dir, "acme", &broken, "acc_broken", &trusted)
604 .unwrap()
605 .is_empty()
606 );
607 }
608
609 #[test]
610 fn generate_org_key_is_independent_across_draws() {
611 // Two draws must differ (CSPRNG). This is the crude stand-in for
612 // "N+1 not derivable from N" — the API simply offers no derive-from-old path.
613 assert_ne!(*generate_org_key(), *generate_org_key());
614 }
615
616 #[test]
617 fn resolve_skips_untrusted_higher_epoch_but_keeps_real_one() {
618 // A bogus higher-epoch wrap from an UNTRUSTED signer must not be adopted;
619 // the genuine epoch still resolves (substitution → no; DoS → possible/named).
620 let mut dir = InMemoryOrgKeyDirectory::new();
621 let g = granter();
622 let mallory = ed25519_id(b"mallory", "acc_mallory");
623 let alice = x25519_id(b"alice", "acc_alice");
624 let trusted = [ed25519_verifying(&g)];
625
626 let real = wrap_org_key(
627 &[7u8; 32],
628 "acme",
629 1,
630 "acc_alice",
631 &x25519_public(&alice),
632 "acc_granter",
633 &g,
634 )
635 .unwrap();
636 dir.publish_wrapped(&real).unwrap();
637 // Mallory publishes a "newer" epoch-9 wrap of a bogus key for Alice.
638 let bogus = wrap_org_key(
639 &[9u8; 32],
640 "acme",
641 9,
642 "acc_alice",
643 &x25519_public(&alice),
644 "acc_mallory",
645 &mallory,
646 )
647 .unwrap();
648 dir.publish_wrapped(&bogus).unwrap();
649
650 let roots = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
651 assert_eq!(roots.keys().copied().collect::<Vec<_>>(), vec![1]);
652 assert_eq!(*roots[&1], [7u8; 32]);
653 assert!(
654 !roots.contains_key(&9),
655 "untrusted higher epoch is skipped, not adopted"
656 );
657 }
658
659 #[test]
660 fn resolve_all_wrong_org_wrap_is_skipped() {
661 let mut dir = InMemoryOrgKeyDirectory::new();
662 let g = granter();
663 let alice = x25519_id(b"alice", "acc_alice");
664 let trusted = [ed25519_verifying(&g)];
665 // A trusted granter's wrap, but for "other" — must not be adopted as acme's.
666 let w = wrap_org_key(
667 &[5u8; 32],
668 "other",
669 1,
670 "acc_alice",
671 &x25519_public(&alice),
672 "acc_granter",
673 &g,
674 )
675 .unwrap();
676 dir.publish_wrapped(&w).unwrap();
677 let roots = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
678 assert!(roots.is_empty(), "wrong-org wrap skipped");
679 }
680
681 // ---- rotation floor sign / verify ----
682
683 #[test]
684 fn floor_signed_by_trusted_granter_verifies() {
685 let g = granter();
686 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
687 assert_eq!(
688 verify_rotation_floor(&floor, &[ed25519_verifying(&g)]).unwrap(),
689 2
690 );
691 }
692
693 #[test]
694 fn floor_from_untrusted_signer_is_rejected() {
695 let mallory = ed25519_id(b"mallory", "acc_mallory");
696 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &mallory);
697 // Verified against the granter's key, not Mallory's → Untrusted.
698 assert!(matches!(
699 verify_rotation_floor(&floor, &[ed25519_verifying(&granter())]),
700 Err(RotationError::Untrusted)
701 ));
702 }
703
704 #[test]
705 fn non_monotonic_floor_is_rejected_even_if_signed() {
706 let g = granter();
707 // floor_epoch == prev_floor_epoch → not strictly newer → rejected.
708 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 2, &g);
709 assert!(matches!(
710 verify_rotation_floor(&floor, &[ed25519_verifying(&g)]),
711 Err(RotationError::NonMonotonic)
712 ));
713 }
714
715 #[test]
716 fn tampered_floor_field_fails_verification() {
717 let g = granter();
718 let mut floor = sign_rotation_floor("acme", 5, hlc(100, 0), 1, &g);
719 // Flip the epoch after signing → transcript mismatch → Untrusted.
720 floor.floor_epoch = 6;
721 assert!(matches!(
722 verify_rotation_floor(&floor, &[ed25519_verifying(&g)]),
723 Err(RotationError::Untrusted)
724 ));
725 }
726
727 #[test]
728 fn floor_with_wrong_algorithm_tag_is_rejected() {
729 let g = granter();
730 let mut floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
731 // A foreign/garbage tag is rejected before the signature is even checked
732 // (mirrors unwrap_org_key's car_wrap gate).
733 floor.car_floor = "org-key-wrap/v2".into();
734 assert!(matches!(
735 verify_rotation_floor(&floor, &[ed25519_verifying(&g)]),
736 Err(RotationError::WrongAlgorithm(_))
737 ));
738 }
739
740 #[test]
741 fn older_valid_floor_still_verifies_anti_rollback_is_the_callers_job() {
742 // THREAT-MODEL CASE (documents C1): verify_rotation_floor is stateless, so
743 // an OLDER but legitimately-signed floor (2,1) still verifies Ok(2) even
744 // after the org advanced to (5,4). This function does NOT prevent that — the
745 // caller must persist the max accepted floor_epoch and reject <= it. If this
746 // ever starts erroring, verify gained rollback state — revisit the doc.
747 let g = granter();
748 let trusted = [ed25519_verifying(&g)];
749 let advanced = sign_rotation_floor("acme", 5, hlc(500, 0), 4, &g);
750 let stale = sign_rotation_floor("acme", 2, hlc(200, 0), 1, &g);
751 assert_eq!(verify_rotation_floor(&advanced, &trusted).unwrap(), 5);
752 assert_eq!(
753 verify_rotation_floor(&stale, &trusted).unwrap(),
754 2,
755 "stale-but-valid floor verifies; rollback defense is external"
756 );
757 }
758
759 // ---- is_authoritative freshness predicate ----
760
761 #[test]
762 fn authoritative_accepts_new_epoch_regardless_of_time() {
763 let g = granter();
764 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
765 // Post-cut epoch-2 op, even far in the future → authoritative (unforgeable).
766 assert!(is_authoritative(Some(2), &hlc(9_999, 0), &floor));
767 assert!(is_authoritative(Some(3), &hlc(9_999, 0), &floor));
768 }
769
770 #[test]
771 fn authoritative_keeps_precut_history_readable() {
772 let g = granter();
773 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
774 // Old epoch-1 op authored BEFORE the cut → still authoritative (non-lossy).
775 assert!(is_authoritative(Some(1), &hlc(50, 0), &floor));
776 // Exactly at the cut boundary counts as pre-cut.
777 assert!(is_authoritative(Some(1), &hlc(100, 0), &floor));
778 }
779
780 #[test]
781 fn authoritative_rejects_postcut_stale_epoch_write() {
782 let g = granter();
783 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
784 // A removed member writing epoch-1 AFTER the cut → NOT authoritative.
785 assert!(!is_authoritative(Some(1), &hlc(101, 0), &floor));
786 // A personal-shaped (no-kid) op after the cut is likewise non-authoritative.
787 assert!(!is_authoritative(None, &hlc(101, 0), &floor));
788 }
789
790 #[test]
791 fn malicious_backdate_is_a_named_limitation_not_a_fix() {
792 // THREAT-MODEL CASE (documents the gap, does not assert it closed): a
793 // removed member holding K_org@1 can BACKDATE an op to hlc <= rotation_hlc
794 // and is_authoritative() will accept it. This is tolerated ONLY because such
795 // an op is confined to epoch 1 (already readable to them) and LOSES LWW to
796 // any genuine epoch-2 op. Fully refusing it needs per-op author signatures /
797 // admission authz (audit-deferred). If this assertion ever flips, the gate
798 // was strengthened — revisit the module audit note.
799 let g = granter();
800 let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
801 let backdated = hlc(50, 0); // attacker chooses a pre-cut timestamp
802 assert!(
803 is_authoritative(Some(1), &backdated, &floor),
804 "backdated forgery slips the HLC half — known, audit-deferred limitation"
805 );
806 }
807}