1use 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
72fn 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum Revoked {
128 Jti { jti: String, exp: Option<i64> },
131 Device { device: DeviceId, at_seq: u64 },
133 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 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 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 pub fn is_lapsed_at(&self, now: i64) -> bool {
202 matches!(self, Self::Jti { exp: Some(exp), .. } if *exp <= now)
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum RevocationEntry {
211 Jti { jti: String, exp: Option<i64> },
213 Device { device: DeviceId, at_seq: u64 },
215 Membership { tag: MembershipTag, at_epoch: u64 },
217}
218
219impl RevocationEntry {
220 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 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#[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 pub fn new(issuer: impl Into<String>, epoch: u64, mut revoked: Vec<RevocationEntry>) -> Self {
263 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 assert!(!RevocationSet::IMPLICIT_ASSERTION.is_empty());
390 }
391}