From 57fb6d31f197fcd070958e2d101ee4c7f9a4ce30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Haudebourg?= Date: Fri, 25 Oct 2024 12:17:13 -0400 Subject: [PATCH] Bring back support for Bitstring Status List Working Draft 06 April 2024. (#622) --- .../bitstring_status_list_20240406/mod.rs | 767 ++++++++++++++++++ .../syntax/entry_set/credential.rs | 150 ++++ .../syntax/entry_set/mod.rs | 87 ++ .../syntax/mod.rs | 61 ++ .../syntax/status_list/credential.rs | 243 ++++++ .../syntax/status_list/mod.rs | 116 +++ crates/status/src/impl/mod.rs | 1 + 7 files changed, 1425 insertions(+) create mode 100644 crates/status/src/impl/bitstring_status_list_20240406/mod.rs create mode 100644 crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/credential.rs create mode 100644 crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/mod.rs create mode 100644 crates/status/src/impl/bitstring_status_list_20240406/syntax/mod.rs create mode 100644 crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/credential.rs create mode 100644 crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/mod.rs diff --git a/crates/status/src/impl/bitstring_status_list_20240406/mod.rs b/crates/status/src/impl/bitstring_status_list_20240406/mod.rs new file mode 100644 index 000000000..82b0522fa --- /dev/null +++ b/crates/status/src/impl/bitstring_status_list_20240406/mod.rs @@ -0,0 +1,767 @@ +//! W3C Bitstring Status List v1.0 (Working Draft 06 April 2024) +//! +//! A privacy-preserving, space-efficient, and high-performance mechanism for +//! publishing status information such as suspension or revocation of Verifiable +//! Credentials through use of bitstrings. +//! +//! See: +use core::fmt; +use iref::UriBuf; +use serde::{Deserialize, Serialize}; +use std::{hash::Hash, str::FromStr, time::Duration}; + +use crate::{Overflow, StatusMap, StatusSizeError}; + +mod syntax; +pub use syntax::*; + +#[derive(Debug, Serialize, Deserialize)] +pub struct StatusMessage { + #[serde(with = "prefixed_hexadecimal")] + pub status: u8, + pub message: String, +} + +impl StatusMessage { + pub fn new(status: u8, message: String) -> Self { + Self { status, message } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("invalid status size `{0}`")] +pub struct InvalidStatusSize(u8); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct StatusSize(u8); + +impl TryFrom for StatusSize { + type Error = InvalidStatusSize; + + fn try_from(value: u8) -> Result { + if value <= 8 { + Ok(Self(value)) + } else { + Err(InvalidStatusSize(value)) + } + } +} + +impl Default for StatusSize { + fn default() -> Self { + Self::DEFAULT + } +} + +impl StatusSize { + pub const DEFAULT: Self = Self(1); + + pub fn is_default(&self) -> bool { + *self == Self::DEFAULT + } + + fn offset_of(&self, index: usize) -> Offset { + let bit_offset = self.0 as usize * index; + Offset { + byte: bit_offset / 8, + bit: bit_offset % 8, + } + } + + fn mask(&self) -> u8 { + if self.0 == 8 { + 0xff + } else { + (1 << self.0) - 1 + } + } +} + +impl<'de> Deserialize<'de> for StatusSize { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + u8::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +#[derive(Debug)] +struct Offset { + byte: usize, + bit: usize, +} + +impl Offset { + fn left_shift(&self, status_size: StatusSize) -> (i32, Option) { + let high = (8 - status_size.0 as isize - self.bit as isize) as i32; + let low = if high < 0 { + Some((8 + high) as u32) + } else { + None + }; + + (high, low) + } +} + +/// Maximum duration, in milliseconds, an implementer is allowed to cache a +/// status list. +/// +/// Default value is 300000. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TimeToLive(pub u64); + +impl Default for TimeToLive { + fn default() -> Self { + Self::DEFAULT + } +} + +impl TimeToLive { + pub const DEFAULT: Self = Self(300000); + + pub fn is_default(&self) -> bool { + *self == Self::DEFAULT + } +} + +impl From for Duration { + fn from(value: TimeToLive) -> Self { + Duration::from_millis(value.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StatusPurpose { + /// Cancel the validity of a verifiable credential. + /// + /// This status is not reversible. + Revocation, + + /// Temporarily prevent the acceptance of a verifiable credential. + /// + /// This status is reversible. + Suspension, + + /// Convey an arbitrary message related to the status of the verifiable + /// credential. + /// + /// The actual message is stored in the status list credential, in + /// [`BitstringStatusList::status_message`]. + Message, +} + +impl StatusPurpose { + /// Creates a new status purpose from its name. + pub fn from_name(name: &str) -> Option { + match name { + "revocation" => Some(Self::Revocation), + "suspension" => Some(Self::Suspension), + "message" => Some(Self::Message), + _ => None, + } + } + + /// Returns the name of this status purpose. + pub fn name(&self) -> &'static str { + match self { + Self::Revocation => "revocation", + Self::Suspension => "suspension", + Self::Message => "message", + } + } + + /// Returns the string representation of this status purpose. + /// + /// Same as [`Self::name`]. + pub fn as_str(&self) -> &'static str { + self.name() + } + + /// Turns this status purpose into its name. + /// + /// Same as [`Self::name`]. + pub fn into_name(self) -> &'static str { + self.name() + } + + /// Turns this status purpose into its string representation. + /// + /// Same as [`Self::name`]. + pub fn into_str(self) -> &'static str { + self.name() + } +} + +impl<'a> From<&'a StatusPurpose> for crate::StatusPurpose<&'a str> { + fn from(value: &'a StatusPurpose) -> Self { + match value { + StatusPurpose::Revocation => Self::Revocation, + StatusPurpose::Suspension => Self::Suspension, + StatusPurpose::Message => Self::Other("message"), + } + } +} + +impl<'a> PartialEq> for StatusPurpose { + fn eq(&self, other: &crate::StatusPurpose<&'a str>) -> bool { + matches!( + (self, other), + (Self::Revocation, crate::StatusPurpose::Revocation) + | (Self::Suspension, crate::StatusPurpose::Suspension) + | (Self::Message, crate::StatusPurpose::Other("message")) + ) + } +} + +impl fmt::Display for StatusPurpose { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.name().fmt(f) + } +} + +/// Error raised when converting a string into a [`StatusPurpose`] fails. +#[derive(Debug, Clone, thiserror::Error)] +#[error("invalid status purpose: {0}")] +pub struct InvalidStatusPurpose(pub String); + +impl FromStr for StatusPurpose { + type Err = InvalidStatusPurpose; + + fn from_str(s: &str) -> Result { + Self::from_name(s).ok_or_else(|| InvalidStatusPurpose(s.to_owned())) + } +} + +/// Bit-string as defined by the W3C Bitstring Status List specification. +/// +/// Bits are indexed from most significant to least significant. +/// ```text +/// | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ... | n-8 | n-7 | n-6 | n-5 | n-4 | n-3 | n-2 | n-1 | +/// | byte 0 | ... | byte k-1 | +/// ``` +/// +/// See: +#[derive(Debug, Clone)] +pub struct BitString { + status_size: StatusSize, + bytes: Vec, + len: usize, +} + +impl BitString { + /// Creates a new empty bit-string. + pub fn new(status_size: StatusSize) -> Self { + Self { + status_size, + bytes: Vec::new(), + len: 0, + } + } + + /// Creates a new bit-string of the given length, using `f` to initialize + /// every status. + /// + /// The `f` function is called with the index of the initialized status. + pub fn new_with( + status_size: StatusSize, + len: usize, + mut f: impl FnMut(usize) -> u8, + ) -> Result { + let mut result = Self::with_capacity(status_size, len); + + for i in 0..len { + result.push(f(i))?; + } + + Ok(result) + } + + /// Creates a new bit-string of the given length, setting every status + /// to the same value. + pub fn new_with_value( + status_size: StatusSize, + len: usize, + value: u8, + ) -> Result { + Self::new_with(status_size, len, |_| value) + } + + /// Creates a new bit-string of the given length, setting every status + /// to 0. + pub fn new_zeroed(status_size: StatusSize, len: usize) -> Self { + Self::new_with_value(status_size, len, 0).unwrap() // 0 cannot overflow. + } + + /// Creates a new bit-string with the given status size and capacity + /// (in number of statuses). + pub fn with_capacity(status_size: StatusSize, capacity: usize) -> Self { + Self { + status_size, + bytes: Vec::with_capacity((capacity * status_size.0 as usize).div_ceil(8)), + len: 0, + } + } + + /// Creates a bit-string from a byte array and status size. + pub fn from_bytes(status_size: StatusSize, bytes: Vec) -> Self { + let len = bytes.len() * 8usize / status_size.0 as usize; + Self { + status_size, + bytes, + len, + } + } + + /// Checks if the list is empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// Returns the length of the list (number of statuses). + pub fn len(&self) -> usize { + self.len + } + + /// Returns the value stored in the list at the given index. + pub fn get(&self, index: usize) -> Option { + if index >= self.len { + return None; + } + + let offset = self.status_size.offset_of(index); + let (high_shift, low_shift) = offset.left_shift(self.status_size); + + Some(self.get_at(offset.byte, high_shift, low_shift)) + } + + fn get_at(&self, byte_offset: usize, high_shift: i32, low_shift: Option) -> u8 { + let high = self + .bytes + .get(byte_offset) + .unwrap() + .overflowing_signed_shr(high_shift) + .0; + + let low = match low_shift { + Some(low_shift) => { + self.bytes + .get(byte_offset + 1) + .unwrap() + .overflowing_shr(low_shift) + .0 + } + None => 0, + }; + + (high | low) & self.status_size.mask() + } + + /// Sets the value at the given index. + /// + /// Returns the previous value, or an `Overflow` error if either the index + /// is out of bounds or the value is too large. + pub fn set(&mut self, index: usize, value: u8) -> Result { + if index >= self.len { + return Err(Overflow::Index(index)); + } + + let mask = self.status_size.mask(); + let masked_value = value & mask; + if masked_value != value { + return Err(Overflow::Value(value)); + } + + let offset = self.status_size.offset_of(index); + let (high_shift, low_shift) = offset.left_shift(self.status_size); + + let old_value = self.get_at(offset.byte, high_shift, low_shift); + + self.bytes[offset.byte] &= !mask.overflowing_signed_shl(high_shift).0; // clear high + self.bytes[offset.byte] |= masked_value.overflowing_signed_shl(high_shift).0; // set high + if let Some(low_shift) = low_shift { + self.bytes[offset.byte + 1] &= !mask.overflowing_shl(low_shift).0; // clear low + self.bytes[offset.byte + 1] |= masked_value.overflowing_shl(low_shift).0; + // set low + } + + Ok(old_value) + } + + /// Push a new value into the bit-string. + /// + /// Returns the index of the newly inserted value in the list, + /// or an error if the value is too large w.r.t. `status_size`. + pub fn push(&mut self, value: u8) -> Result { + let masked_value = value & self.status_size.mask(); + if masked_value != value { + return Err(Overflow::Value(value)); + } + + let index = self.len; + let offset = self.status_size.offset_of(index); + + let (high_shift, low_shift) = offset.left_shift(self.status_size); + + if offset.byte == self.bytes.len() { + self.bytes + .push(masked_value.overflowing_signed_shl(high_shift).0); + } else { + self.bytes[offset.byte] |= masked_value.overflowing_signed_shl(high_shift).0 + } + + if let Some(low_shift) = low_shift { + self.bytes.push(masked_value.overflowing_shl(low_shift).0); + } + + self.len += 1; + Ok(index) + } + + /// Returns an iterator over all the statuses stored in this bit-string. + pub fn iter(&self) -> BitStringIter { + BitStringIter { + bit_string: self, + index: 0, + } + } + + /// Encodes the bit-string. + pub fn encode(&self) -> EncodedList { + EncodedList::encode(&self.bytes) + } +} + +trait OverflowingSignedShift: Sized { + fn overflowing_signed_shl(self, shift: i32) -> (Self, bool); + + fn overflowing_signed_shr(self, shift: i32) -> (Self, bool); +} + +impl OverflowingSignedShift for u8 { + fn overflowing_signed_shl(self, shift: i32) -> (u8, bool) { + if shift < 0 { + self.overflowing_shr(shift.unsigned_abs()) + } else { + self.overflowing_shl(shift.unsigned_abs()) + } + } + + fn overflowing_signed_shr(self, shift: i32) -> (u8, bool) { + if shift < 0 { + self.overflowing_shl(shift.unsigned_abs()) + } else { + self.overflowing_shr(shift.unsigned_abs()) + } + } +} + +#[derive(Debug, Clone)] +pub struct StatusList { + bit_string: BitString, + ttl: TimeToLive, +} + +impl StatusList { + pub fn new(status_size: StatusSize, ttl: TimeToLive) -> Self { + Self { + bit_string: BitString::new(status_size), + ttl, + } + } + + pub fn from_bytes(status_size: StatusSize, bytes: Vec, ttl: TimeToLive) -> Self { + Self { + bit_string: BitString::from_bytes(status_size, bytes), + ttl, + } + } + + pub fn is_empty(&self) -> bool { + self.bit_string.is_empty() + } + + pub fn len(&self) -> usize { + self.bit_string.len() + } + + pub fn get(&self, index: usize) -> Option { + self.bit_string.get(index) + } + + pub fn set(&mut self, index: usize, value: u8) -> Result { + self.bit_string.set(index, value) + } + + pub fn push(&mut self, value: u8) -> Result { + self.bit_string.push(value) + } + + pub fn iter(&self) -> BitStringIter { + self.bit_string.iter() + } + + pub fn to_credential_subject( + &self, + id: Option, + status_purpose: StatusPurpose, + status_message: Vec, + ) -> BitstringStatusList { + BitstringStatusList::new( + id, + status_purpose, + self.bit_string.status_size, + self.bit_string.encode(), + self.ttl, + status_message, + ) + } +} + +pub struct BitStringIter<'a> { + bit_string: &'a BitString, + index: usize, +} + +impl<'a> Iterator for BitStringIter<'a> { + type Item = u8; + + fn next(&mut self) -> Option { + self.bit_string.get(self.index).inspect(|_| { + self.index += 1; + }) + } +} + +impl StatusMap for StatusList { + type Key = usize; + type Status = u8; + type StatusSize = StatusSize; + + fn time_to_live(&self) -> Option { + Some(self.ttl.into()) + } + + fn get_by_key( + &self, + _status_size: Option, + key: Self::Key, + ) -> Result, StatusSizeError> { + Ok(self.bit_string.get(key).map(Into::into)) + } +} + +mod prefixed_hexadecimal { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(value: &u8, serializer: S) -> Result + where + S: Serializer, + { + format!("{value:#x}").serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let string = String::deserialize(deserializer)?; + let number = string + .strip_prefix("0x") + .ok_or_else(|| serde::de::Error::custom("missing `0x` prefix"))?; + u8::from_str_radix(number, 16).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use rand::{rngs::StdRng, RngCore, SeedableRng}; + + use crate::Overflow; + + use super::{BitString, StatusSize}; + + fn random_bit_string( + rng: &mut StdRng, + status_size: StatusSize, + len: usize, + ) -> (Vec, BitString) { + let mut values = Vec::with_capacity(len); + + for _ in 0..len { + values.push((rng.next_u32() & 0xff) as u8 & status_size.mask()) + } + + let mut bit_string = BitString::new(status_size); + for &s in &values { + bit_string.push(s).unwrap(); + } + + (values, bit_string) + } + + fn randomized_roundtrip(seed: u64, status_size: StatusSize, len: usize) { + let mut rng = StdRng::seed_from_u64(seed); + let (values, bit_string) = random_bit_string(&mut rng, status_size, len); + + let encoded = bit_string.encode(); + let decoded = BitString::from_bytes(status_size, encoded.decode(None).unwrap()); + + assert!(decoded.len() >= len); + + for i in 0..len { + assert_eq!(decoded.get(i), Some(values[i])) + } + } + + fn randomized_write(seed: u64, status_size: StatusSize, len: usize) { + let mut rng = StdRng::seed_from_u64(seed); + let (mut values, mut bit_string) = random_bit_string(&mut rng, status_size, len); + + for _ in 0..len { + let i = (rng.next_u32() as usize) % len; + let value = (rng.next_u32() & 0xff) as u8 & status_size.mask(); + bit_string.set(i, value).unwrap(); + values[i] = value; + } + + for i in 0..len { + assert_eq!(bit_string.get(i), Some(values[i])) + } + } + + #[test] + fn randomized_roundtrip_1bit() { + for i in 0..10 { + randomized_roundtrip(i, 1u8.try_into().unwrap(), 10); + } + + for i in 0..10 { + randomized_roundtrip(i, 1u8.try_into().unwrap(), 100); + } + + for i in 0..10 { + randomized_roundtrip(i, 1u8.try_into().unwrap(), 1000); + } + } + + #[test] + fn randomized_write_1bits() { + for i in 0..10 { + randomized_write(i, 1u8.try_into().unwrap(), 10); + } + + for i in 0..10 { + randomized_write(i, 1u8.try_into().unwrap(), 100); + } + + for i in 0..10 { + randomized_write(i, 1u8.try_into().unwrap(), 1000); + } + } + + #[test] + fn randomized_roundtrip_3bits() { + for i in 0..10 { + randomized_roundtrip(i, 3u8.try_into().unwrap(), 10); + } + + for i in 0..10 { + randomized_roundtrip(i, 3u8.try_into().unwrap(), 100); + } + + for i in 0..10 { + randomized_roundtrip(i, 3u8.try_into().unwrap(), 1000); + } + } + + #[test] + fn randomized_write_3bits() { + for i in 0..10 { + randomized_write(i, 3u8.try_into().unwrap(), 10); + } + + for i in 0..10 { + randomized_write(i, 3u8.try_into().unwrap(), 100); + } + + for i in 0..10 { + randomized_write(i, 3u8.try_into().unwrap(), 1000); + } + } + + #[test] + fn randomized_roundtrip_7bits() { + for i in 0..10 { + randomized_roundtrip(i, 7u8.try_into().unwrap(), 10); + } + + for i in 0..10 { + randomized_roundtrip(i, 7u8.try_into().unwrap(), 100); + } + + for i in 0..10 { + randomized_roundtrip(i, 7u8.try_into().unwrap(), 1000); + } + } + + #[test] + fn randomized_write_7bits() { + for i in 0..10 { + randomized_write(i, 7u8.try_into().unwrap(), 10); + } + + for i in 0..10 { + randomized_write(i, 7u8.try_into().unwrap(), 100); + } + + for i in 0..10 { + randomized_write(i, 7u8.try_into().unwrap(), 1000); + } + } + + #[test] + fn overflows() { + let mut rng = StdRng::seed_from_u64(0); + let (_, mut bitstring) = random_bit_string(&mut rng, 1u8.try_into().unwrap(), 15); + + // Out of bounds. + assert!(bitstring.get(15).is_none()); + + // Out of bounds (even if there are enough bytes in the list). + assert_eq!(bitstring.set(15, 0), Err(Overflow::Index(15))); + + // Too many bits. + assert_eq!(bitstring.set(14, 2), Err(Overflow::Value(2))); + } + + #[test] + fn deserialize_status_size_1() { + assert!(serde_json::from_str::("1").is_ok()) + } + + #[test] + fn deserialize_status_size_2() { + assert!(serde_json::from_str::("2").is_ok()) + } + + #[test] + fn deserialize_status_size_3() { + assert!(serde_json::from_str::("3").is_ok()) + } + + #[test] + fn deserialize_status_size_negative() { + assert!(serde_json::from_str::("-1").is_err()) + } + + #[test] + fn deserialize_status_size_overflow() { + assert!(serde_json::from_str::("9").is_err()) + } +} diff --git a/crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/credential.rs b/crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/credential.rs new file mode 100644 index 000000000..c68b0bc7d --- /dev/null +++ b/crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/credential.rs @@ -0,0 +1,150 @@ +use std::{borrow::Cow, collections::HashMap, hash::Hash}; + +use iref::UriBuf; +use rdf_types::{Interpretation, VocabularyMut}; +use serde::{Deserialize, Serialize}; +use ssi_claims_core::{ + ClaimsValidity, DateTimeProvider, Eip712TypesLoaderProvider, ResolverProvider, ValidateClaims, +}; +use ssi_data_integrity::{ + ssi_rdf::{LdEnvironment, LinkedDataResource, LinkedDataSubject}, + AnySuite, +}; +use ssi_json_ld::{ + CompactJsonLd, Expandable, JsonLdError, JsonLdLoaderProvider, JsonLdNodeObject, JsonLdObject, + Loader, +}; +use ssi_jwk::JWKResolver; +use ssi_jws::{InvalidJws, JwsSlice, ValidateJwsHeader}; +use ssi_vc::v2::{syntax::JsonCredentialTypes, Context}; +use ssi_verification_methods::{ssi_core::OneOrMany, AnyMethod, VerificationMethodResolver}; + +use crate::{ + bitstring_status_list_20240406::FromBytesError, FromBytes, FromBytesOptions, StatusMapEntrySet, +}; + +use super::BitstringStatusListEntry; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitstringStatusListEntrySetCredential { + /// JSON-LD context. + #[serde(rename = "@context")] + pub context: Context, + + /// Credential identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Credential type. + #[serde(rename = "type")] + pub types: JsonCredentialTypes, + + pub credential_status: OneOrMany, + + #[serde(flatten)] + pub other_properties: HashMap, +} + +impl StatusMapEntrySet for BitstringStatusListEntrySetCredential { + type Entry<'a> = &'a BitstringStatusListEntry where Self: 'a; + + fn get_entry(&self, purpose: crate::StatusPurpose<&str>) -> Option> { + (&self.credential_status) + .into_iter() + .find(|&entry| entry.status_purpose == purpose) + } +} + +impl JsonLdObject for BitstringStatusListEntrySetCredential { + fn json_ld_context(&self) -> Option> { + Some(Cow::Borrowed(self.context.as_ref())) + } +} + +impl JsonLdNodeObject for BitstringStatusListEntrySetCredential { + fn json_ld_type(&self) -> ssi_json_ld::JsonLdTypes { + self.types.to_json_ld_types() + } +} + +impl Expandable for BitstringStatusListEntrySetCredential { + type Error = JsonLdError; + type Expanded = ssi_json_ld::ExpandedDocument + where + I: Interpretation, + V: VocabularyMut, + V::Iri: LinkedDataResource + LinkedDataSubject, + V::BlankId: LinkedDataResource + LinkedDataSubject; + + async fn expand_with( + &self, + ld: &mut LdEnvironment, + loader: &impl Loader, + ) -> Result, Self::Error> + where + I: Interpretation, + V: VocabularyMut, + V::Iri: Clone + Eq + Hash + LinkedDataResource + LinkedDataSubject, + V::BlankId: Clone + Eq + Hash + LinkedDataResource + LinkedDataSubject, + { + CompactJsonLd(ssi_json_ld::syntax::to_value(self).unwrap()) + .expand_with(ld, loader) + .await + } +} + +impl ValidateClaims for BitstringStatusListEntrySetCredential { + fn validate_claims(&self, _env: &E, _proof: &P) -> ClaimsValidity { + // TODO use `ssi`'s own VC DM v2.0 validation function once it's implemented. + Ok(()) + } +} + +impl ValidateJwsHeader for BitstringStatusListEntrySetCredential { + fn validate_jws_header(&self, _env: &E, _header: &ssi_jws::Header) -> ClaimsValidity { + Ok(()) + } +} + +impl FromBytes for BitstringStatusListEntrySetCredential +where + V: ResolverProvider + DateTimeProvider + JsonLdLoaderProvider + Eip712TypesLoaderProvider, + V::Resolver: JWKResolver + VerificationMethodResolver, +{ + type Error = FromBytesError; + + async fn from_bytes_with( + bytes: &[u8], + media_type: &str, + params: &V, + options: FromBytesOptions, + ) -> Result { + match media_type { + "application/vc+ld+json+jwt" => { + let jws = JwsSlice::new(bytes) + .map_err(InvalidJws::into_owned)? + .decode()? + .try_map::(|bytes| serde_json::from_slice(&bytes))?; + jws.verify(params).await??; + Ok(jws.signing_bytes.payload) + } + // "application/vc+ld+json+sd-jwt" => { + // todo!() + // } + // "application/vc+ld+json+cose" => { + // todo!() + // } + "application/vc+ld+json" => { + let vc = ssi_data_integrity::from_json_slice::(bytes)?; + + if !options.allow_unsecured || !vc.proofs.is_empty() { + vc.verify(params).await??; + } + + Ok(vc.claims) + } + other => Err(FromBytesError::UnexpectedMediaType(other.to_owned())), + } + } +} diff --git a/crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/mod.rs b/crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/mod.rs new file mode 100644 index 000000000..0d70b8c78 --- /dev/null +++ b/crates/status/src/impl/bitstring_status_list_20240406/syntax/entry_set/mod.rs @@ -0,0 +1,87 @@ +use iref::{Uri, UriBuf}; +use serde::{Deserialize, Serialize}; + +mod credential; +pub use credential::*; + +use crate::{ + bitstring_status_list_20240406::{StatusPurpose, StatusSize}, + StatusMapEntry, +}; + +pub const BITSTRING_STATUS_LIST_ENTRY_TYPE: &str = "BitstringStatusListEntry"; + +/// Bitstring status list entry. +/// +/// References a particular entry of a status list, for a given status purpose. +/// It is the type of the `credentialStatus` property of a Verifiable +/// Credential. +/// +/// See: +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub struct BitstringStatusListEntry { + /// Optional identifier for the status list entry. + /// + /// Identifies the status information associated with the verifiable + /// credential. Must *not* be the URL of the status list. + pub id: Option, + + /// Purpose of the status entry. + pub status_purpose: StatusPurpose, + + /// URL to a `BitstringStatusListCredential` verifiable credential. + pub status_list_credential: UriBuf, + + /// Arbitrary size integer greater than or equal to 0, encoded as a string + /// in base 10. + #[serde(with = "base10_nat_string")] + pub status_list_index: usize, +} + +impl BitstringStatusListEntry { + /// Creates a new bit-string status list entry. + pub fn new( + id: Option, + status_purpose: StatusPurpose, + status_list_credential: UriBuf, + status_list_index: usize, + ) -> Self { + Self { + id, + status_purpose, + status_list_credential, + status_list_index, + } + } +} + +impl StatusMapEntry for BitstringStatusListEntry { + type Key = usize; + type StatusSize = StatusSize; + + fn status_list_url(&self) -> &Uri { + &self.status_list_credential + } + + fn status_size(&self) -> Option { + None + } + + fn key(&self) -> Self::Key { + self.status_list_index + } +} + +mod base10_nat_string { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(n: &usize, serializer: S) -> Result { + n.to_string().serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let string = String::deserialize(deserializer)?; + string.parse().map_err(serde::de::Error::custom) + } +} diff --git a/crates/status/src/impl/bitstring_status_list_20240406/syntax/mod.rs b/crates/status/src/impl/bitstring_status_list_20240406/syntax/mod.rs new file mode 100644 index 000000000..e7f0b6475 --- /dev/null +++ b/crates/status/src/impl/bitstring_status_list_20240406/syntax/mod.rs @@ -0,0 +1,61 @@ +use std::io::{Read, Write}; + +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use multibase::Base; +use serde::{Deserialize, Serialize}; + +mod status_list; +pub use status_list::*; + +mod entry_set; +pub use entry_set::*; + +/// Multibase-encoded base64url (with no padding) representation of the +/// GZIP-compressed bitstring values for the associated range of a bitstring +/// status list verifiable credential. +#[derive(Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct EncodedList(String); + +impl EncodedList { + /// Minimum bitstring size (16KB). + pub const MINIMUM_SIZE: usize = 16 * 1024; + + /// Default maximum bitstring size allowed by the `decode` function. + /// + /// 16MB. + pub const DEFAULT_LIMIT: u64 = 16 * 1024 * 1024; + + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn encode(bytes: &[u8]) -> Self { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).unwrap(); + + // Add padding to satisfy the minimum bitstring size constraint. + const PADDING_BUFFER_LEN: usize = 1024; + let padding = [0; PADDING_BUFFER_LEN]; + let mut it = (bytes.len()..Self::MINIMUM_SIZE) + .step_by(PADDING_BUFFER_LEN) + .peekable(); + while let Some(start) = it.next() { + let end = it.peek().copied().unwrap_or(Self::MINIMUM_SIZE); + let len = end - start; + encoder.write_all(&padding[..len]).unwrap(); + } + + let compressed = encoder.finish().unwrap(); + Self(multibase::encode(Base::Base64Url, compressed)) + } + + pub fn decode(&self, limit: Option) -> Result, DecodeError> { + let limit = limit.unwrap_or(Self::DEFAULT_LIMIT); + let (_base, compressed) = multibase::decode(&self.0)?; + let mut decoder = GzDecoder::new(compressed.as_slice()).take(limit); + let mut bytes = Vec::new(); + decoder.read_to_end(&mut bytes).map_err(DecodeError::Gzip)?; + Ok(bytes) + } +} diff --git a/crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/credential.rs b/crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/credential.rs new file mode 100644 index 000000000..861bc1ef6 --- /dev/null +++ b/crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/credential.rs @@ -0,0 +1,243 @@ +use std::{borrow::Cow, collections::HashMap, hash::Hash, io}; + +use iref::UriBuf; +use rdf_types::{Interpretation, Vocabulary, VocabularyMut}; +use serde::{Deserialize, Serialize}; +use ssi_claims_core::{ + ClaimsValidity, DateTimeProvider, Eip712TypesLoaderProvider, InvalidClaims, ResolverProvider, + ValidateClaims, +}; +use ssi_data_integrity::{ + ssi_rdf::{LdEnvironment, LinkedDataResource, LinkedDataSubject}, + AnySuite, +}; +use ssi_json_ld::{ + CompactJsonLd, Expandable, JsonLdError, JsonLdLoaderProvider, JsonLdNodeObject, JsonLdObject, + Loader, +}; +use ssi_jwk::JWKResolver; +use ssi_jws::{InvalidJws, JwsSlice, ValidateJwsHeader}; +use ssi_vc::{ + syntax::RequiredType, + v2::syntax::{Context, JsonCredentialTypes}, +}; +use ssi_verification_methods::{AnyMethod, VerificationMethodResolver}; + +use crate::{EncodedStatusMap, FromBytes, FromBytesOptions}; + +use super::{BitstringStatusList, StatusList}; + +pub const BITSTRING_STATUS_LIST_CREDENTIAL_TYPE: &str = "BitstringStatusListCredential"; + +#[derive(Debug, Clone, Copy)] +pub struct BitstringStatusListCredentialType; + +impl RequiredType for BitstringStatusListCredentialType { + const REQUIRED_TYPE: &'static str = BITSTRING_STATUS_LIST_CREDENTIAL_TYPE; +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BitstringStatusListCredential { + /// JSON-LD context. + #[serde(rename = "@context")] + pub context: Context, + + /// Credential identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Credential type. + #[serde(rename = "type")] + pub types: JsonCredentialTypes, + + /// Valid from. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option, + + /// Valid until. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_until: Option, + + /// Status list. + pub credential_subject: BitstringStatusList, + + /// Other properties. + #[serde(flatten)] + pub other_properties: HashMap, +} + +impl BitstringStatusListCredential { + pub fn new(id: Option, credential_subject: BitstringStatusList) -> Self { + Self { + context: Context::default(), + id, + types: JsonCredentialTypes::default(), + valid_from: None, + valid_until: None, + credential_subject, + other_properties: HashMap::default(), + } + } + + pub fn decode_status_list(&self) -> Result { + self.credential_subject.decode() + } +} + +impl JsonLdObject for BitstringStatusListCredential { + fn json_ld_context(&self) -> Option> { + Some(Cow::Borrowed(self.context.as_ref())) + } +} + +impl JsonLdNodeObject for BitstringStatusListCredential { + fn json_ld_type(&self) -> ssi_json_ld::JsonLdTypes { + self.types.to_json_ld_types() + } +} + +impl Expandable for BitstringStatusListCredential { + type Error = JsonLdError; + + type Expanded = ssi_json_ld::ExpandedDocument + where + I: Interpretation, + V: VocabularyMut, + V::Iri: LinkedDataResource + LinkedDataSubject, + V::BlankId: LinkedDataResource + LinkedDataSubject; + + #[allow(async_fn_in_trait)] + async fn expand_with( + &self, + ld: &mut LdEnvironment, + loader: &impl Loader, + ) -> Result, Self::Error> + where + I: Interpretation, + V: VocabularyMut, + V::Iri: Clone + Eq + Hash + LinkedDataResource + LinkedDataSubject, + V::BlankId: Clone + Eq + Hash + LinkedDataResource + LinkedDataSubject, + { + CompactJsonLd(ssi_json_ld::syntax::to_value(self).unwrap()) + .expand_with(ld, loader) + .await + } +} + +impl ValidateClaims for BitstringStatusListCredential +where + E: DateTimeProvider, +{ + fn validate_claims(&self, env: &E, _proof: &P) -> ClaimsValidity { + // TODO use `ssi`'s own VC DM v2.0 validation function once it's implemented. + let now = env.date_time(); + + if let Some(valid_from) = self.valid_from { + if now < valid_from { + return Err(InvalidClaims::Premature { + now, + valid_from: valid_from.into(), + }); + } + } + + if let Some(valid_until) = self.valid_until { + if now > valid_until { + return Err(InvalidClaims::Expired { + now, + valid_until: valid_until.into(), + }); + } + } + + Ok(()) + } +} + +impl ValidateJwsHeader for BitstringStatusListCredential { + fn validate_jws_header(&self, _env: &E, _header: &ssi_jws::Header) -> ClaimsValidity { + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DecodeError { + #[error("invalid multibase: {0}")] + Multibase(#[from] multibase::Error), + + #[error("GZIP error: {0}")] + Gzip(io::Error), +} + +impl EncodedStatusMap for BitstringStatusListCredential { + type Decoded = StatusList; + type DecodeError = DecodeError; + + fn decode(self) -> Result { + self.decode_status_list() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FromBytesError { + #[error("unexpected media type `{0}`")] + UnexpectedMediaType(String), + + #[error(transparent)] + Jws(#[from] InvalidJws>), + + #[error("invalid JWS: {0}")] + JWS(#[from] ssi_jws::DecodeError), + + #[error(transparent)] + DataIntegrity(#[from] ssi_data_integrity::DecodeError), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[error("proof preparation failed: {0}")] + Preparation(#[from] ssi_claims_core::ProofPreparationError), + + #[error("proof validation failed: {0}")] + Verification(#[from] ssi_claims_core::ProofValidationError), + + #[error("rejected claims: {0}")] + Rejected(#[from] ssi_claims_core::Invalid), +} + +impl FromBytes for BitstringStatusListCredential +where + V: ResolverProvider + DateTimeProvider + JsonLdLoaderProvider + Eip712TypesLoaderProvider, + V::Resolver: JWKResolver + VerificationMethodResolver, +{ + type Error = FromBytesError; + + async fn from_bytes_with( + bytes: &[u8], + media_type: &str, + params: &V, + options: FromBytesOptions, + ) -> Result { + match media_type { + "application/vc+ld+json+jwt" => { + let jws = JwsSlice::new(bytes) + .map_err(InvalidJws::into_owned)? + .decode()? + .try_map::(|bytes| serde_json::from_slice(&bytes))?; + jws.verify(params).await??; + Ok(jws.signing_bytes.payload) + } + "application/vc+ld+json" => { + let vc = ssi_data_integrity::from_json_slice::(bytes)?; + + if !options.allow_unsecured || !vc.proofs.is_empty() { + vc.verify(params).await??; + } + + Ok(vc.claims) + } + other => Err(FromBytesError::UnexpectedMediaType(other.to_owned())), + } + } +} diff --git a/crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/mod.rs b/crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/mod.rs new file mode 100644 index 000000000..e2c5a8f57 --- /dev/null +++ b/crates/status/src/impl/bitstring_status_list_20240406/syntax/status_list/mod.rs @@ -0,0 +1,116 @@ +use iref::UriBuf; +use serde::{Deserialize, Serialize}; + +mod credential; +pub use credential::*; + +use crate::bitstring_status_list_20240406::{ + EncodedList, StatusList, StatusMessage, StatusPurpose, StatusSize, TimeToLive, +}; + +pub const BITSTRING_STATUS_LIST_TYPE: &str = "BitstringStatusList"; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub struct BitstringStatusList { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + + /// Status purpose. + pub status_purpose: StatusPurpose, + + #[serde(default, skip_serializing_if = "StatusSize::is_default")] + pub status_size: StatusSize, + + /// Encoded status list. + pub encoded_list: EncodedList, + + /// Time to live. + #[serde(default, skip_serializing_if = "TimeToLive::is_default")] + pub ttl: TimeToLive, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub status_message: Vec, + + /// URL to material related to the status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status_reference: Option, +} + +impl BitstringStatusList { + pub fn new( + id: Option, + status_purpose: StatusPurpose, + status_size: StatusSize, + encoded_list: EncodedList, + ttl: TimeToLive, + status_message: Vec, + ) -> Self { + Self { + id, + status_purpose, + status_size, + encoded_list, + ttl, + status_message, + status_reference: None, + } + } + + pub fn decode(&self) -> Result { + let bytes = self.encoded_list.decode(None)?; + Ok(StatusList::from_bytes(self.status_size, bytes, self.ttl)) + } +} + +#[cfg(test)] +mod tests { + use super::BitstringStatusList; + use crate::bitstring_status_list_20240406::{ + EncodedList, StatusMessage, StatusPurpose, TimeToLive, + }; + + const STATUS_LIST: &str = r#"{ + "id": "https://example.com/status/3#list", + "type": "BitstringStatusList", + "ttl": 500, + "statusPurpose": "message", + "statusReference": "https://example.org/status-dictionary/", + "statusSize": 2, + "statusMessage": [ + {"status":"0x0", "message":"valid"}, + {"status":"0x1", "message":"invalid"}, + {"status":"0x2", "message":"pending_review"} + ], + "encodedList": "uH4sIAAAAAAAAA-3BMQEAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA" + }"#; + + #[test] + fn deserialize() { + serde_json::from_str::(STATUS_LIST).unwrap(); + } + + #[test] + fn serialize() { + let expected: serde_json::Value = serde_json::from_str(STATUS_LIST).unwrap(); + + let status_list = BitstringStatusList { + id: Some("https://example.com/status/3#list".parse().unwrap()), + ttl: TimeToLive(500), + status_purpose: StatusPurpose::Message, + status_reference: Some("https://example.org/status-dictionary/".parse().unwrap()), + status_size: 2.try_into().unwrap(), + status_message: vec![ + StatusMessage::new(0, "valid".to_owned()), + StatusMessage::new(1, "invalid".to_owned()), + StatusMessage::new(2, "pending_review".to_owned()), + ], + encoded_list: EncodedList::new( + "uH4sIAAAAAAAAA-3BMQEAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA".to_owned(), + ), + }; + + let value = serde_json::to_value(status_list).unwrap(); + assert_eq!(value, expected); + } +} diff --git a/crates/status/src/impl/mod.rs b/crates/status/src/impl/mod.rs index 082de2dbb..eb2423437 100644 --- a/crates/status/src/impl/mod.rs +++ b/crates/status/src/impl/mod.rs @@ -1,5 +1,6 @@ pub mod any; pub mod bitstring_status_list; +pub mod bitstring_status_list_20240406; pub mod token_status_list; pub use flate2::Compression;