Skip to main content

cheers_verify/
admit.rs

1//! Offline admission (R734-F2; `knock.md`, "Offline verification").
2//!
3//! [`AdmitAuthority`] answers one question: does key A hold the admit ability
4//! on resource R, according to what this edge holds? [`IssuerAdmitAuthority`]
5//! answers it rooted in the cheers issuer, from two artifacts that verify
6//! offline:
7//!
8//! 1. the approver's standing binding (§7), proving which user A belongs to,
9//!    unless A itself holds an issuer tuple on R; and
10//! 2. the snapshot of R this edge holds **now** (§8), showing that user (or
11//!    the key) holds a relation carrying the admit ability.
12//!
13//! [`verify_admit_with`] then checks the [`Admit`] itself against that answer,
14//! over any [`AdmitAuthority`] (R734-F7): the [`Approval`] carries the policy
15//! and epoch it was read under, so a root other than the issuer (noisetable:
16//! the creator-signed room roster) runs the same chain.
17//! [`IssuerAdmitAuthority::verify_admit_at`] is that chain over the issuer.
18//! The chain is re-checked against the currently held snapshot on every call,
19//! so an approver demoted in a newer snapshot takes every admit they had not
20//! uploaded with them.
21//!
22//! **Delegation is one hop** (invariant 4). The authority never consults
23//! other admits: a key admitted offline has no issuer tuple and no binding to
24//! a user who holds the ability, so it is not an approver until the issuer
25//! records it.
26
27use std::collections::BTreeMap;
28use std::sync::{PoisonError, RwLock};
29
30use base64::engine::general_purpose::URL_SAFE_NO_PAD;
31use base64::Engine;
32use pasetors::token::UntrustedToken;
33use pasetors::version4::V4;
34use sha2::{Digest, Sha256};
35
36use cheers_core::{
37    evaluate_admit, Admit, AdmissionPolicy, AdmissionRefusal, AdmitFacts, CodecError, DeviceSigned, KnockError, LeaseRequest, LeaseState, PeerKey, PrincipalId, PrincipalKind, StoreError, UserId,
38};
39
40use crate::key_set::VerifyError;
41use crate::public_verifier::PasetoV4PublicVerifier;
42use crate::revocation::RevocationReader;
43use crate::snapshot::{SnapshotError, SnapshotVerifier, VerifiedSnapshot};
44use crate::standing::{StandingError, StandingVerifier};
45
46/// Verify a [`DeviceSigned`] artifact under the key its own payload names.
47///
48/// The payload is read untrusted only to learn the signer, which must be a
49/// `key:` principal; the signature is then checked under exactly that key
50/// with `T::IMPLICIT_ASSERTION`, so a token signed by any other key, or for
51/// another artifact kind, is refused.
52pub fn verify_device_artifact<T: DeviceSigned>(token: &str) -> Result<T, CodecError> {
53    let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(token).map_err(|_| CodecError::Malformed)?;
54    let claimed: T = serde_json::from_slice(untrusted.untrusted_payload())?;
55    let signer = claimed.signer();
56    if signer.kind != PrincipalKind::Key {
57        return Err(CodecError::Malformed);
58    }
59    let key = key_bytes(signer).ok_or(CodecError::Malformed)?;
60    PasetoV4PublicVerifier::from_public_key(&key)?.verify_artifact(token)
61}
62
63/// The raw Ed25519 key of a `key:` principal.
64fn key_bytes(p: &PrincipalId) -> Option<[u8; 32]> {
65    if p.kind != PrincipalKind::Key {
66        return None;
67    }
68    URL_SAFE_NO_PAD.decode(p.id.as_bytes()).ok()?.try_into().ok()
69}
70
71/// What an [`Admit`]'s `source` names: base64url-no-pad SHA-256 of the
72/// [`Knock`](cheers_core::Knock) or [`Offer`](cheers_core::Offer) token.
73pub fn artifact_hash(token: &str) -> String {
74    URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes()))
75}
76
77/// Why an approver or an [`Admit`] was refused.
78#[derive(Debug, thiserror::Error)]
79pub enum AdmitError {
80    /// Bad signature, wrong artifact kind, or a signer that is not a key.
81    #[error("admit refused: {0}")]
82    Signature(#[from] CodecError),
83    /// Decoded, but breaks an artifact invariant (lease, key principals).
84    #[error("admit refused: {0}")]
85    Invalid(#[from] KnockError),
86    /// Past its lease's `exp`.
87    #[error("admit expired at {exp}")]
88    Expired { exp: i64 },
89    /// Checked under an issuer this edge does not trust.
90    #[error("admit names authority {got}, expected {expected}")]
91    WrongAuthority { expected: String, got: String },
92    /// This edge holds no snapshot of the resource.
93    #[error("no snapshot held for {kind}/{id}")]
94    NoSnapshot { kind: String, id: String },
95    /// The held snapshot no longer verifies (superseded, revocation key).
96    #[error(transparent)]
97    Snapshot(#[from] SnapshotError),
98    /// The approver's standing binding was refused.
99    #[error(transparent)]
100    Standing(#[from] StandingError),
101    /// The key has no issuer-rooted path to the admit ability on the resource.
102    #[error("{approver} does not hold the admit ability on {kind}/{id}")]
103    NotAnApprover { approver: PrincipalId, kind: String, id: String },
104    /// The admit's own `jti` is revoked (refused at reconciliation).
105    #[error("admit {jti} is revoked")]
106    Revoked { jti: String },
107    /// The admission policy held now does not permit this admit: the mode,
108    /// a confirmation below the relation's minimum, or a lease past the
109    /// path maximum (`cheers_core::evaluate_admit`).
110    #[error("admission policy refuses admit {jti}: {reason}")]
111    PolicyRefused { jti: String, reason: AdmissionRefusal },
112    #[error(transparent)]
113    Store(#[from] StoreError),
114}
115
116impl From<VerifyError> for AdmitError {
117    fn from(e: VerifyError) -> Self {
118        AdmitError::Snapshot(SnapshotError::Verify(e))
119    }
120}
121
122/// An approver the authority vouches for.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct Approval {
125    /// The user the key's standing binding names; `None` when the key holds
126    /// the relation through its own issuer tuple.
127    pub user: Option<UserId>,
128    /// The admit-bearing relation the approver holds.
129    pub relation: String,
130    /// The admission policy the authority holds now for the resource; `None`
131    /// reads as closed. [`verify_admit_with`] evaluates every admit against
132    /// it, not against the policy the admit was minted under.
133    pub policy: Option<AdmissionPolicy>,
134    /// The epoch of the authority state the approval was read from (the
135    /// issuer: the held snapshot's epoch).
136    pub epoch: u64,
137}
138
139/// Does key `approver` hold the admit ability on `(kind, id)`, according to
140/// what this edge holds now? Implemented over the issuer by
141/// [`IssuerAdmitAuthority`]; a consumer roots it elsewhere (noisetable: the
142/// creator-signed room roster). [`verify_admit_with`] runs the offline chain
143/// over any implementation.
144#[async_trait::async_trait]
145pub trait AdmitAuthority: Send + Sync {
146    async fn approval_at(
147        &self,
148        approver: &PrincipalId,
149        approver_binding: Option<&str>,
150        kind: &str,
151        id: &str,
152        now: i64,
153    ) -> Result<Approval, AdmitError>;
154
155    /// The user a requester's standing `binding` names, verified for
156    /// `requester`'s key at `now`. Asked only when the [`Admit`] carries a
157    /// `requester_binding` (`users` mode admits only these).
158    async fn requester_user_at(&self, requester: &PeerKey, binding: &str, now: i64) -> Result<UserId, AdmitError>;
159}
160
161/// Verify an offline [`Admit`] token at `now` over `authority`, re-checking
162/// the whole chain against what it holds now.
163///
164/// In order: device signature, artifact invariants (key principals, a lease
165/// with `exp`, the midpoint), the admit naming `trusted_authority`, expiry,
166/// the admit's `jti` against `revocations`, the approver's path
167/// ([`AdmitAuthority::approval_at`]), the requester's standing binding when it
168/// carries one ([`AdmitAuthority::requester_user_at`]), then the admission
169/// policy the approval carries (no policy reads as closed).
170pub async fn verify_admit_with(
171    authority: &dyn AdmitAuthority,
172    trusted_authority: &str,
173    revocations: &dyn RevocationReader,
174    token: &str,
175    now: i64,
176) -> Result<VerifiedAdmit, AdmitError> {
177    let admit: Admit = verify_device_artifact(token)?;
178    admit.validate()?;
179    if admit.authority != trusted_authority {
180        return Err(AdmitError::WrongAuthority { expected: trusted_authority.to_owned(), got: admit.authority });
181    }
182    let lease = admit.lease.state_at(now);
183    if lease == LeaseState::Expired {
184        return Err(AdmitError::Expired { exp: admit.lease.exp().unwrap_or(now) });
185    }
186    if revocations.is_revoked(&admit.jti).await? {
187        return Err(AdmitError::Revoked { jti: admit.jti });
188    }
189    let approval = authority
190        .approval_at(&admit.approver, admit.approver_binding.as_deref(), &admit.kind, &admit.id, now)
191        .await?;
192    let requester_user = match &admit.requester_binding {
193        Some(binding) => {
194            let key = key_bytes(&admit.requester).ok_or(KnockError::NotAKey(admit.requester.clone()))?;
195            Some(authority.requester_user_at(&PeerKey::ed25519(key), binding, now).await?)
196        }
197        None => None,
198    };
199    let facts = AdmitFacts {
200        relation: &admit.relation,
201        path: admit.path(),
202        confirmation: Some(admit.confirmation),
203        requester_is_user: requester_user.is_some(),
204    };
205    if let Err(reason) = evaluate_admit(approval.policy.as_ref(), &facts, admit.iat, Some(LeaseRequest::Exact(admit.lease))) {
206        return Err(AdmitError::PolicyRefused { jti: admit.jti, reason });
207    }
208    Ok(VerifiedAdmit { admit, approval, requester_user, lease })
209}
210
211/// A verified [`Admit`].
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct VerifiedAdmit {
214    pub admit: Admit,
215    pub approval: Approval,
216    /// The user the requester's standing binding names, when the Admit
217    /// carried one (`users` mode admits only these).
218    pub requester_user: Option<UserId>,
219    /// The admit's lease at the verification instant; never `Expired`.
220    pub lease: LeaseState,
221}
222
223/// [`AdmitAuthority`] rooted in the cheers issuer (§7 + §8).
224pub struct IssuerAdmitAuthority<Rd> {
225    standing: StandingVerifier<Rd>,
226    snapshots: SnapshotVerifier<Rd>,
227    /// The snapshot token held per `(kind, id)`: the newest adopted.
228    held: RwLock<BTreeMap<(String, String), (u64, String)>>,
229}
230
231impl<Rd> std::fmt::Debug for IssuerAdmitAuthority<Rd> {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        f.debug_struct("IssuerAdmitAuthority").finish_non_exhaustive()
234    }
235}
236
237impl<Rd: RevocationReader> IssuerAdmitAuthority<Rd> {
238    /// Both verifiers must trust the same issuer. Which relations carry the
239    /// admit ability is read from the held snapshot's
240    /// [`AdmissionPolicy::admitters`](cheers_core::AdmissionPolicy::admitters).
241    pub fn new(standing: StandingVerifier<Rd>, snapshots: SnapshotVerifier<Rd>) -> Self {
242        Self {
243            standing,
244            snapshots,
245            held: RwLock::new(BTreeMap::new()),
246        }
247    }
248
249    pub fn standing(&self) -> &StandingVerifier<Rd> {
250        &self.standing
251    }
252
253    pub fn snapshots(&self) -> &SnapshotVerifier<Rd> {
254        &self.snapshots
255    }
256
257    /// Adopt a snapshot (signature only) and hold it for its resource if it
258    /// is at least as new as the one held. Returns the epoch now held.
259    pub async fn hold_snapshot(&self, token: &str) -> Result<u64, VerifyError> {
260        let snapshot = self.snapshots.adopt(token).await?;
261        let mut held = self.held.write().unwrap_or_else(PoisonError::into_inner);
262        let slot = held.entry((snapshot.kind, snapshot.id)).or_insert((0, String::new()));
263        if snapshot.epoch >= slot.0 {
264            *slot = (snapshot.epoch, token.to_owned());
265        }
266        Ok(slot.0)
267    }
268
269    fn held_token(&self, kind: &str, id: &str) -> Option<String> {
270        let held = self.held.read().unwrap_or_else(PoisonError::into_inner);
271        held.get(&(kind.to_owned(), id.to_owned())).map(|(_, t)| t.clone())
272    }
273
274    /// Verify an offline [`Admit`] token at `now` with [`verify_admit_with`],
275    /// rooted in this issuer: the admit must name it, its `jti` is checked
276    /// against the snapshot verifier's revocation set, and the approver
277    /// against the snapshot held now.
278    pub async fn verify_admit_at(&self, token: &str, now: i64) -> Result<VerifiedAdmit, AdmitError> {
279        verify_admit_with(self, self.snapshots.trust().issuer(), self.snapshots.revocations(), token, now).await
280    }
281
282    fn admit_relation(&self, snapshot: &VerifiedSnapshot, principal: &PrincipalId) -> Option<String> {
283        // The policy held now names the admit ability (R734-F4); no policy,
284        // or no admitters, means nobody may admit.
285        let admitters = &snapshot.admission_policy()?.admitters;
286        admitters.iter().find(|r| snapshot.holds(principal, r)).cloned()
287    }
288}
289
290#[async_trait::async_trait]
291impl<Rd: RevocationReader + Send + Sync> AdmitAuthority for IssuerAdmitAuthority<Rd> {
292    async fn approval_at(
293        &self,
294        approver: &PrincipalId,
295        approver_binding: Option<&str>,
296        kind: &str,
297        id: &str,
298        now: i64,
299    ) -> Result<Approval, AdmitError> {
300        let not_an_approver = || AdmitError::NotAnApprover {
301            approver: approver.clone(),
302            kind: kind.to_owned(),
303            id: id.to_owned(),
304        };
305        if approver.kind != PrincipalKind::Key {
306            return Err(not_an_approver());
307        }
308        let token = self.held_token(kind, id).ok_or_else(|| AdmitError::NoSnapshot {
309            kind: kind.to_owned(),
310            id: id.to_owned(),
311        })?;
312        let snapshot = self.snapshots.verify_snapshot_at(&token, now).await?;
313
314        let approval = |user, relation| Approval {
315            user,
316            relation,
317            policy: snapshot.admission_policy().cloned(),
318            epoch: snapshot.snapshot.epoch,
319        };
320        // The key's own issuer tuple.
321        if let Some(relation) = self.admit_relation(&snapshot, approver) {
322            return Ok(approval(None, relation));
323        }
324        // Otherwise a standing binding to a user who holds the ability.
325        let Some(binding) = approver_binding else {
326            return Err(not_an_approver());
327        };
328        let key = key_bytes(approver).ok_or_else(not_an_approver)?;
329        let standing = self.standing.verify_standing_at(binding, &PeerKey::ed25519(key), now).await?;
330        let user = standing.binding.sub;
331        match self.admit_relation(&snapshot, &PrincipalId::from(&user)) {
332            Some(relation) => Ok(approval(Some(user), relation)),
333            None => Err(not_an_approver()),
334        }
335    }
336
337    async fn requester_user_at(&self, requester: &PeerKey, binding: &str, now: i64) -> Result<UserId, AdmitError> {
338        Ok(self.standing.verify_standing_at(binding, requester, now).await?.binding.sub)
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::artifact::IssuerTrust;
346    use crate::jwks::{JwkKey, JwksDoc, KeySet};
347    use crate::key_set::KeySetVerifier;
348    use crate::revocation::{revocation_entry, test_revocation_key, ReplicatedRevocations};
349    use cheers_core::{
350        AdmissionMode, AdmissionPolicy, AdmitSource, Confirmation, DeviceId, Knock, KeyRole, Lease, ResourceRevocationKey, RevocationKey, RevocationSet, Revoked,
351        SetSnapshot, SignedArtifact, SnapshotMember, StandingBinding,
352    };
353    use pasetors::keys::{AsymmetricKeyPair, AsymmetricSecretKey, Generate};
354    use pasetors::version4::PublicToken;
355    use serde::Serialize;
356    use std::sync::Arc;
357
358    const ISS: &str = "https://cheers.test";
359    const IAT: i64 = 1_000;
360    const DAY: i64 = 24 * 60 * 60;
361
362    fn kp() -> AsymmetricKeyPair<V4> {
363        AsymmetricKeyPair::<V4>::generate().unwrap()
364    }
365
366    fn pub_bytes(k: &AsymmetricKeyPair<V4>) -> [u8; 32] {
367        k.public.as_bytes().try_into().unwrap()
368    }
369
370    fn key_of(k: &AsymmetricKeyPair<V4>) -> PrincipalId {
371        PrincipalId::from_public_key(&pub_bytes(k))
372    }
373
374    fn sign_with(secret: &AsymmetricSecretKey<V4>, kid: &str, payload: &impl Serialize, ia: &[u8]) -> String {
375        let body = serde_json::to_vec(payload).unwrap();
376        let footer = format!(r#"{{"kid":"{kid}"}}"#);
377        PublicToken::sign(secret, &body, Some(footer.as_bytes()), Some(ia)).unwrap()
378    }
379
380    fn member(principal: PrincipalId, relation: &str) -> SnapshotMember {
381        SnapshotMember { principal, relation: relation.into(), via: vec![("namespace".into(), "ed".into())], lease: None }
382    }
383
384    struct Rig {
385        issuer: AsymmetricKeyPair<V4>,
386        replica: Arc<ReplicatedRevocations>,
387        auth: IssuerAdmitAuthority<Arc<ReplicatedRevocations>>,
388        /// alice's device key, bound to user alice.
389        device: AsymmetricKeyPair<V4>,
390        binding: String,
391    }
392
393    impl Rig {
394        fn snapshot(&self, epoch: u64, members: Vec<SnapshotMember>) {
395            self.snapshot_with(epoch, members, Some(AdmissionPolicy::new(AdmissionMode::Knock, "guest")));
396        }
397
398        /// As [`Self::snapshot_exact`], with `owner` as the admit relation.
399        fn snapshot_with(&self, epoch: u64, members: Vec<SnapshotMember>, policy: Option<AdmissionPolicy>) {
400            self.snapshot_exact(epoch, members, policy.map(|p| p.with_admitters(["owner"])));
401        }
402
403        fn snapshot_exact(&self, epoch: u64, members: Vec<SnapshotMember>, policy: Option<AdmissionPolicy>) {
404            let s = SetSnapshot::new(
405                ISS,
406                "namespace",
407                "ed",
408                epoch,
409                members,
410                vec![ResourceRevocationKey {
411                    kind: "namespace".into(),
412                    id: "ed".into(),
413                    key: test_revocation_key("namespace", "ed"),
414                }],
415                IAT,
416                Lease::new(IAT, IAT + 7 * DAY, None).unwrap(),
417            )
418            .with_policy(policy);
419            let t = sign_with(&self.issuer.secret, "iss-1", &s, SetSnapshot::IMPLICIT_ASSERTION);
420            pollster::block_on(self.auth.hold_snapshot(&t)).unwrap();
421        }
422
423        fn admit(&self, signer: &AsymmetricKeyPair<V4>, binding: Option<&str>, level: Confirmation) -> Admit {
424            Admit {
425                approver: key_of(signer),
426                approver_binding: binding.map(str::to_owned),
427                authority: ISS.into(),
428                requester: key_of(&kp()),
429                requester_binding: None,
430                kind: "namespace".into(),
431                id: "ed".into(),
432                relation: "guest".into(),
433                source: AdmitSource::Knock(artifact_hash("knock-token")),
434                confirmation: level,
435                epoch: 10,
436                jti: "admit-1".into(),
437                iat: IAT,
438                lease: Lease::new(IAT, IAT + DAY, Some(IAT + 7 * DAY)).unwrap(),
439            }
440        }
441
442        /// A standing binding for `user` on node key `peer`, signed by `by`.
443        fn bind(&self, by: &AsymmetricSecretKey<V4>, user: &str, peer: &AsymmetricKeyPair<V4>) -> String {
444            let b = StandingBinding {
445                issuer: ISS.into(),
446                sub: UserId::new(user),
447                device: DeviceId::new(format!("node:{user}")),
448                peer_key: PeerKey::ed25519(pub_bytes(peer)),
449                seq: 1,
450                iat: IAT,
451                jti: format!("bind-{user}"),
452                lease: Lease::new(IAT, IAT + 7 * DAY, None).unwrap(),
453            };
454            sign_with(by, "iss-1", &b, StandingBinding::IMPLICIT_ASSERTION)
455        }
456
457        fn sign(&self, signer: &AsymmetricKeyPair<V4>, a: &Admit) -> String {
458            sign_with(&signer.secret, "dev", a, Admit::IMPLICIT_ASSERTION)
459        }
460
461        fn verify(&self, token: &str, now: i64) -> Result<VerifiedAdmit, AdmitError> {
462            pollster::block_on(self.auth.verify_admit_at(token, now))
463        }
464    }
465
466    fn rig() -> Rig {
467        let issuer = kp();
468        let doc = JwksDoc { keys: vec![JwkKey::ed25519("iss-1", &pub_bytes(&issuer), ISS, KeyRole::Issuer)] };
469        let trust = IssuerTrust::key_set(ISS, KeySetVerifier::from_key_set(KeySet::from_doc(doc).unwrap()));
470        let replica = Arc::new(ReplicatedRevocations::new(trust.clone()));
471        let auth = IssuerAdmitAuthority::new(
472            StandingVerifier::new(trust.clone(), replica.clone()),
473            SnapshotVerifier::new(trust, replica.clone()),
474        );
475        let device = kp();
476        let b = StandingBinding {
477            issuer: ISS.into(),
478            sub: UserId::new("alice"),
479            device: DeviceId::new("node:alice"),
480            peer_key: PeerKey::ed25519(pub_bytes(&device)),
481            seq: 1,
482            iat: IAT,
483            jti: "bind-1".into(),
484            lease: Lease::new(IAT, IAT + 7 * DAY, None).unwrap(),
485        };
486        let binding = sign_with(&issuer.secret, "iss-1", &b, StandingBinding::IMPLICIT_ASSERTION);
487        let r = Rig { issuer, replica, auth, device, binding };
488        r.snapshot(10, vec![member(PrincipalId::user("alice"), "owner")]);
489        r
490    }
491
492    #[test]
493    fn a_valid_chain_admits() {
494        let r = rig();
495        let a = r.admit(&r.device, Some(&r.binding), Confirmation::Compare);
496        let ok = r.verify(&r.sign(&r.device, &a), IAT + 1).unwrap();
497        assert_eq!(ok.admit, a);
498        assert_eq!(ok.approval.user, Some(UserId::new("alice")));
499        assert_eq!(ok.approval.relation, "owner");
500        assert_eq!(ok.lease, LeaseState::Current);
501    }
502
503    #[test]
504    fn a_key_with_its_own_issuer_tuple_admits_without_a_binding() {
505        let r = rig();
506        let k = kp();
507        r.snapshot(11, vec![member(key_of(&k), "owner")]);
508        let a = r.admit(&k, None, Confirmation::Accept);
509        assert_eq!(r.verify(&r.sign(&k, &a), IAT).unwrap().approval.user, None);
510    }
511
512    #[test]
513    fn a_demoted_approver_loses_unreconciled_admits() {
514        let r = rig();
515        let token = r.sign(&r.device, &r.admit(&r.device, Some(&r.binding), Confirmation::Accept));
516        assert!(r.verify(&token, IAT).is_ok());
517        r.snapshot(11, vec![member(PrincipalId::user("alice"), "guest")]);
518        assert!(matches!(r.verify(&token, IAT).unwrap_err(), AdmitError::NotAnApprover { .. }));
519    }
520
521    #[test]
522    fn a_two_hop_chain_is_refused() {
523        let r = rig();
524        // alice admits guest key G offline; G then admits H.
525        let g = kp();
526        let mut first = r.admit(&r.device, Some(&r.binding), Confirmation::Accept);
527        first.requester = key_of(&g);
528        assert!(r.verify(&r.sign(&r.device, &first), IAT).is_ok());
529        let second = r.admit(&g, None, Confirmation::Accept);
530        let err = r.verify(&r.sign(&g, &second), IAT).unwrap_err();
531        assert!(matches!(err, AdmitError::NotAnApprover { .. }), "got {err:?}");
532        // Borrowing alice's binding does not help: it names alice's key, not G.
533        let borrowed = r.admit(&g, Some(&r.binding), Confirmation::Accept);
534        let err = r.verify(&r.sign(&g, &borrowed), IAT).unwrap_err();
535        assert!(matches!(err, AdmitError::Standing(StandingError::PeerKeyMismatch)), "got {err:?}");
536    }
537
538    #[test]
539    fn the_policy_held_now_decides() {
540        let r = rig();
541        let members = || vec![member(PrincipalId::user("alice"), "owner")];
542        let token = r.sign(&r.device, &r.admit(&r.device, Some(&r.binding), Confirmation::Compare));
543        assert!(r.verify(&token, IAT).is_ok());
544        // Below the relation's minimum confirmation.
545        let strict = AdmissionPolicy::new(AdmissionMode::Knock, "guest").with_min_confirmation("guest", Confirmation::Scan);
546        r.snapshot_with(11, members(), Some(strict));
547        let err = r.verify(&token, IAT).unwrap_err();
548        assert!(
549            matches!(err, AdmitError::PolicyRefused { reason: AdmissionRefusal::BelowConfirmation { .. }, .. }),
550            "got {err:?}"
551        );
552        // No policy row reads as closed, and names no admitters, so the
553        // approver is refused before the dial is even read; `users` makes
554        // this key-only admit inert.
555        r.snapshot_with(12, members(), None);
556        assert!(matches!(r.verify(&token, IAT).unwrap_err(), AdmitError::NotAnApprover { .. }));
557        r.snapshot_with(13, members(), Some(AdmissionPolicy::new(AdmissionMode::Users, "guest")));
558        assert!(matches!(r.verify(&token, IAT).unwrap_err(), AdmitError::PolicyRefused { .. }));
559        // Moving the dial back restores it.
560        r.snapshot(14, members());
561        assert!(r.verify(&token, IAT).is_ok());
562    }
563
564    #[test]
565    fn the_admit_ability_comes_from_the_held_policy() {
566        let r = rig();
567        let members = || vec![member(PrincipalId::user("alice"), "owner")];
568        let token = r.sign(&r.device, &r.admit(&r.device, Some(&r.binding), Confirmation::Accept));
569        // A policy naming no admitters fails closed even for an owner.
570        r.snapshot_exact(20, members(), Some(AdmissionPolicy::new(AdmissionMode::Knock, "guest")));
571        assert!(matches!(r.verify(&token, IAT).unwrap_err(), AdmitError::NotAnApprover { .. }));
572        // Naming the owner's relation restores it.
573        r.snapshot(21, members());
574        assert!(r.verify(&token, IAT).is_ok());
575    }
576
577    #[test]
578    fn a_lease_past_the_path_maximum_is_refused() {
579        let r = rig();
580        let mut a = r.admit(&r.device, Some(&r.binding), Confirmation::Accept);
581        a.lease = Lease::new(IAT, IAT + DAY, Some(IAT + 8 * DAY)).unwrap();
582        let err = r.verify(&r.sign(&r.device, &a), IAT).unwrap_err();
583        assert!(matches!(err, AdmitError::PolicyRefused { reason: AdmissionRefusal::LeaseOverMax { .. }, .. }), "got {err:?}");
584    }
585
586    #[test]
587    fn a_lease_past_its_midpoint_is_refused() {
588        let r = rig();
589        let mut v = serde_json::to_value(r.admit(&r.device, Some(&r.binding), Confirmation::Accept)).unwrap();
590        v["refresh_after"] = (IAT + 4 * DAY).into();
591        let forged: Admit = serde_json::from_value(v).unwrap();
592        let err = r.verify(&r.sign(&r.device, &forged), IAT).unwrap_err();
593        assert!(matches!(err, AdmitError::Invalid(KnockError::Lease(_))), "got {err:?}");
594    }
595
596    #[test]
597    fn lease_state_through_exp_then_refused() {
598        let r = rig();
599        let token = r.sign(&r.device, &r.admit(&r.device, Some(&r.binding), Confirmation::Accept));
600        let (ra, exp) = (IAT + DAY, IAT + 7 * DAY);
601        assert_eq!(r.verify(&token, ra - 1).unwrap().lease, LeaseState::Current);
602        assert_eq!(r.verify(&token, ra).unwrap().lease, LeaseState::Warning { exp: Some(exp) });
603        assert_eq!(r.verify(&token, exp - 1).unwrap().lease, LeaseState::Warning { exp: Some(exp) });
604        assert!(matches!(r.verify(&token, exp).unwrap_err(), AdmitError::Expired { .. }));
605    }
606
607    #[test]
608    fn tampered_and_wrong_assertion_artifacts_are_refused() {
609        let r = rig();
610        let a = r.admit(&r.device, Some(&r.binding), Confirmation::Accept);
611        // Signed by a key other than the one the payload names.
612        let other = kp();
613        assert!(matches!(r.verify(&r.sign(&other, &a), IAT).unwrap_err(), AdmitError::Signature(_)));
614        // A flipped signature byte.
615        let token = r.sign(&r.device, &a);
616        let mut bytes = token.into_bytes();
617        let i = bytes.len() - 10;
618        bytes[i] = if bytes[i] == b'A' { b'B' } else { b'A' };
619        assert!(r.verify(&String::from_utf8(bytes).unwrap(), IAT).is_err());
620        // The right key, the wrong artifact kind.
621        let as_knock = sign_with(&r.device.secret, "dev", &a, Knock::IMPLICIT_ASSERTION);
622        assert!(matches!(r.verify(&as_knock, IAT).unwrap_err(), AdmitError::Signature(_)));
623    }
624
625    /// Publish a revocation set naming `jti` to the rig's replica.
626    fn revoke(r: &Rig, jti: &str) {
627        let entries = [Revoked::Jti { jti: jti.into(), exp: Some(IAT + 7 * DAY) }]
628            .iter()
629            .map(|e| revocation_entry(e, test_revocation_key))
630            .collect();
631        let set = RevocationSet::new(ISS, 5, entries);
632        let t = sign_with(&r.issuer.secret, "iss-1", &set, RevocationSet::IMPLICIT_ASSERTION);
633        pollster::block_on(r.replica.adopt(&t)).unwrap();
634    }
635
636    #[test]
637    fn a_revoked_admit_jti_is_refused() {
638        let r = rig();
639        let token = r.sign(&r.device, &r.admit(&r.device, Some(&r.binding), Confirmation::Accept));
640        revoke(&r, "admit-1");
641        assert!(matches!(r.verify(&token, IAT).unwrap_err(), AdmitError::Revoked { .. }));
642    }
643
644    /// One admit that breaks every link at once, repaired a link at a time:
645    /// each repair surfaces the next check, so the issuer path refuses in
646    /// chain order with the same variants (R734-F7).
647    #[test]
648    fn refusals_keep_the_chain_order() {
649        let r = rig();
650        let k = kp();
651        let users = |epoch, alice: &str| {
652            let policy = AdmissionPolicy::new(AdmissionMode::Users, "guest");
653            r.snapshot_with(epoch, vec![member(PrincipalId::user("alice"), alice)], Some(policy));
654        };
655        users(11, "guest");
656        revoke(&r, "admit-1");
657        let mut a = r.admit(&r.device, Some(&r.binding), Confirmation::Accept);
658        a.authority = "https://rogue.test".into();
659        a.requester = key_of(&k);
660        a.requester_binding = Some(r.bind(&r.issuer.secret, "bob", &kp()));
661        a.lease = Lease::new(IAT, IAT + DAY, Some(IAT + 8 * DAY)).unwrap();
662        let exp = IAT + 8 * DAY;
663        let refuse = |a: &Admit, now| r.verify(&r.sign(&r.device, a), now).unwrap_err();
664
665        let err = refuse(&a, exp);
666        assert!(matches!(err, AdmitError::WrongAuthority { .. }), "got {err:?}");
667        a.authority = ISS.into();
668        let err = refuse(&a, exp);
669        assert!(matches!(err, AdmitError::Expired { .. }), "got {err:?}");
670        let err = refuse(&a, IAT);
671        assert!(matches!(err, AdmitError::Revoked { .. }), "got {err:?}");
672        a.jti = "admit-2".into();
673        let err = refuse(&a, IAT);
674        assert!(matches!(err, AdmitError::NotAnApprover { .. }), "got {err:?}");
675        users(12, "owner");
676        let err = refuse(&a, IAT);
677        assert!(matches!(err, AdmitError::Standing(StandingError::PeerKeyMismatch)), "got {err:?}");
678        a.requester_binding = Some(r.bind(&r.issuer.secret, "bob", &k));
679        let err = refuse(&a, IAT);
680        assert!(matches!(err, AdmitError::PolicyRefused { reason: AdmissionRefusal::LeaseOverMax { .. }, .. }), "got {err:?}");
681        a.lease = Lease::new(IAT, IAT + DAY, Some(IAT + 7 * DAY)).unwrap();
682        let ok = r.verify(&r.sign(&r.device, &a), IAT).unwrap();
683        assert_eq!(ok.requester_user, Some(UserId::new("bob")));
684        assert_eq!(ok.approval.epoch, 12);
685    }
686
687    /// A root other than the issuer: a roster of device keys held under its
688    /// own root key, the way noisetable roots a room in its creator. `enroll`
689    /// carries the admit ability; it binds no users.
690    #[derive(Clone)]
691    struct Roster {
692        root: String,
693        members: BTreeMap<PrincipalId, String>,
694        policy: Option<AdmissionPolicy>,
695        epoch: u64,
696    }
697
698    #[async_trait::async_trait]
699    impl AdmitAuthority for Roster {
700        async fn approval_at(
701            &self,
702            approver: &PrincipalId,
703            _approver_binding: Option<&str>,
704            kind: &str,
705            id: &str,
706            _now: i64,
707        ) -> Result<Approval, AdmitError> {
708            let not_an_approver = || AdmitError::NotAnApprover { approver: approver.clone(), kind: kind.into(), id: id.into() };
709            if (kind, id) != ("room", self.root.as_str()) {
710                return Err(not_an_approver());
711            }
712            let relation = self.members.get(approver).filter(|r| *r == "enroll").cloned().ok_or_else(not_an_approver)?;
713            Ok(Approval { user: None, relation, policy: self.policy.clone(), epoch: self.epoch })
714        }
715
716        async fn requester_user_at(&self, _requester: &PeerKey, _binding: &str, _now: i64) -> Result<UserId, AdmitError> {
717            Err(AdmitError::Standing(StandingError::PeerKeyMismatch))
718        }
719    }
720
721    /// Revocations a non-issuer root keeps itself: a plain jti set.
722    struct Jtis(std::collections::BTreeSet<String>);
723
724    #[async_trait::async_trait]
725    impl RevocationReader for Jtis {
726        async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError> {
727            Ok(self.0.contains(jti))
728        }
729
730        async fn is_device_revoked(&self, _device: &DeviceId, _seq: u64) -> Result<bool, StoreError> {
731            Ok(false)
732        }
733
734        async fn is_membership_revoked(
735            &self,
736            _key: &RevocationKey,
737            _kind: &str,
738            _id: &str,
739            _principal: &PrincipalId,
740            _snapshot_epoch: u64,
741        ) -> Result<bool, StoreError> {
742            Ok(false)
743        }
744    }
745
746    #[test]
747    fn a_non_issuer_root_verifies_an_offline_admit() {
748        let (root, enroller, stranger) = (kp(), kp(), kp());
749        let room = URL_SAFE_NO_PAD.encode(pub_bytes(&root));
750        let authority = format!("room:{room}");
751        let roster = Roster {
752            root: room.clone(),
753            members: BTreeMap::from([(key_of(&enroller), "enroll".to_owned()), (key_of(&stranger), "guest".to_owned())]),
754            policy: Some(AdmissionPolicy::new(AdmissionMode::Knock, "guest")),
755            epoch: 3,
756        };
757        let none = Jtis(Default::default());
758        let admit = |signer: &AsymmetricKeyPair<V4>, authority: &str| {
759            let a = Admit {
760                approver: key_of(signer),
761                approver_binding: None,
762                authority: authority.into(),
763                requester: key_of(&kp()),
764                requester_binding: None,
765                kind: "room".into(),
766                id: room.clone(),
767                relation: "guest".into(),
768                source: AdmitSource::Knock(artifact_hash("knock-token")),
769                confirmation: Confirmation::Compare,
770                epoch: 3,
771                jti: "room-admit-1".into(),
772                iat: IAT,
773                lease: Lease::new(IAT, IAT + DAY, Some(IAT + 7 * DAY)).unwrap(),
774            };
775            let token = sign_with(&signer.secret, "dev", &a, Admit::IMPLICIT_ASSERTION);
776            (a, token)
777        };
778        let verify = |roster: &Roster, revocations: &Jtis, token: &str| {
779            pollster::block_on(verify_admit_with(roster, &authority, revocations, token, IAT + 1))
780        };
781
782        let (a, token) = admit(&enroller, &authority);
783        let ok = verify(&roster, &none, &token).unwrap();
784        assert_eq!(ok.admit, a);
785        assert_eq!(ok.approval, Approval { user: None, relation: "enroll".into(), policy: roster.policy.clone(), epoch: 3 });
786        assert_eq!(ok.requester_user, None);
787        assert_eq!(ok.lease, LeaseState::Current);
788
789        // The admit must name this root, not the cheers issuer.
790        let (_, token) = admit(&enroller, ISS);
791        let err = verify(&roster, &none, &token).unwrap_err();
792        assert!(matches!(&err, AdmitError::WrongAuthority { expected, .. } if *expected == authority), "got {err:?}");
793        // A key the roster holds without the admit ability.
794        let (_, token) = admit(&stranger, &authority);
795        let err = verify(&roster, &none, &token).unwrap_err();
796        assert!(matches!(err, AdmitError::NotAnApprover { .. }), "got {err:?}");
797        // The root's own revocations.
798        let (_, token) = admit(&enroller, &authority);
799        let revoked = Jtis(["room-admit-1".to_owned()].into());
800        let err = verify(&roster, &revoked, &token).unwrap_err();
801        assert!(matches!(err, AdmitError::Revoked { .. }), "got {err:?}");
802        // The policy the roster holds now decides: `users` refuses a key-only admit.
803        let users = Roster { policy: Some(AdmissionPolicy::new(AdmissionMode::Users, "guest")), ..roster.clone() };
804        let err = verify(&users, &none, &token).unwrap_err();
805        assert!(matches!(err, AdmitError::PolicyRefused { .. }), "got {err:?}");
806    }
807
808    /// alice admits key `k`, presenting `binding` as k's standing binding,
809    /// under a `users`-mode policy.
810    fn users_mode_admit(r: &Rig, k: &AsymmetricKeyPair<V4>, binding: String) -> String {
811        r.snapshot_with(
812            11,
813            vec![member(PrincipalId::user("alice"), "owner")],
814            Some(AdmissionPolicy::new(AdmissionMode::Users, "guest")),
815        );
816        let mut a = r.admit(&r.device, Some(&r.binding), Confirmation::Accept);
817        a.requester = key_of(k);
818        a.requester_binding = Some(binding);
819        r.sign(&r.device, &a)
820    }
821
822    #[test]
823    fn users_mode_admits_a_requester_with_its_own_binding() {
824        let r = rig();
825        let k = kp();
826        let token = users_mode_admit(&r, &k, r.bind(&r.issuer.secret, "bob", &k));
827        let ok = r.verify(&token, IAT).unwrap();
828        assert_eq!(ok.requester_user, Some(UserId::new("bob")));
829        assert_eq!(ok.admit.requester, key_of(&k));
830    }
831
832    #[test]
833    fn a_requester_binding_for_another_key_is_refused() {
834        let r = rig();
835        let (k, other) = (kp(), kp());
836        let token = users_mode_admit(&r, &k, r.bind(&r.issuer.secret, "bob", &other));
837        let err = r.verify(&token, IAT).unwrap_err();
838        assert!(matches!(err, AdmitError::Standing(StandingError::PeerKeyMismatch)), "got {err:?}");
839    }
840
841    #[test]
842    fn a_requester_binding_from_an_untrusted_issuer_is_refused() {
843        let r = rig();
844        let k = kp();
845        let rogue = kp();
846        let token = users_mode_admit(&r, &k, r.bind(&rogue.secret, "bob", &k));
847        let err = r.verify(&token, IAT).unwrap_err();
848        assert!(matches!(err, AdmitError::Standing(StandingError::Verify(_))), "got {err:?}");
849    }
850
851    #[test]
852    fn a_knock_verifies_under_its_requester_key() {
853        let k = kp();
854        let knock = Knock {
855            requester: key_of(&k),
856            kind: "namespace".into(),
857            id: "ed".into(),
858            relation: "guest".into(),
859            nonce: "n".into(),
860            label: "Ann's phone".into(),
861            standing: None,
862            renews: Some("admit-1".into()),
863            iat: IAT,
864        };
865        let t = sign_with(&k.secret, "dev", &knock, Knock::IMPLICIT_ASSERTION);
866        assert_eq!(verify_device_artifact::<Knock>(&t).unwrap(), knock);
867        assert!(verify_device_artifact::<Admit>(&t).is_err());
868    }
869}