Skip to main content

cheers_verify/
snapshot.rs

1//! [`SnapshotVerifier`] — the edge check for a membership snapshot (R732-F4;
2//! noisetable W235 §0.1, §5).
3//!
4//! A [`SetSnapshot`] has no expiry. The edge accepts one when:
5//!
6//! 1. it is signed for the trusted issuer ([`IssuerTrust`]: a pinned key, or a
7//!    JWKS **issuer**-role key) under the snapshot's own implicit assertion,
8//!    so no access token, standing binding or revocation set verifies here and
9//!    no snapshot verifies as one of them;
10//! 2. no snapshot of the same resource with a higher epoch has been seen
11//!    ([`SnapshotLedger`]).
12//!
13//! It then **admits** each member entry unless the revocation set masks it:
14//! an entry is dropped iff, for every `(kind, id)` in its `via`,
15//! [`RevocationReader::is_membership_revoked`]`(key, kind, id, user, epoch)`
16//! holds, `key` being the snapshot's own [`RevocationKey`] for `(kind, id)`
17//! (`cheers_core::snapshot`, "The revocation mask"). A user removed from the
18//! parent org but still a direct member of the child stays admitted. An entry
19//! with an empty `via` — which cheers never mints — is dropped, since nothing
20//! could ever revoke it.
21//!
22//! `refresh_after` is advisory: it sets [`VerifiedSnapshot::lease`] and
23//! never refuses. The clock is used for nothing else.
24//!
25//! # Supersession
26//!
27//! The epoch is the issuer's store-wide ownership version, so an equal epoch
28//! means equal members and a higher one is newer. The ledger keeps the highest
29//! epoch seen per `(kind, id)` from **any** genuine snapshot — presented, or
30//! heard by gossip ([`SnapshotVerifier::adopt`]) — and never lowers it.
31//!
32//! # Offline restart
33//!
34//! As for standing bindings (`crate::standing`, "Offline restart"): persist
35//! [`IssuerTrust::export`], [`SnapshotLedger::export`] and
36//! `ReplicatedRevocations::export` beside the held snapshot tokens and rebuild
37//! from them at boot. The key rotation rule there covers snapshots too.
38
39use std::collections::{BTreeMap, HashMap};
40use std::sync::{PoisonError, RwLock};
41
42use serde::{Deserialize, Serialize};
43
44use cheers_core::{LeaseError, LeaseState, PrincipalId, SetSnapshot, SnapshotMember, StoreError};
45
46use crate::artifact::IssuerTrust;
47use crate::key_set::VerifyError;
48use crate::revocation::RevocationReader;
49use crate::standing::ForeignLedger;
50
51/// Why [`SnapshotVerifier::verify_snapshot_at`] refused a snapshot. A masked
52/// member is not a refusal — it is absent from [`VerifiedSnapshot::admitted`].
53#[derive(Debug, thiserror::Error)]
54pub enum SnapshotError {
55    /// Not a membership snapshot signed for the trusted issuer: bad signature,
56    /// another artifact or an access token offered as one, a non-issuer-role
57    /// key, a foreign issuer.
58    #[error("membership snapshot refused: {0}")]
59    Verify(#[from] VerifyError),
60
61    /// A snapshot of the same resource with a higher epoch exists.
62    #[error("snapshot of {kind}/{id} is superseded: epoch {offered} is below {newest}")]
63    Superseded { kind: String, id: String, newest: u64, offered: u64 },
64
65    /// A member's `via` names a resource whose revocation key the snapshot
66    /// does not carry, so its entries could not be tested. Refused rather than
67    /// admitted: a missing key would make revocation fail open (R732-T10).
68    #[error("snapshot carries no revocation key for {kind}/{id}")]
69    MissingRevocationKey { kind: String, id: String },
70
71    /// Signed, but its lease breaks the invariant (`cheers_core::Lease`).
72    #[error("membership snapshot refused: {0}")]
73    Lease(#[from] LeaseError),
74
75    /// The revocation reader failed.
76    #[error(transparent)]
77    Store(#[from] StoreError),
78}
79
80/// An accepted snapshot and the members it still admits.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct VerifiedSnapshot {
83    /// Exactly as signed.
84    pub snapshot: SetSnapshot,
85    /// `snapshot.members` minus every entry the revocation set masks, in the
86    /// same order.
87    pub admitted: Vec<SnapshotMember>,
88    /// The snapshot's lease at the verification instant: on `Warning`, fetch a
89    /// fresher snapshot when the issuer is reachable. Advisory — the snapshot
90    /// was accepted either way (it carries no `exp` today).
91    pub lease: LeaseState,
92}
93
94impl VerifiedSnapshot {
95    /// `true` if an admitted entry gives `principal` exactly `relation`.
96    /// Members are closure-expanded, so "is a member" is
97    /// `holds(principal, "member")`. Any principal kind, a `Key` included.
98    pub fn holds(&self, principal: &PrincipalId, relation: &str) -> bool {
99        self.admitted.iter().any(|m| &m.principal == principal && m.relation == relation)
100    }
101
102    /// Where `principal`'s `relation` stands: `None` for a standing member
103    /// (or none at all), the lease state for a leased guest, so an edge can
104    /// surface `Warning` on reconciled guests (R734-F4).
105    pub fn member_lease(&self, principal: &PrincipalId, relation: &str, now: i64) -> Option<LeaseState> {
106        self.admitted
107            .iter()
108            .find(|m| &m.principal == principal && m.relation == relation)
109            .and_then(|m| m.lease)
110            .map(|l| l.state_at(now))
111    }
112
113    /// The admission policy this snapshot carries; `None` reads as closed.
114    /// Feed it to [`cheers_core::evaluate_admit`] for every held `Admit`:
115    /// the policy held now, not the one an `Admit` was minted under.
116    pub fn admission_policy(&self) -> Option<&cheers_core::AdmissionPolicy> {
117        self.snapshot.policy.as_ref()
118    }
119}
120
121/// A persisted [`SnapshotLedger`]: the highest epoch seen per resource, as
122/// `kind -> id -> epoch`.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct SnapshotLedgerDoc {
125    pub issuer: String,
126    pub newest: BTreeMap<String, BTreeMap<String, u64>>,
127}
128
129/// The highest snapshot epoch seen per `(kind, id)`, for one issuer.
130///
131/// Like [`BindingLedger`](crate::BindingLedger) it holds only high-water
132/// marks, needs no key to restore, and only ever rises: adopting a lower epoch
133/// after a higher one, or importing an older export, cannot un-supersede.
134#[derive(Debug)]
135pub struct SnapshotLedger {
136    issuer: String,
137    newest: RwLock<HashMap<(String, String), u64>>,
138}
139
140impl SnapshotLedger {
141    pub fn new(issuer: impl Into<String>) -> Self {
142        Self {
143            issuer: issuer.into(),
144            newest: RwLock::new(HashMap::new()),
145        }
146    }
147
148    pub fn issuer(&self) -> &str {
149        &self.issuer
150    }
151
152    /// The highest epoch seen for `(kind, id)`.
153    pub fn newest(&self, kind: &str, id: &str) -> Option<u64> {
154        self.newest
155            .read()
156            .unwrap_or_else(PoisonError::into_inner)
157            .get(&(kind.to_owned(), id.to_owned()))
158            .copied()
159    }
160
161    /// Record that a genuine snapshot of `(kind, id)` at `epoch` exists and
162    /// return the resource's highest epoch afterwards. Never lowers it.
163    pub fn observe(&self, kind: &str, id: &str, epoch: u64) -> u64 {
164        let mut newest = self.newest.write().unwrap_or_else(PoisonError::into_inner);
165        let held = newest.entry((kind.to_owned(), id.to_owned())).or_insert(epoch);
166        *held = (*held).max(epoch);
167        *held
168    }
169
170    /// Every high-water mark, for an edge to persist.
171    pub fn export(&self) -> SnapshotLedgerDoc {
172        let mut out: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
173        for ((kind, id), epoch) in self.newest.read().unwrap_or_else(PoisonError::into_inner).iter() {
174            out.entry(kind.clone()).or_default().insert(id.clone(), *epoch);
175        }
176        SnapshotLedgerDoc {
177            issuer: self.issuer.clone(),
178            newest: out,
179        }
180    }
181
182    /// Merge a persisted ledger in, keeping the higher epoch per resource.
183    pub fn import(&self, doc: &SnapshotLedgerDoc) -> Result<(), ForeignLedger> {
184        if doc.issuer != self.issuer {
185            return Err(ForeignLedger {
186                expected: self.issuer.clone(),
187                got: doc.issuer.clone(),
188            });
189        }
190        for (kind, ids) in &doc.newest {
191            for (id, epoch) in ids {
192                self.observe(kind, id, *epoch);
193            }
194        }
195        Ok(())
196    }
197}
198
199/// The edge facade for membership snapshots: trust, revocation replica and
200/// supersession ledger for one issuer. Holds no minter.
201pub struct SnapshotVerifier<Rd> {
202    trust: IssuerTrust,
203    revoked: Rd,
204    ledger: SnapshotLedger,
205}
206
207impl<Rd> std::fmt::Debug for SnapshotVerifier<Rd> {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        f.debug_struct("SnapshotVerifier")
210            .field("trust", &self.trust)
211            .field("ledger", &self.ledger)
212            .finish_non_exhaustive()
213    }
214}
215
216impl<Rd: RevocationReader> SnapshotVerifier<Rd> {
217    /// A verifier with an empty ledger; [`import`](SnapshotLedger::import) a
218    /// persisted one through [`ledger`](Self::ledger) after a restart.
219    pub fn new(trust: IssuerTrust, revoked: Rd) -> Self {
220        let ledger = SnapshotLedger::new(trust.issuer());
221        Self { trust, revoked, ledger }
222    }
223
224    pub fn trust(&self) -> &IssuerTrust {
225        &self.trust
226    }
227
228    pub fn revocations(&self) -> &Rd {
229        &self.revoked
230    }
231
232    pub fn ledger(&self) -> &SnapshotLedger {
233        &self.ledger
234    }
235
236    /// Verify a snapshot that arrived by gossip (or the edge's own persisted
237    /// one at boot) and record its epoch, so it supersedes older snapshots of
238    /// its resource. Checks the signature only.
239    pub async fn adopt(&self, token: &str) -> Result<SetSnapshot, VerifyError> {
240        let snapshot: SetSnapshot = self.trust.verify(token).await?;
241        self.ledger.observe(&snapshot.kind, &snapshot.id, snapshot.epoch);
242        Ok(snapshot)
243    }
244
245    /// Accept `token` as a membership snapshot and work out who it still
246    /// admits.
247    ///
248    /// In order: signature (recording the epoch in the ledger), supersession,
249    /// then one revocation read per member `via` — the only I/O. `now`
250    /// decides [`VerifiedSnapshot::lease`] and nothing else: there is no
251    /// expiry.
252    pub async fn verify_snapshot_at(&self, token: &str, now: i64) -> Result<VerifiedSnapshot, SnapshotError> {
253        let snapshot: SetSnapshot = self.trust.verify(token).await?;
254        snapshot.lease.validate(snapshot.iat)?;
255        let newest = self.ledger.observe(&snapshot.kind, &snapshot.id, snapshot.epoch);
256        if newest > snapshot.epoch {
257            return Err(SnapshotError::Superseded {
258                kind: snapshot.kind,
259                id: snapshot.id,
260                newest,
261                offered: snapshot.epoch,
262            });
263        }
264        let mut admitted = Vec::with_capacity(snapshot.members.len());
265        for member in &snapshot.members {
266            // A leased guest past its exp is gone, whatever snapshot still
267            // lists it (R734-F4).
268            if member.lease.is_some_and(|l| l.state_at(now) == LeaseState::Expired) {
269                continue;
270            }
271            if !self.is_masked(&snapshot, member).await? {
272                admitted.push(member.clone());
273            }
274        }
275        let lease = snapshot.lease.state_at(now);
276        Ok(VerifiedSnapshot { snapshot, admitted, lease })
277    }
278
279    /// The mask: every resource the entry derives from is revoked for its
280    /// user above `epoch`. Stops at the first `via` still standing.
281    async fn is_masked(&self, snapshot: &SetSnapshot, member: &SnapshotMember) -> Result<bool, SnapshotError> {
282        for (kind, id) in &member.via {
283            let key = snapshot.revocation_key(kind, id).ok_or_else(|| SnapshotError::MissingRevocationKey {
284                kind: kind.clone(),
285                id: id.clone(),
286            })?;
287            if !self.revoked.is_membership_revoked(key, kind, id, &member.principal, snapshot.epoch).await? {
288                return Ok(false);
289            }
290        }
291        Ok(true)
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use cheers_core::{Lease, UserId};
299    use crate::jwks::{JwkKey, JwksDoc, KeySet};
300    use crate::key_set::KeySetVerifier;
301    use crate::public_verifier::PasetoV4PublicVerifier;
302    use crate::revocation::{revocation_entry, test_revocation_key, ReplicatedRevocations};
303    use crate::standing::{StandingError, StandingVerifier};
304    use cheers_core::{
305        Claims, DeviceBinding, DeviceId, KeyRole, McpClaims, PeerKey, PrincipalId, ResourceRevocationKey, RevocationSet, Revoked,
306        SignedArtifact, StandingBinding, TokenVerifier,
307    };
308    use pasetors::keys::{AsymmetricKeyPair, AsymmetricSecretKey, Generate};
309    use pasetors::version4::{PublicToken, V4};
310    use std::sync::Arc;
311
312    const ISS: &str = "https://cheers.test";
313    const IAT: i64 = 1_000;
314    /// Ten years on: far past any access-token window.
315    const YEARS_LATER: i64 = IAT + 10 * 365 * 24 * 60 * 60;
316
317    fn kp() -> AsymmetricKeyPair<V4> {
318        AsymmetricKeyPair::<V4>::generate().unwrap()
319    }
320
321    fn pub_bytes(k: &AsymmetricKeyPair<V4>) -> [u8; 32] {
322        k.public.as_bytes().try_into().unwrap()
323    }
324
325    fn sign_with(secret: &AsymmetricSecretKey<V4>, kid: &str, payload: &impl Serialize, ia: Option<&[u8]>) -> String {
326        let body = serde_json::to_vec(payload).unwrap();
327        let footer = format!(r#"{{"kid":"{kid}"}}"#);
328        PublicToken::sign(secret, &body, Some(footer.as_bytes()), ia).unwrap()
329    }
330
331    fn member(user: &str, relation: &str, via: &[&str]) -> SnapshotMember {
332        SnapshotMember {
333            principal: PrincipalId::user(user),
334            relation: relation.into(),
335            via: via.iter().map(|i| ("namespace".to_owned(), (*i).to_owned())).collect(),
336            lease: None,
337        }
338    }
339
340    /// `ed` at `epoch`: alice is a guest only through the museum; bob is a
341    /// direct member and also a guest through the museum.
342    fn ed(epoch: u64) -> SetSnapshot {
343        SetSnapshot::new(
344            ISS,
345            "namespace",
346            "ed",
347            epoch,
348            vec![
349                member("alice", "guest", &["museum"]),
350                member("bob", "guest", &["ed", "museum"]),
351                member("bob", "member", &["ed"]),
352            ],
353            ["ed", "museum"]
354                .map(|id| ResourceRevocationKey {
355                    kind: "namespace".into(),
356                    id: id.into(),
357                    key: test_revocation_key("namespace", id),
358                })
359                .to_vec(),
360            IAT,
361            Lease::new(IAT, IAT + 7 * 24 * 60 * 60, None).unwrap(),
362        )
363    }
364
365    struct Rig {
366        issuer: AsymmetricKeyPair<V4>,
367        assertion: AsymmetricKeyPair<V4>,
368        doc: JwksDoc,
369        replica: Arc<ReplicatedRevocations>,
370        edge: SnapshotVerifier<Arc<ReplicatedRevocations>>,
371    }
372
373    impl Rig {
374        fn sign(&self, s: &SetSnapshot) -> String {
375            sign_with(&self.issuer.secret, "iss-1", s, Some(SetSnapshot::IMPLICIT_ASSERTION))
376        }
377
378        fn revoke(&self, epoch: u64, revoked: Vec<Revoked>) {
379            let entries = revoked.iter().map(|e| revocation_entry(e, test_revocation_key)).collect();
380            let set = RevocationSet::new(ISS, epoch, entries);
381            let token = sign_with(&self.issuer.secret, "iss-1", &set, Some(RevocationSet::IMPLICIT_ASSERTION));
382            pollster::block_on(self.replica.adopt(&token)).unwrap();
383        }
384
385        fn verify(&self, token: &str, now: i64) -> Result<VerifiedSnapshot, SnapshotError> {
386            pollster::block_on(self.edge.verify_snapshot_at(token, now))
387        }
388
389        fn admitted(&self, token: &str) -> Vec<String> {
390            let ok = self.verify(token, IAT).unwrap();
391            ok.admitted.iter().map(|m| format!("{}#{}", m.principal.id, m.relation)).collect()
392        }
393    }
394
395    fn rig() -> Rig {
396        let (issuer, assertion) = (kp(), kp());
397        let doc = JwksDoc {
398            keys: vec![
399                JwkKey::ed25519("iss-1", &pub_bytes(&issuer), ISS, KeyRole::Issuer),
400                JwkKey::ed25519("asr-1", &pub_bytes(&assertion), "svc:issues", KeyRole::Assertion),
401            ],
402        };
403        let keys = KeySetVerifier::from_key_set(KeySet::from_doc(doc.clone()).unwrap());
404        let trust = IssuerTrust::key_set(ISS, keys);
405        let replica = Arc::new(ReplicatedRevocations::new(trust.clone()));
406        Rig {
407            edge: SnapshotVerifier::new(trust, replica.clone()),
408            replica,
409            issuer,
410            assertion,
411            doc,
412        }
413    }
414
415    #[test]
416    fn a_lease_past_its_midpoint_is_refused() {
417        let r = rig();
418        let mut v = serde_json::to_value(ed(10)).unwrap();
419        v["refresh_after"] = (IAT + 600).into();
420        v["exp"] = (IAT + 1_000).into();
421        let forged: SetSnapshot = serde_json::from_value(v).unwrap();
422        let err = r.verify(&r.sign(&forged), IAT).unwrap_err();
423        assert!(matches!(err, SnapshotError::Lease(_)), "got {err:?}");
424    }
425
426    #[test]
427    fn a_snapshot_verifies_years_after_iat_and_past_refresh_after() {
428        let r = rig();
429        let s = ed(10);
430        let token = r.sign(&s);
431        let ra = s.lease.refresh_after();
432        let ok = r.verify(&token, ra - 1).unwrap();
433        assert_eq!(ok.lease, LeaseState::Current);
434        assert_eq!(ok.snapshot, s);
435        assert_eq!(ok.admitted, s.members);
436        assert_eq!(r.verify(&token, ra).unwrap().lease, LeaseState::Warning { exp: None });
437        let later = r.verify(&token, YEARS_LATER).unwrap();
438        assert_eq!(later.lease, LeaseState::Warning { exp: None });
439        assert!(later.holds(&PrincipalId::user("bob"), "member"));
440        assert!(!later.holds(&PrincipalId::user("alice"), "member"));
441    }
442
443    #[test]
444    fn a_key_principal_is_held_and_masked_like_a_user() {
445        let r = rig();
446        let k = PrincipalId::from_public_key(&[5; 32]);
447        let mut s = ed(10);
448        s.members.push(SnapshotMember { principal: k.clone(), relation: "guest".into(), via: vec![("namespace".into(), "ed".into())], lease: None });
449        let s = SetSnapshot::new(ISS, "namespace", "ed", 10, s.members, s.revocation_keys, s.iat, s.lease);
450        let token = r.sign(&s);
451        let ok = r.verify(&token, IAT).unwrap();
452        assert!(ok.holds(&k, "guest"));
453        // The same id string as a user holds nothing.
454        assert!(!ok.holds(&PrincipalId::user(k.id.clone()), "guest"));
455        // A user revocation under the key's id string does not mask the key;
456        // the key's own revocation does.
457        r.revoke(1, vec![Revoked::membership("namespace", "ed", PrincipalId::user(k.id.clone()), 20)]);
458        assert!(r.verify(&token, IAT).unwrap().holds(&k, "guest"));
459        r.revoke(2, vec![Revoked::membership("namespace", "ed", k.clone(), 20)]);
460        assert!(!r.verify(&token, IAT).unwrap().holds(&k, "guest"));
461    }
462
463    #[test]
464    fn a_higher_epoch_supersedes_and_nothing_lower_unsupersedes() {
465        let r = rig();
466        let old = r.sign(&ed(10));
467        let new = r.sign(&ed(12));
468        r.verify(&old, IAT).unwrap();
469        // Same epoch again: unchanged, still accepted.
470        r.verify(&old, IAT).unwrap();
471        r.verify(&new, IAT).unwrap();
472        match r.verify(&old, IAT) {
473            Err(SnapshotError::Superseded { kind, id, newest: 12, offered: 10 }) => {
474                assert_eq!((kind.as_str(), id.as_str()), ("namespace", "ed"));
475            }
476            other => panic!("expected Superseded, got {other:?}"),
477        }
478        // Gossip alone supersedes; a lower epoch adopted later lowers nothing.
479        pollster::block_on(r.edge.adopt(&r.sign(&ed(15)))).unwrap();
480        pollster::block_on(r.edge.adopt(&r.sign(&ed(11)))).unwrap();
481        assert_eq!(r.edge.ledger().newest("namespace", "ed"), Some(15));
482        assert!(matches!(r.verify(&new, IAT), Err(SnapshotError::Superseded { newest: 15, .. })));
483        // Neither does a stale ledger import.
484        let stale = SnapshotLedgerDoc {
485            issuer: ISS.into(),
486            newest: [("namespace".to_owned(), [("ed".to_owned(), 3)].into_iter().collect())].into_iter().collect(),
487        };
488        r.edge.ledger().import(&stale).unwrap();
489        assert_eq!(r.edge.ledger().newest("namespace", "ed"), Some(15));
490        // Other resources are untouched.
491        let mut other = ed(1);
492        other.id = "band".into();
493        r.verify(&r.sign(&other), IAT).unwrap();
494    }
495
496    #[test]
497    fn an_older_epoch_with_a_looser_policy_is_refused() {
498        use cheers_core::{evaluate_admit, AdmissionMode, AdmissionPath, AdmissionPolicy, AdmitFacts, Confirmation};
499        let r = rig();
500        let open = r.sign(&ed(10).with_policy(Some(AdmissionPolicy::new(AdmissionMode::Open, "guest"))));
501        let closed = r.sign(&ed(12).with_policy(Some(AdmissionPolicy::new(AdmissionMode::Closed, "guest"))));
502        let admit = AdmitFacts { relation: "guest", path: AdmissionPath::Knock, confirmation: Some(Confirmation::Accept), requester_is_user: false };
503        let held = r.verify(&open, IAT).unwrap();
504        assert!(evaluate_admit(held.admission_policy(), &admit, IAT, None).is_ok());
505        let held = r.verify(&closed, IAT).unwrap();
506        assert!(evaluate_admit(held.admission_policy(), &admit, IAT, None).is_err());
507        // Replaying the looser, older snapshot does not reopen the dial.
508        assert!(matches!(r.verify(&open, IAT), Err(SnapshotError::Superseded { newest: 12, offered: 10, .. })));
509        // A snapshot with no policy still verifies, and reads as closed.
510        let bare = r.verify(&r.sign(&ed(13)), IAT).unwrap();
511        assert_eq!(bare.admission_policy(), None);
512        assert!(evaluate_admit(bare.admission_policy(), &admit, IAT, None).is_err());
513    }
514
515    #[test]
516    fn a_leased_guest_warns_then_drops_at_exp() {
517        let r = rig();
518        let k = PrincipalId::from_public_key(&[6; 32]);
519        let mut s = ed(10);
520        let lease = Lease::new(IAT, IAT + 100, Some(IAT + 300)).unwrap();
521        s.members.push(SnapshotMember { principal: k.clone(), relation: "guest".into(), via: vec![("namespace".into(), "ed".into())], lease: Some(lease) });
522        let s = SetSnapshot::new(ISS, "namespace", "ed", 10, s.members, s.revocation_keys, s.iat, s.lease);
523        let token = r.sign(&s);
524        let ok = r.verify(&token, IAT).unwrap();
525        assert_eq!(ok.member_lease(&k, "guest", IAT), Some(LeaseState::Current));
526        assert_eq!(ok.member_lease(&k, "guest", IAT + 100), Some(LeaseState::Warning { exp: Some(IAT + 300) }));
527        assert_eq!(ok.member_lease(&PrincipalId::user("bob"), "member", IAT), None);
528        let late = r.verify(&token, IAT + 300).unwrap();
529        assert!(!late.holds(&k, "guest"));
530        assert!(late.holds(&PrincipalId::user("bob"), "member"));
531    }
532
533    #[test]
534    fn a_membership_revoked_at_v_masks_below_v_only() {
535        let r = rig();
536        // bob loses his last direct tuple on ed at version 20.
537        r.revoke(1, vec![Revoked::membership("namespace", "ed", PrincipalId::user("bob"), 20)]);
538        // Below 20: his entry derived only from ed is dropped; his guest entry
539        // still stands through the museum.
540        assert_eq!(r.admitted(&r.sign(&ed(19))), ["alice#guest", "bob#guest"]);
541        // At or above 20 the entry does not apply (a snapshot that new no
542        // longer lists him through ed, or lists a re-added bob).
543        assert_eq!(r.admitted(&r.sign(&ed(20))), ["alice#guest", "bob#guest", "bob#member"]);
544        assert_eq!(r.admitted(&r.sign(&ed(30))), ["alice#guest", "bob#guest", "bob#member"]);
545    }
546
547    #[test]
548    fn an_entry_is_dropped_only_when_every_via_is_revoked() {
549        let r = rig();
550        // Both leave the museum at 20.
551        r.revoke(
552            1,
553            vec![
554                Revoked::membership("namespace", "museum", PrincipalId::user("alice"), 20),
555                Revoked::membership("namespace", "museum", PrincipalId::user("bob"), 20),
556            ],
557        );
558        let held = r.sign(&ed(10));
559        // alice was a guest only through the museum: dropped. bob is still a
560        // direct member of ed: both his entries stand.
561        assert_eq!(r.admitted(&held), ["bob#guest", "bob#member"]);
562        // Then bob leaves ed too: now every via of his guest entry is gone.
563        r.revoke(2, vec![
564            Revoked::membership("namespace", "museum", PrincipalId::user("alice"), 20),
565            Revoked::membership("namespace", "museum", PrincipalId::user("bob"), 20),
566            Revoked::membership("namespace", "ed", PrincipalId::user("bob"), 25),
567        ]);
568        assert!(r.admitted(&held).is_empty());
569        // Other users' and other resources' entries never mask.
570        let r = rig();
571        r.revoke(1, vec![
572            Revoked::membership("namespace", "museum", PrincipalId::user("carol"), 99),
573            Revoked::membership("namespace", "band", PrincipalId::user("alice"), 99),
574        ]);
575        assert_eq!(r.admitted(&r.sign(&ed(10))).len(), 3);
576        // An entry with no via cannot be revoked by anything, so it is dropped.
577        let mut s = ed(10);
578        s.members.push(member("zed", "guest", &[]));
579        assert!(!r.admitted(&r.sign(&s)).contains(&"zed#guest".to_owned()));
580    }
581
582    #[test]
583    fn a_via_without_its_revocation_key_refuses_the_snapshot() {
584        let r = rig();
585        let mut s = ed(10);
586        s.members.push(member("zed", "guest", &["band"]));
587        match r.verify(&r.sign(&s), IAT) {
588            Err(SnapshotError::MissingRevocationKey { kind, id }) => assert_eq!((kind.as_str(), id.as_str()), ("namespace", "band")),
589            other => panic!("expected MissingRevocationKey, got {other:?}"),
590        }
591    }
592
593    #[test]
594    fn a_snapshot_is_refused_as_every_other_credential() {
595        let r = rig();
596        let token = r.sign(&ed(10));
597        // As an access token.
598        let keys = KeySetVerifier::from_key_set(KeySet::from_doc(r.doc.clone()).unwrap());
599        let as_value = pollster::block_on(keys.verify::<serde_json::Value>(&token, IAT, ISS, None));
600        assert!(matches!(as_value, Err(VerifyError::SignatureMismatch)));
601        let pinned = PasetoV4PublicVerifier::from_public_key(&pub_bytes(&r.issuer)).unwrap();
602        assert!(pinned.verify_mcp_at(&token, IAT, "iss-1").is_err());
603        assert!(pinned.verify_at(&token, IAT).is_err());
604        // As a standing binding.
605        let standing = StandingVerifier::new(r.edge.trust().clone(), r.replica.clone());
606        assert!(matches!(
607            pollster::block_on(standing.verify_standing_at(&token, &PeerKey::ed25519([1; 32]), IAT)),
608            Err(StandingError::Verify(VerifyError::SignatureMismatch))
609        ));
610        // As a revocation set.
611        assert!(pollster::block_on(r.replica.adopt(&token)).is_err());
612        assert_eq!(r.replica.epoch(), None);
613    }
614
615    #[test]
616    fn every_other_credential_is_refused_as_a_snapshot() {
617        let r = rig();
618        // The snapshot's own payload under the access-token (empty) assertion.
619        let unasserted = sign_with(&r.issuer.secret, "iss-1", &ed(10), None);
620        assert!(matches!(
621            r.verify(&unasserted, IAT),
622            Err(SnapshotError::Verify(VerifyError::SignatureMismatch))
623        ));
624        // ... and under the binding's and the revocation set's assertions.
625        for ia in [StandingBinding::IMPLICIT_ASSERTION, RevocationSet::IMPLICIT_ASSERTION] {
626            let cross = sign_with(&r.issuer.secret, "iss-1", &ed(10), Some(ia));
627            assert!(matches!(r.verify(&cross, IAT), Err(SnapshotError::Verify(VerifyError::SignatureMismatch))));
628        }
629        // A real binding, a real revocation set, an MCP token and a session token.
630        let binding = StandingBinding {
631            issuer: ISS.into(),
632            sub: UserId::new("alice"),
633            device: DeviceId::new("node:a"),
634            peer_key: PeerKey::ed25519([1; 32]),
635            seq: 5,
636            iat: IAT,
637            jti: "b".into(),
638            lease: Lease::new(IAT, IAT + 60, None).unwrap(),
639        };
640        let binding = sign_with(&r.issuer.secret, "iss-1", &binding, Some(StandingBinding::IMPLICIT_ASSERTION));
641        let set = RevocationSet::new(ISS, 3, vec![]);
642        let set = sign_with(&r.issuer.secret, "iss-1", &set, Some(RevocationSet::IMPLICIT_ASSERTION));
643        let mcp = McpClaims::new(ISS, "https://aud.test", PrincipalId::user("alice"), IAT, IAT + 900, "j", vec![]);
644        let mcp = sign_with(&r.issuer.secret, "iss-1", &mcp, None);
645        let claims = Claims::new(UserId::new("alice"), DeviceId::new("node:a"), DeviceBinding::LanPair, IAT, IAT + 900);
646        let session = sign_with(&r.issuer.secret, "iss-1", &serde_json::json!({ "cheers": claims }), None);
647        for token in [binding, set, mcp, session] {
648            assert!(matches!(r.verify(&token, IAT), Err(SnapshotError::Verify(_))));
649        }
650        // A non-issuer-role key, and a snapshot claiming a foreign issuer.
651        let by_assertion = sign_with(&r.assertion.secret, "asr-1", &ed(10), Some(SetSnapshot::IMPLICIT_ASSERTION));
652        assert!(matches!(
653            r.verify(&by_assertion, IAT),
654            Err(SnapshotError::Verify(VerifyError::KeyRoleRejected { .. }))
655        ));
656        let mut foreign = ed(10);
657        foreign.issuer = "https://elsewhere.test".into();
658        assert!(matches!(
659            r.verify(&r.sign(&foreign), IAT),
660            Err(SnapshotError::Verify(VerifyError::BadIssuer { .. }))
661        ));
662        assert_eq!(r.edge.ledger().newest("namespace", "ed"), None, "nothing refused reached the ledger");
663    }
664
665    #[test]
666    fn ledger_export_import_round_trips_and_refuses_a_foreign_issuer() {
667        let ledger = SnapshotLedger::new(ISS);
668        ledger.observe("namespace", "ed", 9);
669        ledger.observe("namespace", "band", 4);
670        ledger.observe("team", "t", 2);
671        let json = serde_json::to_string(&ledger.export()).unwrap();
672        let back: SnapshotLedgerDoc = serde_json::from_str(&json).unwrap();
673        assert_eq!(back, ledger.export());
674        let restored = SnapshotLedger::new(ISS);
675        restored.import(&back).unwrap();
676        assert_eq!(restored.export(), ledger.export());
677
678        let other = SnapshotLedger::new(ISS);
679        other.observe("namespace", "ed", 12);
680        other.observe("namespace", "band", 1);
681        restored.import(&other.export()).unwrap();
682        assert_eq!(restored.newest("namespace", "ed"), Some(12));
683        assert_eq!(restored.newest("namespace", "band"), Some(4));
684        assert!(restored.import(&SnapshotLedger::new("https://elsewhere.test").export()).is_err());
685    }
686}