Skip to main content

cheers_verify/
revocation.rs

1//! The **read** side of the revocation split.
2//!
3//! Revocation has two physically distinct sides, and edge-verifiable auth (R019)
4//! depends on keeping them apart. This crate holds the edge half:
5//!
6//! - [`RevocationReader`] — the **hot path**, run at the edge on every request.
7//!   Point membership checks — a `jti`, a device, a `(resource, user)`
8//!   membership — against a locally-replicated, read-mostly set (CF KV / a
9//!   gossip replica).
10//! - [`ReplicatedRevocations`] — the offline replica (R732-F6): it adopts
11//!   issuer-signed [`RevocationSet`]s, keeps the highest epoch it has seen, and
12//!   answers [`RevocationReader`] from it.
13//! - The **cold path** writer (`RevocationWriter`) lives in `cheers-server` —
14//!   keeping it out of this crate means a verify-only edge consumer can check
15//!   revocation without holding the power to revoke anyone else's sessions, the
16//!   same capability-by-type discipline as the `TokenVerifier` / `TokenMinter`
17//!   split.
18//!
19//! # The consistency contract
20//!
21//! The set is **eventually consistent** by design. A `revoke` at the origin
22//! propagates to edge readers asynchronously (KV replication / gossip), so an
23//! edge reader may briefly answer `false` for an entry the origin has already
24//! revoked. What bounds that lag depends on the credential:
25//!
26//! - **Access tokens** (15-min `exp`): a revoked-but-not-yet-propagated token
27//!   outlives its revocation by at most one access TTL, then expires on its own
28//!   signature.
29//! - **Standing credentials** (membership snapshots, node bindings — noisetable
30//!   W235 §5.1): no edge-enforced expiry, so **revocation is the only bound**.
31//!   A removed member keeps a fully offline LAN until a set naming them reaches
32//!   it. That cost is accepted (W235 §0.1).
33//!
34//! This is sound because auth has no cross-session OLTP — every check validates
35//! *one* session, so the global hot path never needs a consistent view across
36//! sessions. Revocation membership is the *only* shared auth state the edge
37//! reads, and it tolerates lag.
38
39use std::collections::HashMap;
40use std::sync::{Arc, PoisonError, RwLock};
41
42use async_trait::async_trait;
43
44use cheers_core::{DeviceId, MembershipTag, PrincipalId, RevocationEntry, RevocationKey, RevocationSet, Revoked, StoreError};
45use hmac::{Hmac, Mac};
46use sha2::Sha256;
47
48use crate::artifact::IssuerTrust;
49use crate::key_set::VerifyError;
50
51/// Domain separation for [`membership_tag`].
52const MEMBERSHIP_TAG_CONTEXT: &[u8] = b"urn:cheers:revocation:membership-tag:v2";
53
54/// The published name of `principal`'s membership revocation on `(kind, id)`:
55/// `HMAC-SHA256(key, ctx || len-prefixed kind, id, principal)`, each length a
56/// big-endian `u64` so no two triples share an input. `principal` enters in
57/// its kind-prefixed wire form (`user:x`, `key:x`), so a user and a key with
58/// one id string never share a tag (ctx `v2`; `v1` hashed a bare user id). `key` is the resource's
59/// [`RevocationKey`]; see `cheers_core::revocation`, "Membership privacy".
60/// The issuer computes it on publish, an edge on lookup.
61pub fn membership_tag(key: &RevocationKey, kind: &str, id: &str, principal: &PrincipalId) -> MembershipTag {
62    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key.as_bytes()).expect("HMAC takes any key length");
63    mac.update(MEMBERSHIP_TAG_CONTEXT);
64    let principal = principal.to_string();
65    for part in [kind, id, principal.as_str()] {
66        mac.update(&(part.len() as u64).to_be_bytes());
67        mac.update(part.as_bytes());
68    }
69    MembershipTag::from_bytes(mac.finalize().into_bytes().into())
70}
71
72/// The published form of a store entry: a membership becomes its
73/// [`membership_tag`] under `key_of(kind, id)`; a jti or device is copied (and
74/// `key_of` is not called).
75pub fn revocation_entry(entry: &Revoked, key_of: impl FnOnce(&str, &str) -> RevocationKey) -> RevocationEntry {
76    match entry {
77        Revoked::Jti { jti, exp } => RevocationEntry::Jti { jti: jti.clone(), exp: *exp },
78        Revoked::Device { device, at_seq } => RevocationEntry::Device { device: device.clone(), at_seq: *at_seq },
79        Revoked::Membership { kind, id, principal, at_epoch } => RevocationEntry::Membership {
80            tag: membership_tag(&key_of(kind, id), kind, id, principal),
81            at_epoch: *at_epoch,
82        },
83    }
84}
85
86/// A deterministic per-resource key for tests on either side of the wire.
87#[doc(hidden)]
88pub fn test_revocation_key(kind: &str, id: &str) -> RevocationKey {
89    use sha2::Digest as _;
90    RevocationKey::from_bytes(Sha256::digest(format!("test-key/{kind}/{id}")).into())
91}
92
93/// Edge-side revocation check. The verify-only consumer depends on *this* — it
94/// can ask whether something is revoked but holds no power to revoke.
95///
96/// A `false` answer is never a *proof* of liveness — only that the revocation,
97/// if any, has not yet reached this replica (see the module contract).
98///
99/// `async` + dyn-compatible (via [`async_trait`]) to match the rest of the store
100/// surface, so an edge can hold a `dyn RevocationReader` backed by CF KV.
101#[async_trait]
102pub trait RevocationReader: Send + Sync {
103    /// `true` if the credential with this `jti` has been revoked
104    /// ([`Revoked::Jti`]). Cryptographic expiry is enforced separately by the
105    /// [`TokenVerifier`](cheers_core::TokenVerifier).
106    ///
107    /// A jti entry whose `exp` has passed may read as unrevoked: the
108    /// credential is dead on its own signature by then.
109    async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError>;
110
111    /// `true` if a standing binding of `device` at sequence `seq` is revoked:
112    /// a [`Revoked::Device`] entry exists with `seq < at_seq` — the check a
113    /// standing node binding (R732-F5) makes. A binding minted after the
114    /// revoke (re-enrollment) carries a higher seq and reads `false`.
115    async fn is_device_revoked(&self, device: &DeviceId, seq: u64) -> Result<bool, StoreError>;
116
117    /// `true` if `principal`'s membership of resource `(kind, id)` in a
118    /// snapshot at `snapshot_epoch` is revoked: a membership entry exists with
119    /// `snapshot_epoch < at_epoch` — the check a membership snapshot (R732-F4)
120    /// makes per member. Any principal kind, a `Key` included.
121    ///
122    /// `key` is `(kind, id)`'s [`RevocationKey`], taken from the snapshot
123    /// being checked. A replica of the published set holds only tags and
124    /// looks up [`membership_tag`]`(key, kind, id, principal)`; an origin store
125    /// holding plaintext rows may ignore it.
126    async fn is_membership_revoked(
127        &self,
128        key: &RevocationKey,
129        kind: &str,
130        id: &str,
131        principal: &PrincipalId,
132        snapshot_epoch: u64,
133    ) -> Result<bool, StoreError>;
134}
135
136/// One replica shared by a gossip task and a verifier.
137#[async_trait]
138impl<T: RevocationReader + ?Sized> RevocationReader for Arc<T> {
139    async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError> {
140        (**self).is_revoked(jti).await
141    }
142
143    async fn is_device_revoked(&self, device: &DeviceId, seq: u64) -> Result<bool, StoreError> {
144        (**self).is_device_revoked(device, seq).await
145    }
146
147    async fn is_membership_revoked(
148        &self,
149        key: &RevocationKey,
150        kind: &str,
151        id: &str,
152        principal: &PrincipalId,
153        snapshot_epoch: u64,
154    ) -> Result<bool, StoreError> {
155        (**self).is_membership_revoked(key, kind, id, principal, snapshot_epoch).await
156    }
157}
158
159/// What [`ReplicatedRevocations::adopt`] did with a verified set.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum Adopted {
162    /// The set replaced the held one (`from: None` when nothing was held).
163    Advanced { from: Option<u64>, to: u64 },
164    /// The set carried the epoch already held. Equal epochs carry equal
165    /// contents, so this is a no-op.
166    Unchanged { epoch: u64 },
167}
168
169/// Why [`ReplicatedRevocations::adopt`] refused a set.
170#[derive(Debug, thiserror::Error)]
171pub enum AdoptError {
172    /// Not a revocation set signed for the trusted issuer: bad signature, an
173    /// access token offered as a set, a non-issuer-role key, a foreign issuer.
174    #[error("revocation set refused: {0}")]
175    Verify(#[from] VerifyError),
176
177    /// A genuine set, but older than the one held. Gossip delivers sets out of
178    /// order, so callers should treat this as routine, not as an attack.
179    #[error("stale revocation set: epoch {offered} is below the held epoch {held}")]
180    Stale { held: u64, offered: u64 },
181}
182
183/// The held set, indexed identity -> bound for point lookups.
184struct Held {
185    epoch: u64,
186    token: String,
187    jtis: HashMap<String, Option<i64>>,
188    devices: HashMap<DeviceId, u64>,
189    memberships: HashMap<MembershipTag, u64>,
190}
191
192impl Held {
193    fn new(set: RevocationSet, token: &str) -> Self {
194        let mut held = Self {
195            epoch: set.epoch,
196            token: token.to_owned(),
197            jtis: HashMap::new(),
198            devices: HashMap::new(),
199            memberships: HashMap::new(),
200        };
201        // A signed set is canonical (one entry per identity), but a set built
202        // by hand need not be: keep the greatest bound either way.
203        for entry in set.revoked {
204            match entry {
205                RevocationEntry::Jti { jti, exp } => {
206                    let kept = held.jtis.entry(jti).or_insert(exp);
207                    *kept = match (*kept, exp) {
208                        (Some(a), Some(b)) => Some(a.max(b)),
209                        _ => None,
210                    };
211                }
212                RevocationEntry::Device { device, at_seq } => {
213                    let kept = held.devices.entry(device).or_insert(at_seq);
214                    *kept = (*kept).max(at_seq);
215                }
216                RevocationEntry::Membership { tag, at_epoch } => {
217                    let kept = held.memberships.entry(tag).or_insert(at_epoch);
218                    *kept = (*kept).max(at_epoch);
219                }
220            }
221        }
222        held
223    }
224}
225
226/// An offline replica of one issuer's revocation set (R732-F6).
227///
228/// [`adopt`](Self::adopt) verifies a signed set against the [`IssuerTrust`]
229/// (pinned key, or JWKS with only issuer-role keys accepted) and keeps it only
230/// if its epoch is higher than the one held. Readers never block on an adopt:
231/// the held set is an `Arc` swapped under a short write lock, after the
232/// signature work is done.
233///
234/// **Persistence.** [`export`](Self::export) returns the held set as the signed
235/// token itself; an edge writes those bytes to disk and, after a restart, hands
236/// them back to [`adopt`](Self::adopt), which re-verifies them. Gossip carries
237/// the same bytes between peers. Transport and storage are the consumer's.
238pub struct ReplicatedRevocations {
239    trust: IssuerTrust,
240    held: RwLock<Option<Arc<Held>>>,
241}
242
243impl std::fmt::Debug for ReplicatedRevocations {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        f.debug_struct("ReplicatedRevocations")
246            .field("trust", &self.trust)
247            .field("epoch", &self.epoch())
248            .finish()
249    }
250}
251
252impl ReplicatedRevocations {
253    /// An empty replica. Until a set is adopted every query answers `false`.
254    pub fn new(trust: IssuerTrust) -> Self {
255        Self {
256            trust,
257            held: RwLock::new(None),
258        }
259    }
260
261    pub fn issuer(&self) -> &str {
262        self.trust.issuer()
263    }
264
265    /// Verify `token` as a [`RevocationSet`] for the trusted issuer and adopt
266    /// it if its epoch is higher than the one held.
267    ///
268    /// An equal epoch is [`Adopted::Unchanged`]; a lower one is
269    /// [`AdoptError::Stale`]. Concurrent adopts are linearised on the epoch:
270    /// whatever order they finish in, the highest epoch wins.
271    pub async fn adopt(&self, token: &str) -> Result<Adopted, AdoptError> {
272        let set: RevocationSet = self.trust.verify(token).await?;
273        let offered = Held::new(set, token);
274        let mut held = self.held.write().unwrap_or_else(PoisonError::into_inner);
275        let from = held.as_ref().map(|h| h.epoch);
276        match from {
277            Some(epoch) if offered.epoch < epoch => Err(AdoptError::Stale {
278                held: epoch,
279                offered: offered.epoch,
280            }),
281            Some(epoch) if offered.epoch == epoch => Ok(Adopted::Unchanged { epoch }),
282            _ => {
283                let to = offered.epoch;
284                *held = Some(Arc::new(offered));
285                Ok(Adopted::Advanced { from, to })
286            }
287        }
288    }
289
290    /// The held epoch, or `None` before the first adopt.
291    pub fn epoch(&self) -> Option<u64> {
292        self.held().map(|h| h.epoch)
293    }
294
295    /// The held set as the signed token it arrived as — what an edge persists
296    /// and gossips. Feed it back through [`adopt`](Self::adopt) to import.
297    pub fn export(&self) -> Option<String> {
298        self.held().map(|h| h.token.clone())
299    }
300
301    fn held(&self) -> Option<Arc<Held>> {
302        self.held.read().unwrap_or_else(PoisonError::into_inner).clone()
303    }
304}
305
306#[async_trait]
307impl RevocationReader for ReplicatedRevocations {
308    async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError> {
309        // The held set is fixed per epoch, so a lapsed jti stays named until
310        // a newer set drops it; it masks only a credential that is already
311        // dead on its own exp.
312        Ok(self.held().is_some_and(|h| h.jtis.contains_key(jti)))
313    }
314
315    async fn is_device_revoked(&self, device: &DeviceId, seq: u64) -> Result<bool, StoreError> {
316        Ok(self.held().is_some_and(|h| h.devices.get(device).is_some_and(|&at| seq < at)))
317    }
318
319    async fn is_membership_revoked(
320        &self,
321        key: &RevocationKey,
322        kind: &str,
323        id: &str,
324        principal: &PrincipalId,
325        snapshot_epoch: u64,
326    ) -> Result<bool, StoreError> {
327        let Some(held) = self.held() else {
328            return Ok(false);
329        };
330        let tag = membership_tag(key, kind, id, principal);
331        Ok(held.memberships.get(&tag).is_some_and(|&at| snapshot_epoch < at))
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::jwks::{JwkKey, JwksDoc, KeySet};
339    use crate::key_set::KeySetVerifier;
340    use crate::public_verifier::PasetoV4PublicVerifier;
341    use cheers_core::{KeyRole, McpClaims, PrincipalId, SignedArtifact, TokenVerifier};
342    use pasetors::keys::{AsymmetricKeyPair, AsymmetricSecretKey, Generate};
343    use pasetors::version4::{PublicToken, V4};
344
345    const ISS: &str = "https://cheers.test";
346
347    fn kp() -> AsymmetricKeyPair<V4> {
348        AsymmetricKeyPair::<V4>::generate().unwrap()
349    }
350
351    fn pub_bytes(k: &AsymmetricKeyPair<V4>) -> [u8; 32] {
352        k.public.as_bytes().try_into().unwrap()
353    }
354
355    /// Sign `payload` the way `PasetoV4SecretMinter::mint_artifact` does.
356    fn sign_with(secret: &AsymmetricSecretKey<V4>, kid: &str, payload: &impl serde::Serialize, ia: Option<&[u8]>) -> String {
357        let body = serde_json::to_vec(payload).unwrap();
358        let footer = format!(r#"{{"kid":"{kid}"}}"#);
359        PublicToken::sign(secret, &body, Some(footer.as_bytes()), ia).unwrap()
360    }
361
362    fn sign_set(secret: &AsymmetricSecretKey<V4>, kid: &str, set: &RevocationSet) -> String {
363        sign_with(secret, kid, set, Some(RevocationSet::IMPLICIT_ASSERTION))
364    }
365
366    fn set(epoch: u64, revoked: Vec<Revoked>) -> RevocationSet {
367        let entries = revoked.iter().map(|e| revocation_entry(e, test_revocation_key)).collect();
368        RevocationSet::new(ISS, epoch, entries)
369    }
370
371    struct Rig {
372        issuer: AsymmetricKeyPair<V4>,
373        assertion: AsymmetricKeyPair<V4>,
374        selfsig: AsymmetricKeyPair<V4>,
375        replica: ReplicatedRevocations,
376    }
377
378    fn jwks_rig() -> Rig {
379        let (issuer, assertion, selfsig) = (kp(), kp(), kp());
380        let doc = JwksDoc {
381            keys: vec![
382                JwkKey::ed25519("iss-1", &pub_bytes(&issuer), ISS, KeyRole::Issuer),
383                JwkKey::ed25519("asr-1", &pub_bytes(&assertion), "svc:issues", KeyRole::Assertion),
384                JwkKey::ed25519("self-1", &pub_bytes(&selfsig), "svc:issues", KeyRole::SelfSigner),
385            ],
386        };
387        let keys = KeySetVerifier::from_key_set(KeySet::from_doc(doc).unwrap());
388        Rig {
389            replica: ReplicatedRevocations::new(IssuerTrust::key_set(ISS, keys)),
390            issuer,
391            assertion,
392            selfsig,
393        }
394    }
395
396    fn adopt(r: &ReplicatedRevocations, token: &str) -> Result<Adopted, AdoptError> {
397        pollster::block_on(r.adopt(token))
398    }
399
400    fn jti(r: &ReplicatedRevocations, j: &str) -> bool {
401        pollster::block_on(r.is_revoked(j)).unwrap()
402    }
403
404    fn device(r: &ReplicatedRevocations, d: &str) -> bool {
405        pollster::block_on(r.is_device_revoked(&DeviceId::new(d), 0)).unwrap()
406    }
407
408    fn member(r: &ReplicatedRevocations, kind: &str, id: &str, user: &str) -> bool {
409        pollster::block_on(r.is_membership_revoked(&test_revocation_key(kind, id), kind, id, &PrincipalId::user(user), 0)).unwrap()
410    }
411
412    #[test]
413    fn empty_replica_answers_false() {
414        let r = jwks_rig();
415        assert_eq!(r.replica.epoch(), None);
416        assert_eq!(r.replica.export(), None);
417        assert!(!jti(&r.replica, "j1"));
418        assert!(!device(&r.replica, "phone"));
419        assert!(!member(&r.replica, "namespace", "ns-1", "alice"));
420    }
421
422    #[test]
423    fn adopted_set_answers_jti_device_and_membership_queries() {
424        let r = jwks_rig();
425        let s = set(
426            5,
427            vec![
428                Revoked::jti("j1", None),
429                Revoked::device("phone", 1),
430                Revoked::membership("namespace", "ns-1", PrincipalId::user("alice"), 1),
431            ],
432        );
433        assert_eq!(
434            adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &s)).unwrap(),
435            Adopted::Advanced { from: None, to: 5 }
436        );
437        assert!(jti(&r.replica, "j1"));
438        assert!(!jti(&r.replica, "j2"));
439        assert!(device(&r.replica, "phone"));
440        assert!(!device(&r.replica, "laptop"));
441        assert!(member(&r.replica, "namespace", "ns-1", "alice"));
442        // Every coordinate of the membership triple matters.
443        assert!(!member(&r.replica, "namespace", "ns-1", "bob"));
444        assert!(!member(&r.replica, "namespace", "ns-2", "alice"));
445        assert!(!member(&r.replica, "room", "ns-1", "alice"));
446        // The wrong resource key finds nothing: the set names tags, not people.
447        let wrong = test_revocation_key("namespace", "ns-2");
448        let alice = PrincipalId::user("alice");
449        assert!(!pollster::block_on(r.replica.is_membership_revoked(&wrong, "namespace", "ns-1", &alice, 0)).unwrap());
450        // Kinds don't bleed: a jti named like a device is not a device.
451        assert!(!device(&r.replica, "j1"));
452    }
453
454    #[test]
455    fn a_published_set_names_no_member_in_clear() {
456        let s = set(1, vec![Revoked::membership("namespace", "ns-secret", PrincipalId::user("alice-secret"), 3)]);
457        let wire = serde_json::to_string(&s).unwrap();
458        assert!(!wire.contains("ns-secret") && !wire.contains("alice-secret"), "{wire}");
459        assert!(wire.contains("\"tag\""));
460    }
461
462    #[test]
463    fn tags_are_length_prefixed_so_triples_do_not_collide() {
464        let key = test_revocation_key("k", "i");
465        assert_ne!(
466            membership_tag(&key, "ab", "c", &PrincipalId::user("d")),
467            membership_tag(&key, "a", "bc", &PrincipalId::user("d"))
468        );
469    }
470
471    #[test]
472    fn a_user_and_a_key_with_one_id_string_tag_differently() {
473        let key = test_revocation_key("namespace", "ns-1");
474        let k = PrincipalId::from_public_key(&[3; 32]);
475        let u = PrincipalId::user(k.id.clone());
476        assert_eq!(u.id, k.id);
477        assert_ne!(membership_tag(&key, "namespace", "ns-1", &u), membership_tag(&key, "namespace", "ns-1", &k));
478
479        // And a replica holding the key's revocation does not mask the user.
480        let r = jwks_rig();
481        let s = set(5, vec![Revoked::membership("namespace", "ns-1", k.clone(), 7)]);
482        adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &s)).unwrap();
483        let revoked = |p: &PrincipalId| pollster::block_on(r.replica.is_membership_revoked(&key, "namespace", "ns-1", p, 6)).unwrap();
484        assert!(revoked(&k));
485        assert!(!revoked(&u));
486    }
487
488    #[test]
489    fn device_and_membership_entries_mask_only_below_their_bound() {
490        let r = jwks_rig();
491        let s = set(5, vec![Revoked::device("phone", 100), Revoked::membership("namespace", "ns-1", PrincipalId::user("alice"), 7)]);
492        adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &s)).unwrap();
493        let dev = |seq| pollster::block_on(r.replica.is_device_revoked(&DeviceId::new("phone"), seq)).unwrap();
494        assert!(dev(99));
495        assert!(!dev(100), "a binding minted at or after the revoke (re-enrollment) is admitted");
496        assert!(!dev(101));
497        let key = test_revocation_key("namespace", "ns-1");
498        let mem = |e| {
499            pollster::block_on(r.replica.is_membership_revoked(&key, "namespace", "ns-1", &PrincipalId::user("alice"), e)).unwrap()
500        };
501        assert!(mem(6));
502        assert!(!mem(7));
503        assert!(!mem(8));
504    }
505
506    #[test]
507    fn lower_epoch_is_refused_and_equal_epoch_is_a_no_op() {
508        let r = jwks_rig();
509        let newer = set(10, vec![Revoked::jti("j1", None), Revoked::jti("j2", None)]);
510        adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &newer)).unwrap();
511
512        let older = set(9, vec![Revoked::jti("j1", None)]);
513        match adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &older)) {
514            Err(AdoptError::Stale { held: 10, offered: 9 }) => {}
515            other => panic!("expected Stale, got {other:?}"),
516        }
517        let same_epoch = set(10, vec![]);
518        assert_eq!(
519            adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &same_epoch)).unwrap(),
520            Adopted::Unchanged { epoch: 10 }
521        );
522        // Neither touched the held set.
523        assert_eq!(r.replica.epoch(), Some(10));
524        assert!(jti(&r.replica, "j2"));
525
526        // A higher epoch replaces it wholesale — an entry it drops is lifted.
527        let next = set(11, vec![Revoked::jti("j1", None)]);
528        assert_eq!(
529            adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &next)).unwrap(),
530            Adopted::Advanced { from: Some(10), to: 11 }
531        );
532        assert!(!jti(&r.replica, "j2"));
533    }
534
535    #[test]
536    fn an_access_token_is_refused_as_a_set() {
537        let r = jwks_rig();
538        // Issuer-signed with the access-token (empty) implicit assertion.
539        let token = sign_with(&r.issuer.secret, "iss-1", &set(1, vec![Revoked::jti("j1", None)]), None);
540        assert!(matches!(
541            adopt(&r.replica, &token),
542            Err(AdoptError::Verify(VerifyError::SignatureMismatch))
543        ));
544        let mcp = McpClaims::new(ISS, "https://aud.test", PrincipalId::user("alice"), 1, 2, "j", vec![]);
545        let token = sign_with(&r.issuer.secret, "iss-1", &mcp, None);
546        assert!(matches!(adopt(&r.replica, &token), Err(AdoptError::Verify(_))));
547        assert_eq!(r.replica.epoch(), None);
548    }
549
550    #[test]
551    fn a_set_is_refused_as_an_access_token() {
552        let r = jwks_rig();
553        let token = sign_set(&r.issuer.secret, "iss-1", &set(1, vec![]));
554        let keys = KeySetVerifier::from_issuer_key("iss-1", &pub_bytes(&r.issuer), ISS);
555        let res = pollster::block_on(keys.verify_mcp(&token, 0, ISS, None));
556        assert!(matches!(res, Err(VerifyError::SignatureMismatch)), "{res:?}");
557        let pinned = PasetoV4PublicVerifier::from_public_key(&pub_bytes(&r.issuer)).unwrap();
558        assert!(pinned.verify_mcp_at(&token, 0, "iss-1").is_err());
559        assert!(pinned.verify_at(&token, 0).is_err());
560    }
561
562    #[test]
563    fn only_an_issuer_role_key_may_sign_a_set() {
564        let r = jwks_rig();
565        let s = set(1, vec![]);
566        for (secret, kid) in [(&r.assertion.secret, "asr-1"), (&r.selfsig.secret, "self-1")] {
567            match adopt(&r.replica, &sign_set(secret, kid, &s)) {
568                Err(AdoptError::Verify(VerifyError::KeyRoleRejected { .. })) => {}
569                other => panic!("{kid}: expected KeyRoleRejected, got {other:?}"),
570            }
571        }
572        // A forged footer naming the issuer kid fails on the signature instead.
573        assert!(matches!(
574            adopt(&r.replica, &sign_set(&r.selfsig.secret, "iss-1", &s)),
575            Err(AdoptError::Verify(VerifyError::SignatureMismatch))
576        ));
577        assert_eq!(r.replica.epoch(), None);
578    }
579
580    #[test]
581    fn a_set_for_another_issuer_is_refused() {
582        let r = jwks_rig();
583        let foreign = RevocationSet::new("https://evil.test", 1, vec![]);
584        assert!(matches!(
585            adopt(&r.replica, &sign_set(&r.issuer.secret, "iss-1", &foreign)),
586            Err(AdoptError::Verify(VerifyError::BadIssuer { .. }))
587        ));
588    }
589
590    #[test]
591    fn pinned_key_trust_verifies_and_refuses_other_keys() {
592        let issuer = kp();
593        let pinned = PasetoV4PublicVerifier::from_public_key(&pub_bytes(&issuer)).unwrap();
594        let replica = ReplicatedRevocations::new(IssuerTrust::pinned(ISS, pinned));
595        let s = set(3, vec![Revoked::device("phone", 1)]);
596        assert!(matches!(
597            adopt(&replica, &sign_set(&kp().secret, "any", &s)),
598            Err(AdoptError::Verify(VerifyError::SignatureMismatch))
599        ));
600        assert!(matches!(
601            adopt(&replica, &sign_with(&issuer.secret, "any", &s, None)),
602            Err(AdoptError::Verify(VerifyError::SignatureMismatch))
603        ));
604        adopt(&replica, &sign_set(&issuer.secret, "any", &s)).unwrap();
605        assert!(device(&replica, "phone"));
606        let foreign = RevocationSet::new("https://evil.test", 4, vec![]);
607        assert!(matches!(
608            adopt(&replica, &sign_set(&issuer.secret, "any", &foreign)),
609            Err(AdoptError::Verify(VerifyError::BadIssuer { .. }))
610        ));
611    }
612
613    #[test]
614    fn export_import_round_trips_through_a_fresh_replica() {
615        let r = jwks_rig();
616        let s = set(
617            42,
618            vec![Revoked::jti("j1", None), Revoked::device("phone", 1), Revoked::membership("band", "b1", PrincipalId::user("carol"), 1)],
619        );
620        let token = sign_set(&r.issuer.secret, "iss-1", &s);
621        adopt(&r.replica, &token).unwrap();
622        let bytes = r.replica.export().expect("held set exports");
623        assert_eq!(bytes, token);
624
625        // A restart: a new replica with the same trust, fed the persisted bytes.
626        let pinned = PasetoV4PublicVerifier::from_public_key(&pub_bytes(&r.issuer)).unwrap();
627        let restarted = ReplicatedRevocations::new(IssuerTrust::pinned(ISS, pinned));
628        assert_eq!(
629            adopt(&restarted, &bytes).unwrap(),
630            Adopted::Advanced { from: None, to: 42 }
631        );
632        assert!(jti(&restarted, "j1"));
633        assert!(device(&restarted, "phone"));
634        assert!(member(&restarted, "band", "b1", "carol"));
635        // Tampered bytes do not import.
636        let mut tampered = bytes.clone();
637        tampered.pop();
638        let fresh = ReplicatedRevocations::new(IssuerTrust::key_set(
639            ISS,
640            KeySetVerifier::from_issuer_key("iss-1", &pub_bytes(&r.issuer), ISS),
641        ));
642        assert!(adopt(&fresh, &tampered).is_err());
643        assert_eq!(fresh.epoch(), None);
644    }
645
646    #[test]
647    fn concurrent_adopts_and_reads_settle_on_the_highest_epoch() {
648        let r = Arc::new(jwks_rig());
649        // Cumulative, as an issuer's sets are: epoch e names j1..=je.
650        let tokens: Vec<String> = (1..=32u64)
651            .map(|e| {
652                let revoked = (1..=e).map(|i| Revoked::jti(format!("j{i}"), None)).collect();
653                sign_set(&r.issuer.secret, "iss-1", &set(e, revoked))
654            })
655            .collect();
656        let handles: Vec<_> = tokens
657            .into_iter()
658            .rev()
659            .map(|t| {
660                let r = r.clone();
661                std::thread::spawn(move || {
662                    let _ = adopt(&r.replica, &t);
663                    // Readers interleave with writers; the epoch never goes
664                    // backwards, so what epoch e named stays named.
665                    let e = r.replica.epoch().expect("something is held after an adopt");
666                    assert!(jti(&r.replica, &format!("j{e}")));
667                })
668            })
669            .collect();
670        for h in handles {
671            h.join().unwrap();
672        }
673        assert_eq!(r.replica.epoch(), Some(32));
674        assert!(jti(&r.replica, "j32"));
675        assert!(!jti(&r.replica, "j33"));
676    }
677
678    #[test]
679    fn trait_is_dyn_compatible_and_shareable() {
680        fn _reader(_: &dyn RevocationReader) {}
681        fn _shared(r: Arc<ReplicatedRevocations>) -> impl RevocationReader {
682            r
683        }
684    }
685}