Skip to main content

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}