Skip to main content

cheers_verify/
standing.rs

1//! [`StandingVerifier`] — the edge check for a standing node binding
2//! (R732-F5; noisetable W235 §0.1, §5).
3//!
4//! A [`StandingBinding`] has no expiry. The edge admits 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 binding's own implicit assertion, so
8//!    no access token verifies here and no binding verifies as one;
9//! 2. its `peer_key` is the key the connecting peer proved in its transport
10//!    handshake;
11//! 3. no newer binding for the same device has been seen ([`BindingLedger`]);
12//! 4. neither its `jti` nor its `device` at its `seq` is revoked ([`RevocationReader`] —
13//!    `ReplicatedRevocations` on an offline edge).
14//!
15//! `refresh_after` is advisory: it sets [`VerifiedStanding::lease`] and
16//! never refuses. The clock is used for nothing else.
17//!
18//! # Supersession
19//!
20//! The issuer stamps every binding with a per-device `seq` that only rises
21//! (`max(prev + 1, now)`, clock-floored like the revocation epoch). The ledger
22//! keeps the highest `seq` seen per device from **any** genuine binding —
23//! presented by a peer or heard by gossip ([`StandingVerifier::adopt`]) — and
24//! never lowers it, whatever order bindings arrive in. A binding below its
25//! device's highest is superseded. Nothing compares `iat`.
26//!
27//! # Offline restart
28//!
29//! An edge persists three documents beside the binding tokens it holds:
30//! [`IssuerTrust::export`] (the keys), [`BindingLedger::export`] (the
31//! supersession high-water marks) and `ReplicatedRevocations::export` (the
32//! revocation set). At boot it rebuilds the trust with
33//! [`IssuerTrust::from_doc`] — never from a JWKS fetch it may not be able to
34//! make — imports the ledger, re-adopts the set, and verifies as before.
35//!
36//! **Key rotation rule.** Pre-publish a new issuer kid in the JWKS before it
37//! signs anything, and keep a retired kid published (verify-only) until every
38//! standing binding it signed has been superseded or revoked. Removing a kid
39//! from the JWKS means "this key is compromised" and deliberately invalidates
40//! every binding it signed. Under that rule a fetched JWKS never lacks a kid a
41//! held credential needs, so an edge may always replace its persisted trust
42//! with what it fetched; an edge that cannot fetch keeps verifying against what
43//! it persisted. Retiring a kid therefore never strands a working offline edge.
44
45use std::collections::{BTreeMap, HashMap};
46use std::sync::{PoisonError, RwLock};
47
48use serde::{Deserialize, Serialize};
49
50use cheers_core::{DeviceId, LeaseError, LeaseState, PeerKey, StandingBinding, StoreError};
51
52use crate::artifact::IssuerTrust;
53use crate::key_set::VerifyError;
54use crate::revocation::RevocationReader;
55
56/// Why [`StandingVerifier::verify_standing_at`] refused a binding. Collapse to
57/// one refusal on the wire; the variants are for logs and audit.
58#[derive(Debug, thiserror::Error)]
59pub enum StandingError {
60    /// Not a standing binding signed for the trusted issuer: bad signature, an
61    /// access token or other artifact offered as one, a non-issuer-role key, a
62    /// foreign issuer.
63    #[error("standing binding refused: {0}")]
64    Verify(#[from] VerifyError),
65
66    /// Genuine, but it names a different node key than the peer proved.
67    #[error("standing binding is not bound to the presented peer key")]
68    PeerKeyMismatch,
69
70    /// A newer binding for the same device exists.
71    #[error("standing binding for device {device:?} is superseded: seq {offered} is below {newest}")]
72    Superseded { device: DeviceId, newest: u64, offered: u64 },
73
74    /// The binding's own `jti` is in the revocation set.
75    #[error("standing binding {jti:?} is revoked")]
76    Revoked { jti: String },
77
78    /// The binding's device is in the revocation set.
79    #[error("device {device:?} is revoked")]
80    DeviceRevoked { device: DeviceId },
81
82    /// Signed, but its lease breaks the invariant (`cheers_core::Lease`).
83    #[error("standing binding refused: {0}")]
84    Lease(#[from] LeaseError),
85
86    /// The revocation reader failed.
87    #[error(transparent)]
88    Store(#[from] StoreError),
89}
90
91/// An admitted binding.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct VerifiedStanding {
94    pub binding: StandingBinding,
95    /// The binding's lease at the verification instant: on `Warning`, fetch a
96    /// successor when the issuer is reachable. Advisory — the binding was
97    /// admitted either way (it carries no `exp` today).
98    pub lease: LeaseState,
99}
100
101/// A ledger import ([`BindingLedger`], `SnapshotLedger`) that names a
102/// different issuer.
103#[derive(Debug, thiserror::Error)]
104#[error("ledger belongs to issuer {got:?}, not {expected:?}")]
105pub struct ForeignLedger {
106    pub expected: String,
107    pub got: String,
108}
109
110/// A persisted [`BindingLedger`]: the highest `seq` seen per device id.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct LedgerDoc {
113    pub issuer: String,
114    pub newest: BTreeMap<String, u64>,
115}
116
117/// The highest binding `seq` seen per device, for one issuer.
118///
119/// Holds only high-water marks — never a token — so it stays one integer per
120/// device however many bindings pass through, and it needs no key to restore:
121/// a key rotation cannot erase what it knows. Every update is a `max`, so
122/// adopting a lower `seq` after a higher one, or importing an older export,
123/// cannot un-supersede anything.
124#[derive(Debug)]
125pub struct BindingLedger {
126    issuer: String,
127    newest: RwLock<HashMap<DeviceId, u64>>,
128}
129
130impl BindingLedger {
131    pub fn new(issuer: impl Into<String>) -> Self {
132        Self {
133            issuer: issuer.into(),
134            newest: RwLock::new(HashMap::new()),
135        }
136    }
137
138    pub fn issuer(&self) -> &str {
139        &self.issuer
140    }
141
142    /// The highest `seq` seen for `device`.
143    pub fn newest(&self, device: &DeviceId) -> Option<u64> {
144        self.newest
145            .read()
146            .unwrap_or_else(PoisonError::into_inner)
147            .get(device)
148            .copied()
149    }
150
151    /// Record that a genuine binding with `seq` exists for `device` and return
152    /// the device's highest `seq` afterwards. Never lowers it.
153    pub fn observe(&self, device: &DeviceId, seq: u64) -> u64 {
154        let mut newest = self.newest.write().unwrap_or_else(PoisonError::into_inner);
155        let held = newest.entry(device.clone()).or_insert(seq);
156        *held = (*held).max(seq);
157        *held
158    }
159
160    /// Every high-water mark, for an edge to persist.
161    pub fn export(&self) -> LedgerDoc {
162        let newest = self.newest.read().unwrap_or_else(PoisonError::into_inner);
163        LedgerDoc {
164            issuer: self.issuer.clone(),
165            newest: newest.iter().map(|(d, s)| (d.as_str().to_owned(), *s)).collect(),
166        }
167    }
168
169    /// Merge a persisted ledger in, keeping the higher `seq` per device.
170    pub fn import(&self, doc: &LedgerDoc) -> Result<(), ForeignLedger> {
171        if doc.issuer != self.issuer {
172            return Err(ForeignLedger {
173                expected: self.issuer.clone(),
174                got: doc.issuer.clone(),
175            });
176        }
177        for (device, seq) in &doc.newest {
178            self.observe(&DeviceId::new(device.clone()), *seq);
179        }
180        Ok(())
181    }
182}
183
184/// The edge facade for standing node bindings: trust, revocation replica and
185/// supersession ledger for one issuer. Like `EdgeVerifier` it holds no minter.
186pub struct StandingVerifier<Rd> {
187    trust: IssuerTrust,
188    revoked: Rd,
189    ledger: BindingLedger,
190}
191
192impl<Rd> std::fmt::Debug for StandingVerifier<Rd> {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("StandingVerifier")
195            .field("trust", &self.trust)
196            .field("ledger", &self.ledger)
197            .finish_non_exhaustive()
198    }
199}
200
201impl<Rd: RevocationReader> StandingVerifier<Rd> {
202    /// A verifier with an empty ledger; [`import`](BindingLedger::import) a
203    /// persisted one through [`ledger`](Self::ledger) after a restart.
204    pub fn new(trust: IssuerTrust, revoked: Rd) -> Self {
205        let ledger = BindingLedger::new(trust.issuer());
206        Self { trust, revoked, ledger }
207    }
208
209    pub fn trust(&self) -> &IssuerTrust {
210        &self.trust
211    }
212
213    pub fn revocations(&self) -> &Rd {
214        &self.revoked
215    }
216
217    pub fn ledger(&self) -> &BindingLedger {
218        &self.ledger
219    }
220
221    /// Verify a binding that arrived without a peer — by gossip, or the edge's
222    /// own persisted one at boot — and record its `seq`, so it supersedes
223    /// older bindings for its device. Checks the signature only: whether the
224    /// binding is revoked or names this peer does not change that the issuer
225    /// minted it.
226    pub async fn adopt(&self, token: &str) -> Result<StandingBinding, VerifyError> {
227        let binding: StandingBinding = self.trust.verify(token).await?;
228        self.ledger.observe(&binding.device, binding.seq);
229        Ok(binding)
230    }
231
232    /// Admit `token` as a standing binding for the peer that proved
233    /// `presented` in its handshake.
234    ///
235    /// In order, cheapest and most local first: signature (recording the
236    /// binding's `seq` in the ledger), peer key, supersession, then the
237    /// revocation reads (`jti`, then device) — the only I/O. `now` decides
238    /// [`VerifiedStanding::lease`] and nothing else: there is no expiry.
239    pub async fn verify_standing_at(
240        &self,
241        token: &str,
242        presented: &PeerKey,
243        now: i64,
244    ) -> Result<VerifiedStanding, StandingError> {
245        let binding: StandingBinding = self.trust.verify(token).await?;
246        binding.lease.validate(binding.iat)?;
247        let newest = self.ledger.observe(&binding.device, binding.seq);
248        if !binding.is_bound_to(presented) {
249            return Err(StandingError::PeerKeyMismatch);
250        }
251        if newest > binding.seq {
252            return Err(StandingError::Superseded {
253                device: binding.device,
254                newest,
255                offered: binding.seq,
256            });
257        }
258        if self.revoked.is_revoked(&binding.jti).await? {
259            return Err(StandingError::Revoked { jti: binding.jti });
260        }
261        if self.revoked.is_device_revoked(&binding.device, binding.seq).await? {
262            return Err(StandingError::DeviceRevoked { device: binding.device });
263        }
264        let lease = binding.lease.state_at(now);
265        Ok(VerifiedStanding { binding, lease })
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use cheers_core::Lease;
273    use crate::artifact::{AnchorDoc, TrustDoc};
274    use crate::jwks::{JwkKey, JwksDoc, KeySet};
275    use crate::key_set::KeySetVerifier;
276    use crate::public_verifier::PasetoV4PublicVerifier;
277    use crate::revocation::ReplicatedRevocations;
278    use cheers_core::{
279        Claims, DeviceBinding, KeyRole, McpClaims, PrincipalId, RevocationSet, Revoked, SignedArtifact, TokenVerifier,
280        UserId,
281    };
282    use pasetors::keys::{AsymmetricKeyPair, AsymmetricSecretKey, Generate};
283    use pasetors::version4::{PublicToken, V4};
284    use std::sync::Arc;
285
286    const ISS: &str = "https://cheers.test";
287    const DEVICE: &str = "node:a";
288    const IAT: i64 = 1_000;
289    /// Ten years on: far past any access-token window.
290    const YEARS_LATER: i64 = IAT + 10 * 365 * 24 * 60 * 60;
291
292    fn kp() -> AsymmetricKeyPair<V4> {
293        AsymmetricKeyPair::<V4>::generate().unwrap()
294    }
295
296    fn pub_bytes(k: &AsymmetricKeyPair<V4>) -> [u8; 32] {
297        k.public.as_bytes().try_into().unwrap()
298    }
299
300    fn node(b: u8) -> PeerKey {
301        PeerKey::ed25519([b; 32])
302    }
303
304    /// Sign `payload` the way `PasetoV4SecretMinter::mint_artifact` does.
305    fn sign_with(secret: &AsymmetricSecretKey<V4>, kid: &str, payload: &impl Serialize, ia: Option<&[u8]>) -> String {
306        let body = serde_json::to_vec(payload).unwrap();
307        let footer = format!(r#"{{"kid":"{kid}"}}"#);
308        PublicToken::sign(secret, &body, Some(footer.as_bytes()), ia).unwrap()
309    }
310
311    fn binding(seq: u64, key: PeerKey) -> StandingBinding {
312        StandingBinding {
313            issuer: ISS.into(),
314            sub: UserId::new("alice"),
315            device: DeviceId::new(DEVICE),
316            peer_key: key,
317            seq,
318            iat: IAT,
319            jti: format!("bind-{seq}"),
320            lease: Lease::new(IAT, IAT + 7 * 24 * 60 * 60, None).unwrap(),
321        }
322    }
323
324    struct Rig {
325        issuer: AsymmetricKeyPair<V4>,
326        assertion: AsymmetricKeyPair<V4>,
327        selfsig: AsymmetricKeyPair<V4>,
328        doc: JwksDoc,
329        replica: Arc<ReplicatedRevocations>,
330        edge: StandingVerifier<Arc<ReplicatedRevocations>>,
331    }
332
333    impl Rig {
334        fn sign(&self, b: &StandingBinding) -> String {
335            sign_with(&self.issuer.secret, "iss-1", b, Some(StandingBinding::IMPLICIT_ASSERTION))
336        }
337
338        fn revoke(&self, epoch: u64, revoked: Vec<Revoked>) {
339            let entries = revoked.iter().map(|e| crate::revocation_entry(e, crate::test_revocation_key)).collect();
340            let set = RevocationSet::new(ISS, epoch, entries);
341            let token = sign_with(&self.issuer.secret, "iss-1", &set, Some(RevocationSet::IMPLICIT_ASSERTION));
342            pollster::block_on(self.replica.adopt(&token)).unwrap();
343        }
344
345        fn verify(&self, token: &str, key: &PeerKey, now: i64) -> Result<VerifiedStanding, StandingError> {
346            pollster::block_on(self.edge.verify_standing_at(token, key, now))
347        }
348    }
349
350    fn rig() -> Rig {
351        let (issuer, assertion, selfsig) = (kp(), kp(), kp());
352        let doc = JwksDoc {
353            keys: vec![
354                JwkKey::ed25519("iss-1", &pub_bytes(&issuer), ISS, KeyRole::Issuer),
355                JwkKey::ed25519("asr-1", &pub_bytes(&assertion), "svc:issues", KeyRole::Assertion),
356                JwkKey::ed25519("self-1", &pub_bytes(&selfsig), "svc:issues", KeyRole::SelfSigner),
357            ],
358        };
359        let keys = KeySetVerifier::from_key_set(KeySet::from_doc(doc.clone()).unwrap());
360        let trust = IssuerTrust::key_set(ISS, keys.clone());
361        let replica = Arc::new(ReplicatedRevocations::new(trust.clone()));
362        Rig {
363            edge: StandingVerifier::new(trust, replica.clone()),
364            replica,
365            issuer,
366            assertion,
367            selfsig,
368            doc,
369        }
370    }
371
372    #[test]
373    fn a_binding_verifies_years_after_any_access_window() {
374        let r = rig();
375        let token = r.sign(&binding(5, node(1)));
376        let ok = r.verify(&token, &node(1), YEARS_LATER).unwrap();
377        assert_eq!(ok.binding, binding(5, node(1)));
378        assert_eq!(ok.lease, LeaseState::Warning { exp: None });
379    }
380
381    #[test]
382    fn refresh_after_in_the_past_still_verifies() {
383        let r = rig();
384        let b = binding(5, node(1));
385        let token = r.sign(&b);
386        let ra = b.lease.refresh_after();
387        let warn = LeaseState::Warning { exp: None };
388        assert_eq!(r.verify(&token, &node(1), ra - 1).unwrap().lease, LeaseState::Current);
389        assert_eq!(r.verify(&token, &node(1), ra).unwrap().lease, warn);
390        assert_eq!(r.verify(&token, &node(1), ra + 1).unwrap().lease, warn);
391    }
392
393    #[test]
394    fn a_lease_past_its_midpoint_is_refused() {
395        let r = rig();
396        // Forged past the midpoint: only deserialization can build it.
397        let mut v = serde_json::to_value(binding(5, node(1))).unwrap();
398        v["refresh_after"] = (IAT + 600).into();
399        v["exp"] = (IAT + 1_000).into();
400        let forged: StandingBinding = serde_json::from_value(v).unwrap();
401        let token = r.sign(&forged);
402        let err = r.verify(&token, &node(1), IAT).unwrap_err();
403        assert!(matches!(err, StandingError::Lease(_)), "got {err:?}");
404        // At the midpoint it verifies and reports the window.
405        let mut v = serde_json::to_value(binding(5, node(1))).unwrap();
406        v["refresh_after"] = (IAT + 500).into();
407        v["exp"] = (IAT + 1_000).into();
408        let ok: StandingBinding = serde_json::from_value(v).unwrap();
409        let token = r.sign(&ok);
410        assert_eq!(r.verify(&token, &node(1), IAT + 500).unwrap().lease, LeaseState::Warning { exp: Some(IAT + 1_000) });
411    }
412
413    #[test]
414    fn a_wrong_peer_key_is_refused() {
415        let r = rig();
416        let token = r.sign(&binding(5, node(1)));
417        assert!(matches!(r.verify(&token, &node(2), IAT), Err(StandingError::PeerKeyMismatch)));
418    }
419
420    #[test]
421    fn a_revoked_jti_is_refused() {
422        let r = rig();
423        let token = r.sign(&binding(5, node(1)));
424        r.verify(&token, &node(1), IAT).unwrap();
425        r.revoke(1, vec![Revoked::jti("bind-5", None)]);
426        match r.verify(&token, &node(1), IAT) {
427            Err(StandingError::Revoked { jti }) => assert_eq!(jti, "bind-5"),
428            other => panic!("expected Revoked, got {other:?}"),
429        }
430    }
431
432    #[test]
433    fn a_revoked_device_is_refused() {
434        let r = rig();
435        let token = r.sign(&binding(5, node(1)));
436        r.revoke(1, vec![Revoked::device("node:other", 100)]);
437        r.verify(&token, &node(1), IAT).unwrap();
438        // A revoke at seq 5 masks bindings below 5 only; this one is at 5.
439        r.revoke(2, vec![Revoked::device(DEVICE, 5)]);
440        r.verify(&token, &node(1), IAT).unwrap();
441        r.revoke(3, vec![Revoked::device(DEVICE, 6)]);
442        match r.verify(&token, &node(1), YEARS_LATER) {
443            Err(StandingError::DeviceRevoked { device }) => assert_eq!(device.as_str(), DEVICE),
444            other => panic!("expected DeviceRevoked, got {other:?}"),
445        }
446        // Re-enrolling the machine mints a seq above the revoke: admitted.
447        let reenrolled = r.sign(&binding(7, node(1)));
448        r.verify(&reenrolled, &node(1), YEARS_LATER).unwrap();
449    }
450
451    #[test]
452    fn a_superseded_binding_is_refused_and_its_successor_accepted() {
453        let r = rig();
454        let old = r.sign(&binding(5, node(1)));
455        // Re-enrolled under a new node key: higher seq, same device.
456        let new = r.sign(&binding(7, node(2)));
457        r.verify(&old, &node(1), IAT).unwrap();
458        r.verify(&new, &node(2), IAT).unwrap();
459        match r.verify(&old, &node(1), IAT) {
460            Err(StandingError::Superseded { device, newest: 7, offered: 5 }) => assert_eq!(device.as_str(), DEVICE),
461            other => panic!("expected Superseded, got {other:?}"),
462        }
463        r.verify(&new, &node(2), YEARS_LATER).unwrap();
464        // Other devices are untouched.
465        let mut elsewhere = binding(1, node(3));
466        elsewhere.device = DeviceId::new("node:b");
467        r.verify(&r.sign(&elsewhere), &node(3), IAT).unwrap();
468    }
469
470    #[test]
471    fn a_successor_heard_by_gossip_supersedes_without_being_presented() {
472        let r = rig();
473        let old = r.sign(&binding(5, node(1)));
474        pollster::block_on(r.edge.adopt(&r.sign(&binding(6, node(1))))).unwrap();
475        assert!(matches!(r.verify(&old, &node(1), IAT), Err(StandingError::Superseded { .. })));
476    }
477
478    #[test]
479    fn a_lower_sequence_adopted_after_a_higher_one_does_not_unsupersede() {
480        let r = rig();
481        let middle = r.sign(&binding(7, node(1)));
482        pollster::block_on(r.edge.adopt(&r.sign(&binding(9, node(1))))).unwrap();
483        pollster::block_on(r.edge.adopt(&r.sign(&binding(3, node(1))))).unwrap();
484        assert_eq!(r.edge.ledger().newest(&DeviceId::new(DEVICE)), Some(9));
485        assert!(matches!(
486            r.verify(&middle, &node(1), IAT),
487            Err(StandingError::Superseded { newest: 9, offered: 7, .. })
488        ));
489        // An older persisted ledger imported later cannot lower it either.
490        let stale = LedgerDoc {
491            issuer: ISS.into(),
492            newest: [(DEVICE.to_owned(), 2)].into_iter().collect(),
493        };
494        r.edge.ledger().import(&stale).unwrap();
495        assert_eq!(r.edge.ledger().newest(&DeviceId::new(DEVICE)), Some(9));
496    }
497
498    #[test]
499    fn a_binding_is_refused_as_an_access_token() {
500        let r = rig();
501        let token = r.sign(&binding(5, node(1)));
502        let keys = KeySetVerifier::from_key_set(KeySet::from_doc(r.doc.clone()).unwrap());
503        let as_mcp = pollster::block_on(keys.verify::<serde_json::Value>(&token, IAT, ISS, None));
504        assert!(matches!(as_mcp, Err(VerifyError::SignatureMismatch)));
505        let pinned = PasetoV4PublicVerifier::from_public_key(&pub_bytes(&r.issuer)).unwrap();
506        assert!(pinned.verify_mcp_at(&token, IAT, "iss-1").is_err());
507        assert!(pinned.verify_at(&token, IAT).is_err());
508    }
509
510    #[test]
511    fn an_access_token_or_other_artifact_is_refused_as_a_binding() {
512        let r = rig();
513        // The binding's own payload, signed with the access-token (empty) assertion.
514        let unasserted = sign_with(&r.issuer.secret, "iss-1", &binding(5, node(1)), None);
515        assert!(matches!(
516            r.verify(&unasserted, &node(1), IAT),
517            Err(StandingError::Verify(VerifyError::SignatureMismatch))
518        ));
519        // A real MCP access token.
520        let mcp = McpClaims::new(ISS, "https://aud.test", PrincipalId::user("alice"), IAT, IAT + 900, "j", vec![]);
521        let mcp = sign_with(&r.issuer.secret, "iss-1", &mcp, None);
522        assert!(matches!(r.verify(&mcp, &node(1), IAT), Err(StandingError::Verify(_))));
523        // A v4.public session token in the `cheers` claim convention.
524        let claims = Claims::new(UserId::new("alice"), DeviceId::new(DEVICE), DeviceBinding::LanPair, IAT, IAT + 900)
525            .with_peer_key(node(1));
526        let session = sign_with(&r.issuer.secret, "iss-1", &serde_json::json!({ "cheers": claims }), None);
527        assert!(matches!(r.verify(&session, &node(1), IAT), Err(StandingError::Verify(_))));
528        // Another artifact kind's assertion over a binding payload.
529        let cross = sign_with(&r.issuer.secret, "iss-1", &binding(5, node(1)), Some(RevocationSet::IMPLICIT_ASSERTION));
530        assert!(matches!(
531            r.verify(&cross, &node(1), IAT),
532            Err(StandingError::Verify(VerifyError::SignatureMismatch))
533        ));
534        assert_eq!(r.edge.ledger().newest(&DeviceId::new(DEVICE)), None);
535    }
536
537    #[test]
538    fn a_non_issuer_role_key_is_refused() {
539        let r = rig();
540        let b = binding(5, node(1));
541        let ia = Some(StandingBinding::IMPLICIT_ASSERTION);
542        for (secret, kid) in [(&r.assertion.secret, "asr-1"), (&r.selfsig.secret, "self-1")] {
543            let token = sign_with(secret, kid, &b, ia);
544            assert!(matches!(
545                r.verify(&token, &node(1), IAT),
546                Err(StandingError::Verify(VerifyError::KeyRoleRejected { .. }))
547            ));
548        }
549        // A binding claiming a foreign issuer, signed by the trusted key.
550        let mut foreign = b;
551        foreign.issuer = "https://elsewhere.test".into();
552        assert!(matches!(
553            r.verify(&r.sign(&foreign), &node(1), IAT),
554            Err(StandingError::Verify(VerifyError::BadIssuer { .. }))
555        ));
556        assert_eq!(r.edge.ledger().newest(&DeviceId::new(DEVICE)), None);
557    }
558
559    #[test]
560    fn ledger_export_import_round_trips() {
561        let r = rig();
562        let ledger = r.edge.ledger();
563        ledger.observe(&DeviceId::new("node:a"), 9);
564        ledger.observe(&DeviceId::new("node:b"), 4);
565        let json = serde_json::to_string(&ledger.export()).unwrap();
566
567        let back: LedgerDoc = serde_json::from_str(&json).unwrap();
568        assert_eq!(back, ledger.export());
569        let restored = BindingLedger::new(ISS);
570        restored.import(&back).unwrap();
571        assert_eq!(restored.export(), ledger.export());
572        assert_eq!(restored.newest(&DeviceId::new("node:a")), Some(9));
573        assert_eq!(restored.newest(&DeviceId::new("node:c")), None);
574
575        // Merge keeps the higher mark per device.
576        let other = BindingLedger::new(ISS);
577        other.observe(&DeviceId::new("node:a"), 12);
578        other.observe(&DeviceId::new("node:b"), 1);
579        restored.import(&other.export()).unwrap();
580        assert_eq!(restored.newest(&DeviceId::new("node:a")), Some(12));
581        assert_eq!(restored.newest(&DeviceId::new("node:b")), Some(4));
582
583        let foreign = BindingLedger::new("https://elsewhere.test");
584        assert!(restored.import(&foreign.export()).is_err());
585    }
586
587    #[test]
588    fn an_offline_restart_verifies_against_the_persisted_key_set() {
589        let r = rig();
590        let held = r.sign(&binding(7, node(1)));
591        let old = r.sign(&binding(5, node(1)));
592        r.verify(&held, &node(1), IAT).unwrap();
593        r.revoke(3, vec![Revoked::jti("bind-other", None)]);
594
595        // What the edge writes to disk.
596        let trust_json = serde_json::to_string(&r.edge.trust().export()).unwrap();
597        let ledger_json = serde_json::to_string(&r.edge.ledger().export()).unwrap();
598        let set = r.replica.export().unwrap();
599        drop(r.edge);
600
601        // Meanwhile the issuer rotated: a freshly fetched JWKS no longer has
602        // iss-1, so verifying against it would strand the node.
603        let rotated = JwksDoc {
604            keys: vec![JwkKey::ed25519("iss-2", &pub_bytes(&kp()), ISS, KeyRole::Issuer)],
605        };
606        let fetched = IssuerTrust::key_set(ISS, KeySetVerifier::from_key_set(KeySet::from_doc(rotated).unwrap()));
607        assert!(matches!(
608            pollster::block_on(fetched.verify::<StandingBinding>(&held)),
609            Err(VerifyError::UnknownKid(_))
610        ));
611
612        // Restart offline from the persisted documents alone.
613        let doc: TrustDoc = serde_json::from_str(&trust_json).unwrap();
614        assert!(matches!(doc.anchor, AnchorDoc::KeySet(_)));
615        let trust = IssuerTrust::from_doc(doc).unwrap();
616        let replica = Arc::new(ReplicatedRevocations::new(trust.clone()));
617        pollster::block_on(replica.adopt(&set)).unwrap();
618        let edge = StandingVerifier::new(trust, replica);
619        edge.ledger().import(&serde_json::from_str(&ledger_json).unwrap()).unwrap();
620
621        let ok = pollster::block_on(edge.verify_standing_at(&held, &node(1), YEARS_LATER)).unwrap();
622        assert_eq!(ok.binding.seq, 7);
623        // Supersession and the role rules survived the restart.
624        assert!(matches!(
625            pollster::block_on(edge.verify_standing_at(&old, &node(1), YEARS_LATER)),
626            Err(StandingError::Superseded { newest: 7, offered: 5, .. })
627        ));
628        let by_assertion = sign_with(&r.assertion.secret, "asr-1", &binding(8, node(1)), Some(StandingBinding::IMPLICIT_ASSERTION));
629        assert!(matches!(
630            pollster::block_on(edge.verify_standing_at(&by_assertion, &node(1), IAT)),
631            Err(StandingError::Verify(VerifyError::KeyRoleRejected { .. }))
632        ));
633    }
634
635    #[test]
636    fn a_pinned_trust_round_trips_through_its_document() {
637        let issuer = kp();
638        let pinned = IssuerTrust::pinned(ISS, PasetoV4PublicVerifier::from_public_key(&pub_bytes(&issuer)).unwrap());
639        let json = serde_json::to_string(&pinned.export()).unwrap();
640        let back = IssuerTrust::from_doc(serde_json::from_str(&json).unwrap()).unwrap();
641        let token = sign_with(&issuer.secret, "any", &binding(1, node(1)), Some(StandingBinding::IMPLICIT_ASSERTION));
642        let edge = StandingVerifier::new(back, Arc::new(ReplicatedRevocations::new(pinned)));
643        pollster::block_on(edge.verify_standing_at(&token, &node(1), YEARS_LATER)).unwrap();
644
645        let short = TrustDoc {
646            issuer: ISS.into(),
647            anchor: AnchorDoc::Pinned("AAAA".into()),
648        };
649        assert!(IssuerTrust::from_doc(short).is_err());
650    }
651}