Skip to main content

cheers_server/
standing.rs

1//! The issuer half of the **standing node binding** (R732-F5; noisetable W235
2//! §0.1, §5).
3//!
4//! - [`BindingSequenceStore`] — the per-device sequence a binding is stamped
5//!   with. Advanced by [`next_binding_seq`]: `max(prev + 1, now)`, the same
6//!   clock floor as the revocation epoch, so an issuer restored from an older
7//!   backup still mints above the sequences edges already hold.
8//! - [`StandingBinder`] — signs [`StandingBinding`]s under the issuer key.
9//!   [`SessionAuthority`](crate::session::SessionAuthority) holds one and mints
10//!   a binding with every peer-key-bound `DeviceBinding::LanPair` session.
11//!
12//! The edge half is `cheers_verify::StandingVerifier`.
13
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex, PoisonError};
16
17use async_trait::async_trait;
18
19use cheers_core::{DeviceId, Error, Lease, PeerKey, StandingBinding, StoreError, UserId};
20
21use crate::codec::PasetoV4SecretMinter;
22use crate::session::generate_jti;
23
24/// The sequence a mint advances to: `max(prev + 1, now)`, or `max(1, now)` for
25/// a device's first binding. Strictly monotonic per device whatever the clock
26/// does; the floor keeps a restored-from-backup issuer above what edges hold.
27pub fn next_binding_seq(prev: Option<u64>, now: i64) -> u64 {
28    prev.unwrap_or(0).saturating_add(1).max(u64::try_from(now).unwrap_or(0))
29}
30
31/// Origin-side per-device binding sequence. Keyed by device alone: a device id
32/// re-bound to another user still supersedes its previous binding, which is
33/// what an edge must see when a node changes hands.
34#[async_trait]
35pub trait BindingSequenceStore: Send + Sync {
36    /// Advance `device`'s sequence by [`next_binding_seq`] and return it, as
37    /// one atomic step: two concurrent mints for one device never share a
38    /// sequence.
39    async fn next_binding_seq(&self, device: &DeviceId, now: i64) -> Result<u64, StoreError>;
40}
41
42#[async_trait]
43impl<T: BindingSequenceStore + ?Sized> BindingSequenceStore for Arc<T> {
44    async fn next_binding_seq(&self, device: &DeviceId, now: i64) -> Result<u64, StoreError> {
45        (**self).next_binding_seq(device, now).await
46    }
47}
48
49/// In-process sequences — tests and single-process deployments. Clones share
50/// one map.
51#[derive(Debug, Clone, Default)]
52pub struct MemoryBindingSequenceStore(Arc<Mutex<HashMap<DeviceId, u64>>>);
53
54impl MemoryBindingSequenceStore {
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// The sequence `device` is at, without advancing it.
60    pub fn current(&self, device: &DeviceId) -> Option<u64> {
61        self.0.lock().unwrap_or_else(PoisonError::into_inner).get(device).copied()
62    }
63}
64
65#[async_trait]
66impl BindingSequenceStore for MemoryBindingSequenceStore {
67    async fn next_binding_seq(&self, device: &DeviceId, now: i64) -> Result<u64, StoreError> {
68        let mut seqs = self.0.lock().unwrap_or_else(PoisonError::into_inner);
69        let next = next_binding_seq(seqs.get(device).copied(), now);
70        seqs.insert(device.clone(), next);
71        Ok(next)
72    }
73}
74
75/// A [`StandingBinding`] and its signed token.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct SignedStandingBinding {
78    pub binding: StandingBinding,
79    /// PASETO v4.public under the issuer key, with
80    /// [`StandingBinding::IMPLICIT_ASSERTION`](cheers_core::SignedArtifact).
81    pub token: String,
82}
83
84/// Mints standing bindings under the issuer key.
85///
86/// `kid` must be published in the JWKS with the **issuer** role; edges refuse
87/// any other. A kid that has signed bindings stays published until every one
88/// of them is superseded or revoked — removing it invalidates them (see
89/// `cheers_verify::standing`, "Key rotation rule").
90pub struct StandingBinder {
91    sequences: Arc<dyn BindingSequenceStore>,
92    minter: PasetoV4SecretMinter,
93    issuer: String,
94    kid: String,
95    refresh_after_seconds: i64,
96}
97
98impl std::fmt::Debug for StandingBinder {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("StandingBinder")
101            .field("issuer", &self.issuer)
102            .field("kid", &self.kid)
103            .field("refresh_after_seconds", &self.refresh_after_seconds)
104            .finish_non_exhaustive()
105    }
106}
107
108impl StandingBinder {
109    /// 7 days. Advisory: how long until a node that can reach the issuer
110    /// fetches a successor. It bounds how long an online fleet keeps bindings
111    /// signed by a kid that has stopped signing, never how long one is valid.
112    pub const DEFAULT_REFRESH_AFTER_SECONDS: i64 = 7 * 24 * 60 * 60;
113
114    pub fn new(
115        sequences: impl BindingSequenceStore + 'static,
116        minter: PasetoV4SecretMinter,
117        issuer: impl Into<String>,
118        kid: impl Into<String>,
119    ) -> Self {
120        Self {
121            sequences: Arc::new(sequences),
122            minter,
123            issuer: issuer.into(),
124            kid: kid.into(),
125            refresh_after_seconds: Self::DEFAULT_REFRESH_AFTER_SECONDS,
126        }
127    }
128
129    pub fn with_refresh_after(mut self, seconds: i64) -> Self {
130        self.refresh_after_seconds = seconds;
131        self
132    }
133
134    pub fn issuer(&self) -> &str {
135        &self.issuer
136    }
137
138    /// Consume `device`'s next sequence number as the bound of a
139    /// [`Revoked::Device`](cheers_core::Revoked::Device) entry (R732-T7).
140    /// Every binding minted before sits below it and every binding minted
141    /// after (re-enrollment) above it — one atomic advance, no read-then-write
142    /// race against a concurrent mint.
143    pub async fn revocation_seq(&self, device: &DeviceId, now: i64) -> Result<u64, Error> {
144        Ok(self.sequences.next_binding_seq(device, now).await?)
145    }
146
147    /// Bind `sub` to the node keyed `peer_key` as `device`: advance the
148    /// device's sequence, then sign. A failed sign leaves only a skipped
149    /// sequence number behind.
150    pub async fn mint(&self, sub: UserId, device: DeviceId, peer_key: PeerKey, now: i64) -> Result<SignedStandingBinding, Error> {
151        let seq = self.sequences.next_binding_seq(&device, now).await?;
152        let binding = StandingBinding {
153            issuer: self.issuer.clone(),
154            sub,
155            device,
156            peer_key,
157            seq,
158            iat: now,
159            jti: generate_jti(),
160            lease: Lease::new(now, now.saturating_add(self.refresh_after_seconds), None)?,
161        };
162        let token = self.minter.mint_artifact(&binding, &self.kid)?;
163        Ok(SignedStandingBinding { binding, token })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use cheers_verify::{IssuerTrust, ReplicatedRevocations, StandingVerifier};
171
172    #[test]
173    fn next_seq_is_clock_floored_and_strictly_monotonic() {
174        assert_eq!(next_binding_seq(None, 1_000), 1_000);
175        assert_eq!(next_binding_seq(None, -5), 1);
176        assert_eq!(next_binding_seq(Some(1_000), 1_000), 1_001);
177        // The clock went backwards: still above what was issued.
178        assert_eq!(next_binding_seq(Some(5_000), 1_000), 5_001);
179        // Restored from a backup that remembers only 10: the floor lifts the
180        // next sequence above anything minted before the clock read now.
181        assert_eq!(next_binding_seq(Some(10), 9_000), 9_000);
182        assert_eq!(next_binding_seq(Some(u64::MAX), 0), u64::MAX);
183    }
184
185    #[test]
186    fn memory_store_advances_per_device() {
187        pollster::block_on(async {
188            let store = MemoryBindingSequenceStore::new();
189            let (a, b) = (DeviceId::new("node:a"), DeviceId::new("node:b"));
190            assert_eq!(store.next_binding_seq(&a, 100).await.unwrap(), 100);
191            assert_eq!(store.next_binding_seq(&a, 100).await.unwrap(), 101);
192            assert_eq!(store.next_binding_seq(&a, 50).await.unwrap(), 102);
193            assert_eq!(store.next_binding_seq(&b, 50).await.unwrap(), 50);
194            assert_eq!(store.current(&a), Some(102));
195        });
196    }
197
198    #[test]
199    fn minted_binding_verifies_at_the_edge_and_carries_the_sequence() {
200        pollster::block_on(async {
201            let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
202            let store = MemoryBindingSequenceStore::new();
203            let binder = StandingBinder::new(store.clone(), minter, "https://c.test", "k1").with_refresh_after(60);
204            let key = PeerKey::ed25519([3; 32]);
205            let first = binder
206                .mint(UserId::new("alice"), DeviceId::new("node:a"), key.clone(), 1_000)
207                .await
208                .unwrap();
209            assert_eq!(first.binding.seq, 1_000);
210            assert_eq!(first.binding.lease.refresh_after(), 1_060);
211            assert!(!first.binding.jti.is_empty());
212
213            let trust = IssuerTrust::pinned("https://c.test", verifier);
214            let edge = StandingVerifier::new(trust.clone(), ReplicatedRevocations::new(trust));
215            let ok = edge.verify_standing_at(&first.token, &key, 1_000).await.unwrap();
216            assert_eq!(ok.binding, first.binding);
217
218            let second = binder
219                .mint(UserId::new("alice"), DeviceId::new("node:a"), key.clone(), 1_000)
220                .await
221                .unwrap();
222            assert_eq!(second.binding.seq, 1_001);
223            assert_ne!(second.binding.jti, first.binding.jti);
224        });
225    }
226}