Skip to main content

cheers_core/
revocation.rs

1//! The **revocation set** wire type (R732-F6; noisetable W235 §5.1, C6).
2//!
3//! Standing credentials — membership snapshots (C4) and node bindings (C5) —
4//! carry no edge-enforced expiry: offline admission lasts until *revoked*
5//! (W235 §0.1). A [`RevocationSet`] is therefore the only way one ends. cheers
6//! signs the current set under its issuer key; offline peers replicate it by
7//! gossip and keep the highest [`epoch`](RevocationSet::epoch) they have seen
8//! (`cheers_verify::ReplicatedRevocations`).
9//!
10//! The accepted cost: a removed member keeps a fully offline LAN until a set
11//! naming them reaches it.
12//!
13//! # Wire shape
14//!
15//! The PASETO payload is the JSON of [`RevocationSet`], signed with
16//! [`RevocationSet::IMPLICIT_ASSERTION`](crate::SignedArtifact) and a
17//! `{"kid": ...}` footer:
18//!
19//! ```json
20//! {"issuer":"https://cheers.example","epoch":1791234567,"revoked":[
21//!   {"jti":{"jti":"6f1c...","exp":1791235467}},
22//!   {"device":{"device":"phone-1","at_seq":1791234000}},
23//!   {"membership":{"tag":"q0Zp...","at_epoch":12}}]}
24//! ```
25//!
26//! # Membership privacy (R732-T10)
27//!
28//! The set is public, so a membership entry never names `(kind, id, user)`.
29//! The issuer stores plaintext [`Revoked`] rows; on publish each membership
30//! becomes a [`RevocationEntry::Membership`] carrying a [`MembershipTag`]:
31//! `HMAC-SHA256(key, ctx || len-prefixed kind, id, user)` under the
32//! resource's [`RevocationKey`] (`cheers_verify::membership_tag`). The key is
33//! random per `(kind, id)`, immutable once created, and reaches an edge only
34//! inside a signed [`SetSnapshot`](crate::SetSnapshot) — for the snapshot's
35//! own resource and every resource a member's `via` names. Anyone without the
36//! snapshot cannot confirm a removal offline; anyone holding it (an insider)
37//! can, which is accepted.
38//!
39//! # Identity and bound (R732-T7)
40//!
41//! Every entry is an **identity** (what it names: a jti, a device, a
42//! `(kind, id, user)` membership) plus one **bound** that limits what it masks:
43//!
44//! - [`Revoked::Jti`] `exp` — the credential's own expiry. Once `exp <= now`
45//!   the credential is dead on its signature, so the entry *lapses* and may be
46//!   dropped (store gc, and the published set omits it). `None` never lapses —
47//!   standing bindings and non-expiring user tokens.
48//! - [`Revoked::Device`] `at_seq` — masks only standing bindings of the device
49//!   with `seq < at_seq`. Re-enrolling the same machine mints a higher seq and
50//!   is admitted again.
51//! - [`Revoked::Membership`] `at_epoch` — the user's last direct tuple on
52//!   `(kind, id)` was revoked at ownership version `at_epoch` (R732-F4). It
53//!   masks, in any membership snapshot with `epoch < at_epoch`, the user's
54//!   entries that derive from `(kind, id)` — and drops an entry only once
55//!   every resource in its `via` is masked (`crate::snapshot`).
56//!
57//! An identity holds one bound: re-revoking keeps the larger (for a jti,
58//! `None` counts as larger than any `exp`), and [`RevocationSet::new`] dedups
59//! by identity the same way.
60
61use std::borrow::Cow;
62use std::cmp::Ordering;
63
64use base64::engine::general_purpose::URL_SAFE_NO_PAD;
65use base64::Engine as _;
66use serde::{Deserialize, Deserializer, Serialize, Serializer};
67
68use crate::artifact::SignedArtifact;
69use crate::claims::DeviceId;
70use crate::principal::PrincipalId;
71
72/// 32 raw bytes on the wire as a base64url-no-pad string.
73fn ser_b64<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
74    s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
75}
76
77fn de_b64<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
78    let text = String::deserialize(d)?;
79    let raw = URL_SAFE_NO_PAD.decode(text.as_bytes()).map_err(serde::de::Error::custom)?;
80    raw.try_into()
81        .map_err(|raw: Vec<u8>| serde::de::Error::custom(format!("expected 32 bytes, got {}", raw.len())))
82}
83
84/// A resource's secret membership-revocation key (module docs, "Membership
85/// privacy"). Random, created once per `(kind, id)` and never changed: a
86/// changed key would make every edge holding an older snapshot miss every new
87/// entry — revocation failing open. Travels only inside signed snapshots.
88#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct RevocationKey(#[serde(serialize_with = "ser_b64", deserialize_with = "de_b64")] [u8; 32]);
90
91impl RevocationKey {
92    pub fn from_bytes(bytes: [u8; 32]) -> Self {
93        Self(bytes)
94    }
95
96    pub fn as_bytes(&self) -> &[u8; 32] {
97        &self.0
98    }
99}
100
101impl std::fmt::Debug for RevocationKey {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str("RevocationKey(..)")
104    }
105}
106
107/// The published name of a membership revocation: an HMAC of `(kind, id,
108/// principal)` under the resource's [`RevocationKey`].
109#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
110pub struct MembershipTag(#[serde(serialize_with = "ser_b64", deserialize_with = "de_b64")] [u8; 32]);
111
112impl MembershipTag {
113    pub fn from_bytes(bytes: [u8; 32]) -> Self {
114        Self(bytes)
115    }
116
117    pub fn as_bytes(&self) -> &[u8; 32] {
118        &self.0
119    }
120}
121
122/// One entry of the issuer's revocation log, in plaintext: an identity plus
123/// its bound (module docs). What stores hold. A [`RevocationSet`] publishes
124/// it as a [`RevocationEntry`], with memberships tagged.
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum Revoked {
128    /// One credential, by its `jti`. Lapses once `exp <= now`; `None` never
129    /// lapses.
130    Jti { jti: String, exp: Option<i64> },
131    /// Standing bindings of this device with `seq < at_seq`.
132    Device { device: DeviceId, at_seq: u64 },
133    /// `principal`'s direct membership of resource `(kind, id)`, whatever
134    /// relation it held, in snapshots with `epoch < at_epoch` — of `(kind, id)`
135    /// itself and of every resource whose members derive from it by a subject
136    /// set ([`SnapshotMember::via`](crate::SnapshotMember::via)). Any principal
137    /// kind, a [`Key`](crate::PrincipalKind::Key) included.
138    Membership {
139        kind: String,
140        id: String,
141        principal: PrincipalId,
142        at_epoch: u64,
143    },
144}
145
146impl Revoked {
147    pub fn jti(jti: impl Into<String>, exp: Option<i64>) -> Self {
148        Self::Jti { jti: jti.into(), exp }
149    }
150
151    pub fn device(device: impl Into<DeviceId>, at_seq: u64) -> Self {
152        Self::Device {
153            device: device.into(),
154            at_seq,
155        }
156    }
157
158    pub fn membership(
159        kind: impl Into<String>,
160        id: impl Into<String>,
161        principal: impl Into<PrincipalId>,
162        at_epoch: u64,
163    ) -> Self {
164        Self::Membership {
165            kind: kind.into(),
166            id: id.into(),
167            principal: principal.into(),
168            at_epoch,
169        }
170    }
171
172    /// What the entry names, without its bound: `(variant, a, b, c)`. Two
173    /// entries with one identity are the same row in every store. A
174    /// membership's `c` is the principal's wire form (`user:alice`, `key:..`).
175    pub fn identity(&self) -> (u8, &str, &str, Cow<'_, str>) {
176        match self {
177            Self::Jti { jti, .. } => (0, jti, "", Cow::Borrowed("")),
178            Self::Device { device, .. } => (1, device.as_str(), "", Cow::Borrowed("")),
179            Self::Membership { kind, id, principal, .. } => (2, kind, id, Cow::Owned(principal.to_string())),
180        }
181    }
182
183    /// Order of the bounds of two entries with one identity: the greater bound
184    /// masks more. For a jti, `None` (never lapses) is greater than any `exp`.
185    /// Entries with different identities compare `Equal`.
186    pub fn cmp_bound(&self, other: &Self) -> Ordering {
187        match (self, other) {
188            (Self::Jti { exp: a, .. }, Self::Jti { exp: b, .. }) => match (a, b) {
189                (None, None) => Ordering::Equal,
190                (None, Some(_)) => Ordering::Greater,
191                (Some(_), None) => Ordering::Less,
192                (Some(a), Some(b)) => a.cmp(b),
193            },
194            (Self::Device { at_seq: a, .. }, Self::Device { at_seq: b, .. }) => a.cmp(b),
195            (Self::Membership { at_epoch: a, .. }, Self::Membership { at_epoch: b, .. }) => a.cmp(b),
196            _ => Ordering::Equal,
197        }
198    }
199
200    /// `true` once a jti's `exp` is at or before `now`; never for the others.
201    pub fn is_lapsed_at(&self, now: i64) -> bool {
202        matches!(self, Self::Jti { exp: Some(exp), .. } if *exp <= now)
203    }
204}
205
206/// One entry of a published [`RevocationSet`]: a [`Revoked`] with its
207/// membership identity replaced by a [`MembershipTag`] (module docs).
208#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum RevocationEntry {
211    /// As [`Revoked::Jti`].
212    Jti { jti: String, exp: Option<i64> },
213    /// As [`Revoked::Device`].
214    Device { device: DeviceId, at_seq: u64 },
215    /// As [`Revoked::Membership`], named by its tag.
216    Membership { tag: MembershipTag, at_epoch: u64 },
217}
218
219impl RevocationEntry {
220    /// What the entry names, without its bound.
221    pub fn identity(&self) -> (u8, &[u8]) {
222        match self {
223            Self::Jti { jti, .. } => (0, jti.as_bytes()),
224            Self::Device { device, .. } => (1, device.as_str().as_bytes()),
225            Self::Membership { tag, .. } => (2, tag.as_bytes()),
226        }
227    }
228
229    /// As [`Revoked::cmp_bound`].
230    pub fn cmp_bound(&self, other: &Self) -> Ordering {
231        match (self, other) {
232            (Self::Jti { exp: a, .. }, Self::Jti { exp: b, .. }) => match (a, b) {
233                (None, None) => Ordering::Equal,
234                (None, Some(_)) => Ordering::Greater,
235                (Some(_), None) => Ordering::Less,
236                (Some(a), Some(b)) => a.cmp(b),
237            },
238            (Self::Device { at_seq: a, .. }, Self::Device { at_seq: b, .. }) => a.cmp(b),
239            (Self::Membership { at_epoch: a, .. }, Self::Membership { at_epoch: b, .. }) => a.cmp(b),
240            _ => Ordering::Equal,
241        }
242    }
243}
244
245/// An issuer's revocations as of `epoch`.
246///
247/// `epoch` is strictly monotonic per issuer: every change the issuer records
248/// produces a higher one, so a replica orders sets by epoch alone and never by
249/// clock. Equal epochs carry equal contents.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct RevocationSet {
252    pub issuer: String,
253    pub epoch: u64,
254    pub revoked: Vec<RevocationEntry>,
255}
256
257impl RevocationSet {
258    /// A set in canonical form: `revoked` sorted and de-duplicated.
259    ///
260    /// Entries sharing an identity collapse to the one with the greatest bound
261    /// ([`RevocationEntry::cmp_bound`]).
262    pub fn new(issuer: impl Into<String>, epoch: u64, mut revoked: Vec<RevocationEntry>) -> Self {
263        // Identity ascending, then bound descending, so the first of each
264        // identity run is the one to keep.
265        revoked.sort_by(|a, b| a.identity().cmp(&b.identity()).then_with(|| b.cmp_bound(a)));
266        revoked.dedup_by(|later, kept| later.identity() == kept.identity());
267        Self {
268            issuer: issuer.into(),
269            epoch,
270            revoked,
271        }
272    }
273}
274
275impl SignedArtifact for RevocationSet {
276    const IMPLICIT_ASSERTION: &'static [u8] = b"urn:cheers:artifact:revocation-set:v1";
277
278    fn issuer(&self) -> &str {
279        &self.issuer
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn jti(j: &str, exp: Option<i64>) -> RevocationEntry {
288        RevocationEntry::Jti { jti: j.into(), exp }
289    }
290
291    fn device(d: &str, at_seq: u64) -> RevocationEntry {
292        RevocationEntry::Device { device: DeviceId::new(d), at_seq }
293    }
294
295    fn membership(tag: u8, at_epoch: u64) -> RevocationEntry {
296        RevocationEntry::Membership { tag: MembershipTag::from_bytes([tag; 32]), at_epoch }
297    }
298
299    #[test]
300    fn wire_shape_is_externally_tagged_with_bounds() {
301        let set = RevocationSet::new(
302            "https://c.example",
303            7,
304            vec![
305                membership(7, 12),
306                device("phone", 900),
307                jti("j1", Some(1_000)),
308                jti("j0", None),
309            ],
310        );
311        let v = serde_json::to_value(&set).unwrap();
312        assert_eq!(
313            v,
314            serde_json::json!({
315                "issuer": "https://c.example",
316                "epoch": 7,
317                "revoked": [
318                    {"jti": {"jti": "j0", "exp": null}},
319                    {"jti": {"jti": "j1", "exp": 1_000}},
320                    {"device": {"device": "phone", "at_seq": 900}},
321                    {"membership": {"tag": URL_SAFE_NO_PAD.encode([7u8; 32]), "at_epoch": 12}},
322                ],
323            })
324        );
325        let back: RevocationSet = serde_json::from_value(v).unwrap();
326        assert_eq!(back, set);
327    }
328
329    #[test]
330    fn new_sorts_and_dedups_so_equal_contents_sign_equal_bytes() {
331        let a = RevocationSet::new(
332            "i",
333            1,
334            vec![jti("b", None), jti("a", None), jti("b", None)],
335        );
336        let b = RevocationSet::new("i", 1, vec![jti("a", None), jti("b", None)]);
337        assert_eq!(serde_json::to_vec(&a).unwrap(), serde_json::to_vec(&b).unwrap());
338    }
339
340    #[test]
341    fn new_dedups_by_identity_keeping_the_greatest_bound() {
342        let set = RevocationSet::new(
343            "i",
344            1,
345            vec![
346                device("d", 5),
347                jti("j", Some(10)),
348                device("d", 9),
349                jti("j", None),
350                jti("k", Some(3)),
351                jti("k", Some(7)),
352                membership(1, 2),
353                membership(1, 1),
354            ],
355        );
356        assert_eq!(
357            set.revoked,
358            vec![
359                jti("j", None),
360                jti("k", Some(7)),
361                device("d", 9),
362                membership(1, 2),
363            ]
364        );
365    }
366
367    #[test]
368    fn only_a_jti_with_an_exp_lapses() {
369        assert!(Revoked::jti("j", Some(10)).is_lapsed_at(10));
370        assert!(!Revoked::jti("j", Some(10)).is_lapsed_at(9));
371        assert!(!Revoked::jti("j", None).is_lapsed_at(i64::MAX));
372        assert!(!Revoked::device("d", 1).is_lapsed_at(i64::MAX));
373    }
374
375    #[test]
376    fn a_tag_or_key_of_the_wrong_length_is_refused() {
377        let short = serde_json::json!({"membership": {"tag": URL_SAFE_NO_PAD.encode([1u8; 31]), "at_epoch": 1}});
378        assert!(serde_json::from_value::<RevocationEntry>(short).is_err());
379        let key = RevocationKey::from_bytes([9; 32]);
380        let back: RevocationKey = serde_json::from_value(serde_json::to_value(&key).unwrap()).unwrap();
381        assert_eq!(back, key);
382        assert_eq!(format!("{key:?}"), "RevocationKey(..)");
383    }
384
385    #[test]
386    fn implicit_assertion_is_non_empty() {
387        // The access-token paths verify with the empty assertion; a non-empty
388        // one is what keeps a set from ever verifying there.
389        assert!(!RevocationSet::IMPLICIT_ASSERTION.is_empty());
390    }
391}