use std::borrow::Cow;
use std::cmp::Ordering;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::artifact::SignedArtifact;
use crate::claims::DeviceId;
use crate::principal::PrincipalId;
fn ser_b64<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
}
fn de_b64<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let text = String::deserialize(d)?;
let raw = URL_SAFE_NO_PAD.decode(text.as_bytes()).map_err(serde::de::Error::custom)?;
raw.try_into()
.map_err(|raw: Vec<u8>| serde::de::Error::custom(format!("expected 32 bytes, got {}", raw.len())))
}
#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RevocationKey(#[serde(serialize_with = "ser_b64", deserialize_with = "de_b64")] [u8; 32]);
impl RevocationKey {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl std::fmt::Debug for RevocationKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("RevocationKey(..)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct MembershipTag(#[serde(serialize_with = "ser_b64", deserialize_with = "de_b64")] [u8; 32]);
impl MembershipTag {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Revoked {
Jti { jti: String, exp: Option<i64> },
Device { device: DeviceId, at_seq: u64 },
Membership {
kind: String,
id: String,
principal: PrincipalId,
at_epoch: u64,
},
}
impl Revoked {
pub fn jti(jti: impl Into<String>, exp: Option<i64>) -> Self {
Self::Jti { jti: jti.into(), exp }
}
pub fn device(device: impl Into<DeviceId>, at_seq: u64) -> Self {
Self::Device {
device: device.into(),
at_seq,
}
}
pub fn membership(
kind: impl Into<String>,
id: impl Into<String>,
principal: impl Into<PrincipalId>,
at_epoch: u64,
) -> Self {
Self::Membership {
kind: kind.into(),
id: id.into(),
principal: principal.into(),
at_epoch,
}
}
pub fn identity(&self) -> (u8, &str, &str, Cow<'_, str>) {
match self {
Self::Jti { jti, .. } => (0, jti, "", Cow::Borrowed("")),
Self::Device { device, .. } => (1, device.as_str(), "", Cow::Borrowed("")),
Self::Membership { kind, id, principal, .. } => (2, kind, id, Cow::Owned(principal.to_string())),
}
}
pub fn cmp_bound(&self, other: &Self) -> Ordering {
match (self, other) {
(Self::Jti { exp: a, .. }, Self::Jti { exp: b, .. }) => match (a, b) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.cmp(b),
},
(Self::Device { at_seq: a, .. }, Self::Device { at_seq: b, .. }) => a.cmp(b),
(Self::Membership { at_epoch: a, .. }, Self::Membership { at_epoch: b, .. }) => a.cmp(b),
_ => Ordering::Equal,
}
}
pub fn is_lapsed_at(&self, now: i64) -> bool {
matches!(self, Self::Jti { exp: Some(exp), .. } if *exp <= now)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevocationEntry {
Jti { jti: String, exp: Option<i64> },
Device { device: DeviceId, at_seq: u64 },
Membership { tag: MembershipTag, at_epoch: u64 },
}
impl RevocationEntry {
pub fn identity(&self) -> (u8, &[u8]) {
match self {
Self::Jti { jti, .. } => (0, jti.as_bytes()),
Self::Device { device, .. } => (1, device.as_str().as_bytes()),
Self::Membership { tag, .. } => (2, tag.as_bytes()),
}
}
pub fn cmp_bound(&self, other: &Self) -> Ordering {
match (self, other) {
(Self::Jti { exp: a, .. }, Self::Jti { exp: b, .. }) => match (a, b) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.cmp(b),
},
(Self::Device { at_seq: a, .. }, Self::Device { at_seq: b, .. }) => a.cmp(b),
(Self::Membership { at_epoch: a, .. }, Self::Membership { at_epoch: b, .. }) => a.cmp(b),
_ => Ordering::Equal,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevocationSet {
pub issuer: String,
pub epoch: u64,
pub revoked: Vec<RevocationEntry>,
}
impl RevocationSet {
pub fn new(issuer: impl Into<String>, epoch: u64, mut revoked: Vec<RevocationEntry>) -> Self {
revoked.sort_by(|a, b| a.identity().cmp(&b.identity()).then_with(|| b.cmp_bound(a)));
revoked.dedup_by(|later, kept| later.identity() == kept.identity());
Self {
issuer: issuer.into(),
epoch,
revoked,
}
}
}
impl SignedArtifact for RevocationSet {
const IMPLICIT_ASSERTION: &'static [u8] = b"urn:cheers:artifact:revocation-set:v1";
fn issuer(&self) -> &str {
&self.issuer
}
}
#[cfg(test)]
mod tests {
use super::*;
fn jti(j: &str, exp: Option<i64>) -> RevocationEntry {
RevocationEntry::Jti { jti: j.into(), exp }
}
fn device(d: &str, at_seq: u64) -> RevocationEntry {
RevocationEntry::Device { device: DeviceId::new(d), at_seq }
}
fn membership(tag: u8, at_epoch: u64) -> RevocationEntry {
RevocationEntry::Membership { tag: MembershipTag::from_bytes([tag; 32]), at_epoch }
}
#[test]
fn wire_shape_is_externally_tagged_with_bounds() {
let set = RevocationSet::new(
"https://c.example",
7,
vec![
membership(7, 12),
device("phone", 900),
jti("j1", Some(1_000)),
jti("j0", None),
],
);
let v = serde_json::to_value(&set).unwrap();
assert_eq!(
v,
serde_json::json!({
"issuer": "https://c.example",
"epoch": 7,
"revoked": [
{"jti": {"jti": "j0", "exp": null}},
{"jti": {"jti": "j1", "exp": 1_000}},
{"device": {"device": "phone", "at_seq": 900}},
{"membership": {"tag": URL_SAFE_NO_PAD.encode([7u8; 32]), "at_epoch": 12}},
],
})
);
let back: RevocationSet = serde_json::from_value(v).unwrap();
assert_eq!(back, set);
}
#[test]
fn new_sorts_and_dedups_so_equal_contents_sign_equal_bytes() {
let a = RevocationSet::new(
"i",
1,
vec![jti("b", None), jti("a", None), jti("b", None)],
);
let b = RevocationSet::new("i", 1, vec![jti("a", None), jti("b", None)]);
assert_eq!(serde_json::to_vec(&a).unwrap(), serde_json::to_vec(&b).unwrap());
}
#[test]
fn new_dedups_by_identity_keeping_the_greatest_bound() {
let set = RevocationSet::new(
"i",
1,
vec![
device("d", 5),
jti("j", Some(10)),
device("d", 9),
jti("j", None),
jti("k", Some(3)),
jti("k", Some(7)),
membership(1, 2),
membership(1, 1),
],
);
assert_eq!(
set.revoked,
vec![
jti("j", None),
jti("k", Some(7)),
device("d", 9),
membership(1, 2),
]
);
}
#[test]
fn only_a_jti_with_an_exp_lapses() {
assert!(Revoked::jti("j", Some(10)).is_lapsed_at(10));
assert!(!Revoked::jti("j", Some(10)).is_lapsed_at(9));
assert!(!Revoked::jti("j", None).is_lapsed_at(i64::MAX));
assert!(!Revoked::device("d", 1).is_lapsed_at(i64::MAX));
}
#[test]
fn a_tag_or_key_of_the_wrong_length_is_refused() {
let short = serde_json::json!({"membership": {"tag": URL_SAFE_NO_PAD.encode([1u8; 31]), "at_epoch": 1}});
assert!(serde_json::from_value::<RevocationEntry>(short).is_err());
let key = RevocationKey::from_bytes([9; 32]);
let back: RevocationKey = serde_json::from_value(serde_json::to_value(&key).unwrap()).unwrap();
assert_eq!(back, key);
assert_eq!(format!("{key:?}"), "RevocationKey(..)");
}
#[test]
fn implicit_assertion_is_non_empty() {
assert!(!RevocationSet::IMPLICIT_ASSERTION.is_empty());
}
}