identity_credential/revocation/status_list_2021/
credential.rs

1// Copyright 2020-2024 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use std::fmt::Display;
5use std::ops::Deref;
6use std::str::FromStr;
7
8use identity_core::common::Context;
9use identity_core::common::OneOrMany;
10use identity_core::common::Timestamp;
11use identity_core::common::Url;
12use serde::Deserialize;
13use serde::Serialize;
14use serde_json::Value;
15use thiserror::Error;
16
17/// The type of a `StatusList2021Credential`.
18pub const CREDENTIAL_TYPE: &str = "StatusList2021Credential";
19const CREDENTIAL_SUBJECT_TYPE: &str = "StatusList2021";
20
21/// [Error](std::error::Error) type that represents the possible errors that can be
22/// encountered when dealing with [`StatusList2021Credential`]s.
23#[derive(Clone, Debug, Error, strum::IntoStaticStr, PartialEq, Eq)]
24pub enum StatusList2021CredentialError {
25  /// The provided [`Credential`] has more than one `credentialSubject`.
26  #[error("A StatusList2021Credential may only have one credentialSubject")]
27  MultipleCredentialSubject,
28  /// The provided [`Credential`] has an invalid property.
29  #[error("Invalid property \"{0}\"")]
30  InvalidProperty(&'static str),
31  /// The provided [`Credential`] doesn't have a mandatory property.
32  #[error("Missing property \"{0}\"")]
33  MissingProperty(&'static str),
34  /// Inner status list failures.
35  #[error(transparent)]
36  StatusListError(#[from] StatusListError),
37  /// Missing status list id.
38  #[error("Cannot set the status of a credential without a \"credentialSubject.id\".")]
39  Unreferenceable,
40  /// Credentials cannot be unrevoked.
41  #[error("A previously revoked credential cannot be unrevoked.")]
42  UnreversibleRevocation,
43}
44
45use crate::credential::Credential;
46use crate::credential::CredentialBuilder;
47use crate::credential::Issuer;
48use crate::credential::Proof;
49use crate::credential::Subject;
50
51use super::status_list::StatusListError;
52use super::StatusList2021;
53use super::StatusList2021Entry;
54
55/// A parsed [StatusList2021Credential](https://www.w3.org/TR/2023/WD-vc-status-list-20230427/#statuslist2021credential).
56#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
57#[serde(try_from = "Credential", into = "Credential")]
58pub struct StatusList2021Credential {
59  inner: Credential,
60  subject: StatusList2021CredentialSubject,
61}
62
63impl Display for StatusList2021Credential {
64  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65    write!(f, "{}", &self.inner)
66  }
67}
68
69impl From<StatusList2021Credential> for Credential {
70  fn from(value: StatusList2021Credential) -> Self {
71    value.into_inner()
72  }
73}
74
75impl Deref for StatusList2021Credential {
76  type Target = Credential;
77  fn deref(&self) -> &Self::Target {
78    &self.inner
79  }
80}
81
82impl TryFrom<Credential> for StatusList2021Credential {
83  type Error = StatusList2021CredentialError;
84  fn try_from(mut credential: Credential) -> Result<Self, Self::Error> {
85    let has_right_credential_type = credential.types.contains(&CREDENTIAL_TYPE.to_owned());
86    let subject = StatusList2021CredentialSubject::try_from_credential(&mut credential)?;
87
88    if has_right_credential_type {
89      Ok(Self {
90        inner: credential,
91        subject,
92      })
93    } else {
94      Err(StatusList2021CredentialError::InvalidProperty("type"))
95    }
96  }
97}
98
99impl StatusList2021Credential {
100  /// Returns the inner "raw" [`Credential`].
101  pub fn into_inner(self) -> Credential {
102    let Self { mut inner, subject } = self;
103    inner.credential_subject = OneOrMany::One(subject.into());
104    inner
105  }
106
107  /// Returns the id of this credential.
108  pub fn id(&self) -> Option<&Url> {
109    self.subject.id.as_ref()
110  }
111
112  /// Returns the purpose of this status list.
113  pub fn purpose(&self) -> StatusPurpose {
114    self.subject.status_purpose
115  }
116
117  fn status_list(&self) -> Result<StatusList2021, StatusListError> {
118    StatusList2021::try_from_encoded_str(&self.subject.encoded_list)
119  }
120
121  /// Sets the credential status of a given [`Credential`],
122  /// mapping it to the `index`-th entry of this [`StatusList2021Credential`].
123  ///
124  /// ## Note:
125  /// - A revoked credential cannot ever be unrevoked and will lead to a
126  ///   [`StatusList2021CredentialError::UnreversibleRevocation`].
127  /// - Trying to set `revoked_or_suspended` to `false` for an already valid credential will have no impact.
128  pub fn set_credential_status(
129    &mut self,
130    credential: &mut Credential,
131    index: usize,
132    revoked_or_suspended: bool,
133  ) -> Result<StatusList2021Entry, StatusList2021CredentialError> {
134    let id = self
135      .id()
136      .cloned()
137      .ok_or(StatusList2021CredentialError::Unreferenceable)?;
138    let entry = StatusList2021Entry::new(id, self.purpose(), index, None);
139
140    self.set_entry(index, revoked_or_suspended)?;
141    credential.credential_status = Some(entry.clone().into());
142
143    Ok(entry)
144  }
145
146  /// Apply `update_fn` to the status list encoded in this credential.
147  pub fn update<F>(&mut self, update_fn: F) -> Result<(), StatusList2021CredentialError>
148  where
149    F: FnOnce(&mut MutStatusList) -> Result<(), StatusList2021CredentialError>,
150  {
151    let mut encapsuled_status_list = MutStatusList {
152      status_list: self.status_list()?,
153      purpose: self.purpose(),
154    };
155    update_fn(&mut encapsuled_status_list)?;
156
157    self.subject.encoded_list = encapsuled_status_list.status_list.into_encoded_str();
158    Ok(())
159  }
160
161  /// Sets the `index`-th entry to `value`
162  pub(crate) fn set_entry(&mut self, index: usize, value: bool) -> Result<(), StatusList2021CredentialError> {
163    let mut status_list = self.status_list()?;
164    let entry_status = status_list.get(index)?;
165    if self.purpose() == StatusPurpose::Revocation && !value && entry_status {
166      return Err(StatusList2021CredentialError::UnreversibleRevocation);
167    }
168    status_list.set(index, value)?;
169    self.subject.encoded_list = status_list.into_encoded_str();
170
171    Ok(())
172  }
173
174  /// Returns the status of the `index-th` entry.
175  pub fn entry(&self, index: usize) -> Result<CredentialStatus, StatusList2021CredentialError> {
176    let status_list = self.status_list()?;
177    Ok(match (self.purpose(), status_list.get(index)?) {
178      (StatusPurpose::Revocation, true) => CredentialStatus::Revoked,
179      (StatusPurpose::Suspension, true) => CredentialStatus::Suspended,
180      _ => CredentialStatus::Valid,
181    })
182  }
183}
184
185/// A wrapper over the [`StatusList2021`] contained in a [`StatusList2021Credential`]
186/// that allows for its mutation.
187pub struct MutStatusList {
188  status_list: StatusList2021,
189  purpose: StatusPurpose,
190}
191
192impl MutStatusList {
193  /// Sets the value of the `index`-th entry in the status list.
194  pub fn set_entry(&mut self, index: usize, value: bool) -> Result<(), StatusList2021CredentialError> {
195    let entry_status = self.status_list.get(index)?;
196    if self.purpose == StatusPurpose::Revocation && !value && entry_status {
197      return Err(StatusList2021CredentialError::UnreversibleRevocation);
198    }
199    self.status_list.set(index, value)?;
200    Ok(())
201  }
202}
203
204/// The status of a credential referenced inside a [`StatusList2021Credential`]
205#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
206pub enum CredentialStatus {
207  /// A revoked credential
208  Revoked,
209  /// A suspended credential
210  Suspended,
211  /// A valid credential
212  Valid,
213}
214
215/// [`StatusList2021Credential`]'s purpose.
216#[derive(Default, Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
217#[serde(rename_all = "lowercase")]
218pub enum StatusPurpose {
219  /// Used for revocation.
220  #[default]
221  Revocation,
222  /// Used for suspension.
223  Suspension,
224}
225
226impl Display for StatusPurpose {
227  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228    let s = match self {
229      Self::Revocation => "revocation",
230      Self::Suspension => "suspension",
231    };
232    write!(f, "{s}")
233  }
234}
235
236impl FromStr for StatusPurpose {
237  type Err = ();
238  fn from_str(s: &str) -> Result<Self, Self::Err> {
239    match s {
240      "revocation" => Ok(Self::Revocation),
241      "suspension" => Ok(Self::Suspension),
242      _ => Err(()),
243    }
244  }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Default)]
248struct StatusList2021CredentialSubject {
249  status_purpose: StatusPurpose,
250  encoded_list: String,
251  id: Option<Url>,
252}
253
254impl From<StatusList2021CredentialSubject> for Subject {
255  fn from(value: StatusList2021CredentialSubject) -> Self {
256    let properties = [
257      (
258        "statusPurpose".to_owned(),
259        Value::String(value.status_purpose.to_string()),
260      ),
261      ("type".to_owned(), Value::String(CREDENTIAL_SUBJECT_TYPE.to_owned())),
262      ("encodedList".to_owned(), Value::String(value.encoded_list)),
263    ]
264    .into_iter()
265    .collect();
266
267    if let Some(id) = value.id {
268      Subject::with_id_and_properties(id, properties)
269    } else {
270      Subject::with_properties(properties)
271    }
272  }
273}
274
275impl StatusList2021CredentialSubject {
276  /// Parse a StatusListCredentialSubject out of a credential, without copying.
277  fn try_from_credential(credential: &mut Credential) -> Result<Self, StatusList2021CredentialError> {
278    let OneOrMany::One(mut subject) = std::mem::take(&mut credential.credential_subject) else {
279      return Err(StatusList2021CredentialError::MultipleCredentialSubject);
280    };
281    if let Some(subject_type) = subject.properties.get("type") {
282      if subject_type.as_str() != Some(CREDENTIAL_SUBJECT_TYPE) {
283        return Err(StatusList2021CredentialError::InvalidProperty("credentialSubject.type"));
284      }
285    } else {
286      return Err(StatusList2021CredentialError::MissingProperty("credentialSubject.type"));
287    }
288    let status_purpose = subject
289      .properties
290      .get("statusPurpose")
291      .ok_or(StatusList2021CredentialError::MissingProperty(
292        "credentialSubject.statusPurpose",
293      ))
294      .and_then(|value| {
295        value
296          .as_str()
297          .and_then(|purpose| StatusPurpose::from_str(purpose).ok())
298          .ok_or(StatusList2021CredentialError::InvalidProperty(
299            "credentialSubject.statusPurpose",
300          ))
301      })?;
302    let encoded_list = subject
303      .properties
304      .get_mut("encodedList")
305      .ok_or(StatusList2021CredentialError::MissingProperty(
306        "credentialSubject.encodedList",
307      ))
308      .and_then(|value| {
309        if let Value::String(ref mut s) = value {
310          Ok(s)
311        } else {
312          Err(StatusList2021CredentialError::InvalidProperty(
313            "credentialSubject.encodedList",
314          ))
315        }
316      })
317      .map(std::mem::take)?;
318
319    Ok(StatusList2021CredentialSubject {
320      id: subject.id,
321      encoded_list,
322      status_purpose,
323    })
324  }
325}
326
327/// Builder type for [`StatusList2021Credential`].
328#[derive(Debug, Default)]
329pub struct StatusList2021CredentialBuilder {
330  inner_builder: CredentialBuilder,
331  credential_subject: StatusList2021CredentialSubject,
332}
333
334impl StatusList2021CredentialBuilder {
335  /// Creates a new [`StatusList2021CredentialBuilder`] from a [`StatusList2021`].
336  pub fn new(status_list: StatusList2021) -> Self {
337    let credential_subject = StatusList2021CredentialSubject {
338      encoded_list: status_list.into_encoded_str(),
339      ..Default::default()
340    };
341    Self {
342      credential_subject,
343      ..Default::default()
344    }
345  }
346
347  /// Sets `credentialSubject.statusPurpose`.
348  pub const fn purpose(mut self, purpose: StatusPurpose) -> Self {
349    self.credential_subject.status_purpose = purpose;
350    self
351  }
352
353  /// Sets `credentialSubject.id`.
354  pub fn subject_id(mut self, id: Url) -> Self {
355    self.credential_subject.id = Some(id);
356    self
357  }
358
359  /// Sets `expirationDate`.
360  pub const fn expiration_date(mut self, time: Timestamp) -> Self {
361    self.inner_builder.expiration_date = Some(time);
362    self
363  }
364
365  /// Sets `issuer`.
366  pub fn issuer(mut self, issuer: Issuer) -> Self {
367    self.inner_builder.issuer = Some(issuer);
368    self
369  }
370
371  /// Adds a `@context` entry.
372  pub fn context(mut self, ctx: Context) -> Self {
373    self.inner_builder.context.push(ctx);
374    self
375  }
376
377  /// Adds a `type` entry.
378  pub fn add_type(mut self, type_: String) -> Self {
379    self.inner_builder.types.push(type_);
380    self
381  }
382
383  /// Adds a credential proof.
384  pub fn proof(mut self, proof: Proof) -> Self {
385    self.inner_builder.proof = Some(proof);
386    self
387  }
388
389  /// Consumes this [`StatusList2021CredentialBuilder`] into a [`StatusList2021Credential`].
390  pub fn build(mut self) -> Result<StatusList2021Credential, crate::Error> {
391    let id = self.credential_subject.id.clone().map(|mut url| {
392      url.set_fragment(None);
393      url
394    });
395    self.inner_builder.id = id;
396    self
397      .inner_builder
398      .type_(CREDENTIAL_TYPE)
399      .issuance_date(Timestamp::now_utc())
400      .subject(Subject {
401        id: self.credential_subject.id.clone(),
402        ..Default::default()
403      })
404      .build()
405      .map(|mut credential| {
406        credential.credential_subject = OneOrMany::default();
407        StatusList2021Credential {
408          subject: self.credential_subject,
409          inner: credential,
410        }
411      })
412  }
413}
414
415#[cfg(test)]
416mod tests {
417  use super::*;
418
419  const STATUS_LIST_2021_CREDENTIAL_SAMPLE: &str = r#"
420{
421  "@context": [
422    "https://www.w3.org/2018/credentials/v1",
423    "https://w3id.org/vc/status-list/2021/v1"
424  ],
425  "id": "https://example.com/credentials/status/3",
426  "type": ["VerifiableCredential", "StatusList2021Credential"],
427  "issuer": "did:example:12345",
428  "issuanceDate": "2021-04-05T14:27:40Z",
429  "credentialSubject": {
430    "id": "https://example.com/status/3#list",
431    "type": "StatusList2021",
432    "statusPurpose": "revocation",
433    "encodedList": "H4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA"
434  }
435}
436  "#;
437
438  #[test]
439  fn status_purpose_serialization_works() {
440    assert_eq!(
441      serde_json::to_string(&StatusPurpose::Revocation).ok(),
442      Some(format!("\"{}\"", StatusPurpose::Revocation))
443    );
444  }
445  #[test]
446  fn status_purpose_deserialization_works() {
447    assert_eq!(
448      serde_json::from_str::<StatusPurpose>("\"suspension\"").ok(),
449      Some(StatusPurpose::Suspension),
450    )
451  }
452  #[test]
453  fn status_list_2021_credential_deserialization_works() {
454    let credential = serde_json::from_str::<StatusList2021Credential>(STATUS_LIST_2021_CREDENTIAL_SAMPLE)
455      .expect("Failed to deserialize");
456    assert_eq!(credential.purpose(), StatusPurpose::Revocation);
457  }
458  #[test]
459  fn revoked_credential_cannot_be_unrevoked() {
460    let url = Url::parse("http://example.com").unwrap();
461    let mut status_list_credential = StatusList2021CredentialBuilder::new(StatusList2021::default())
462      .issuer(Issuer::Url(url.clone()))
463      .purpose(StatusPurpose::Revocation)
464      .subject_id(url)
465      .build()
466      .unwrap();
467
468    assert!(status_list_credential.set_entry(420, false).is_ok());
469    status_list_credential.set_entry(420, true).unwrap();
470    assert_eq!(
471      status_list_credential.set_entry(420, false),
472      Err(StatusList2021CredentialError::UnreversibleRevocation)
473    );
474  }
475  #[test]
476  fn suspended_credential_can_be_unsuspended() {
477    let url = Url::parse("http://example.com").unwrap();
478    let mut status_list_credential = StatusList2021CredentialBuilder::new(StatusList2021::default())
479      .issuer(Issuer::Url(url.clone()))
480      .purpose(StatusPurpose::Suspension)
481      .subject_id(url)
482      .build()
483      .unwrap();
484
485    assert!(status_list_credential.set_entry(420, false).is_ok());
486    status_list_credential.set_entry(420, true).unwrap();
487    assert!(status_list_credential.set_entry(420, false).is_ok());
488  }
489}