identity_credential/sd_jwt_vc/
builder.rs

1// Copyright 2020-2024 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(clippy::vec_init_then_push)]
5use std::sync::LazyLock;
6
7use identity_core::common::StringOrUrl;
8use identity_core::common::Timestamp;
9use identity_core::common::Url;
10use identity_core::convert::ToJson;
11use sd_jwt_payload_rework::Hasher;
12use sd_jwt_payload_rework::JsonObject;
13use sd_jwt_payload_rework::JwsSigner;
14use sd_jwt_payload_rework::RequiredKeyBinding;
15use sd_jwt_payload_rework::SdJwtBuilder;
16use sd_jwt_payload_rework::Sha256Hasher;
17use serde::Serialize;
18use serde_json::json;
19use serde_json::Value;
20
21use crate::credential::Credential;
22use crate::credential::CredentialJwtClaims;
23
24use super::Error;
25use super::Result;
26use super::SdJwtVc;
27use super::Status;
28use super::SD_JWT_VC_TYP;
29
30static DEFAULT_HEADER: LazyLock<JsonObject> = LazyLock::new(|| {
31  let mut object = JsonObject::default();
32  object.insert("typ".to_string(), SD_JWT_VC_TYP.into());
33  object
34});
35
36macro_rules! claim_to_key_value_pair {
37  ( $( $claim:ident ),+ ) => {
38    {
39      let mut claim_list = Vec::<(&'static str, serde_json::Value)>::new();
40      $(
41        claim_list.push((stringify!($claim), serde_json::to_value($claim).unwrap()));
42      )*
43      claim_list
44    }
45  };
46}
47
48/// A structure to ease the creation of an [`SdJwtVc`].
49#[derive(Debug)]
50pub struct SdJwtVcBuilder<H = Sha256Hasher> {
51  inner_builder: SdJwtBuilder<H>,
52  header: JsonObject,
53  iss: Option<Url>,
54  nbf: Option<i64>,
55  exp: Option<i64>,
56  iat: Option<i64>,
57  vct: Option<StringOrUrl>,
58  sub: Option<StringOrUrl>,
59  status: Option<Status>,
60}
61
62impl Default for SdJwtVcBuilder {
63  fn default() -> Self {
64    Self {
65      inner_builder: SdJwtBuilder::<Sha256Hasher>::new(json!({})).unwrap(),
66      header: DEFAULT_HEADER.clone(),
67      iss: None,
68      nbf: None,
69      exp: None,
70      iat: None,
71      vct: None,
72      sub: None,
73      status: None,
74    }
75  }
76}
77
78impl SdJwtVcBuilder {
79  /// Creates a new [`SdJwtVcBuilder`] using `object` JSON representation and default
80  /// `sha-256` hasher.
81  pub fn new<T: Serialize>(object: T) -> Result<Self> {
82    let inner_builder = SdJwtBuilder::<Sha256Hasher>::new(object)?;
83    Ok(Self {
84      header: DEFAULT_HEADER.clone(),
85      inner_builder,
86      ..Default::default()
87    })
88  }
89}
90
91impl<H: Hasher> SdJwtVcBuilder<H> {
92  /// Creates a new [`SdJwtVcBuilder`] using `object` JSON representation and a given
93  /// hasher `hasher`.
94  pub fn new_with_hasher<T: Serialize>(object: T, hasher: H) -> Result<Self> {
95    let inner_builder = SdJwtBuilder::new_with_hasher(object, hasher)?;
96    Ok(Self {
97      inner_builder,
98      header: DEFAULT_HEADER.clone(),
99      iss: None,
100      nbf: None,
101      exp: None,
102      iat: None,
103      vct: None,
104      sub: None,
105      status: None,
106    })
107  }
108
109  /// Creates a new [`SdJwtVcBuilder`] starting from a [`Credential`] that is converted to a JWT claim set.
110  pub fn new_from_credential(credential: Credential, hasher: H) -> std::result::Result<Self, crate::Error> {
111    let mut vc_jwt_claims = CredentialJwtClaims::new(&credential, None)?
112      .to_json_value()
113      .map_err(|e| crate::Error::JwtClaimsSetSerializationError(Box::new(e)))?;
114    // When converting a VC to its JWT claims representation, some VC specific claims are putted into a `vc` object
115    // property. Flatten out `vc`, keeping the other JWT claims intact.
116    {
117      let claims = vc_jwt_claims.as_object_mut().expect("serialized VC is a JSON object");
118      let Value::Object(vc_properties) = claims.remove("vc").expect("serialized VC has `vc` property") else {
119        unreachable!("`vc` property's value is a JSON object");
120      };
121      for (key, value) in vc_properties {
122        claims.insert(key, value);
123      }
124    }
125    Ok(Self::new_with_hasher(vc_jwt_claims, hasher)?)
126  }
127
128  /// Substitutes a value with the digest of its disclosure.
129  ///
130  /// ## Notes
131  /// - `path` indicates the pointer to the value that will be concealed using the syntax of [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901).
132  ///
133  /// ## Example
134  /// ```rust
135  /// use serde_json::json;  
136  /// use identity_credential::sd_jwt_vc::SdJwtVcBuilder;
137  ///
138  /// let obj = json!({
139  ///   "id": "did:value",
140  ///   "claim1": {
141  ///      "abc": true
142  ///   },
143  ///   "claim2": ["val_1", "val_2"]
144  /// });
145  /// let builder = SdJwtVcBuilder::new(obj)
146  ///   .unwrap()
147  ///   .make_concealable("/id").unwrap() //conceals "id": "did:value"
148  ///   .make_concealable("/claim1/abc").unwrap() //"abc": true
149  ///   .make_concealable("/claim2/0").unwrap(); //conceals "val_1"
150  /// ```
151  pub fn make_concealable(mut self, path: &str) -> Result<Self> {
152    self.inner_builder = self.inner_builder.make_concealable(path)?;
153    Ok(self)
154  }
155
156  /// Sets the JWT header.
157  /// ## Notes
158  /// - if [`SdJwtVcBuilder::header`] is not called, the default header is used: ```json { "typ": "sd-jwt", "alg":
159  ///   "<algorithm used in SdJwtBuilder::finish>" } ```
160  /// - `alg` is always replaced with the value passed to [`SdJwtVcBuilder::finish`].
161  pub fn header(mut self, header: JsonObject) -> Self {
162    self.header = header;
163    self
164  }
165
166  /// Adds a decoy digest to the specified path.
167  ///
168  /// `path` indicates the pointer to the value that will be concealed using the syntax of
169  /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901).
170  ///
171  /// Use `path` = "" to add decoys to the top level.
172  pub fn add_decoys(mut self, path: &str, number_of_decoys: usize) -> Result<Self> {
173    self.inner_builder = self.inner_builder.add_decoys(path, number_of_decoys)?;
174
175    Ok(self)
176  }
177
178  /// Require a proof of possession of a given key from the holder.
179  ///
180  /// This operation adds a JWT confirmation (`cnf`) claim as specified in
181  /// [RFC8300](https://www.rfc-editor.org/rfc/rfc7800.html#section-3).
182  pub fn require_key_binding(mut self, key_bind: RequiredKeyBinding) -> Self {
183    self.inner_builder = self.inner_builder.require_key_binding(key_bind);
184    self
185  }
186
187  /// Inserts an `iss` claim. See [`super::SdJwtVcClaims::iss`].
188  pub fn iss(mut self, issuer: Url) -> Self {
189    self.iss = Some(issuer);
190    self
191  }
192
193  /// Inserts a `nbf` claim. See [`super::SdJwtVcClaims::nbf`].
194  pub fn nbf(mut self, nbf: Timestamp) -> Self {
195    self.nbf = Some(nbf.to_unix());
196    self
197  }
198
199  /// Inserts a `exp` claim. See [`super::SdJwtVcClaims::exp`].
200  pub fn exp(mut self, exp: Timestamp) -> Self {
201    self.exp = Some(exp.to_unix());
202    self
203  }
204
205  /// Inserts a `iat` claim. See [`super::SdJwtVcClaims::iat`].
206  pub fn iat(mut self, iat: Timestamp) -> Self {
207    self.iat = Some(iat.to_unix());
208    self
209  }
210
211  /// Inserts a `vct` claim. See [`super::SdJwtVcClaims::vct`].
212  pub fn vct(mut self, vct: impl Into<StringOrUrl>) -> Self {
213    self.vct = Some(vct.into());
214    self
215  }
216
217  /// Inserts a `sub` claim. See [`super::SdJwtVcClaims::sub`].
218  #[allow(clippy::should_implement_trait)]
219  pub fn sub(mut self, sub: impl Into<StringOrUrl>) -> Self {
220    self.sub = Some(sub.into());
221    self
222  }
223
224  /// Inserts a `status` claim. See [`super::SdJwtVcClaims::status`].
225  pub fn status(mut self, status: Status) -> Self {
226    self.status = Some(status);
227    self
228  }
229
230  /// Creates an [`SdJwtVc`] with the provided data.
231  pub async fn finish<S>(self, signer: &S, alg: &str) -> Result<SdJwtVc>
232  where
233    S: JwsSigner,
234  {
235    let Self {
236      inner_builder,
237      mut header,
238      iss,
239      nbf,
240      exp,
241      iat,
242      vct,
243      sub,
244      status,
245    } = self;
246    // Check header.
247    header
248      .entry("typ")
249      .or_insert_with(|| SD_JWT_VC_TYP.to_owned().into())
250      .as_str()
251      .filter(|typ| typ.contains(SD_JWT_VC_TYP))
252      .ok_or_else(|| Error::InvalidJoseType(String::default()))?;
253
254    let builder = inner_builder.header(header);
255
256    // Insert SD-JWT VC claims into object.
257    let builder = claim_to_key_value_pair![iss, nbf, exp, iat, vct, sub, status]
258      .into_iter()
259      .filter(|(_, value)| !value.is_null())
260      .fold(builder, |builder, (key, value)| {
261        builder.insert_claim(key, value).expect("value is a JSON Value")
262      });
263
264    let sd_jwt = builder.finish(signer, alg).await?;
265    SdJwtVc::try_from(sd_jwt)
266  }
267}
268
269#[cfg(test)]
270mod tests {
271
272  use super::*;
273  use crate::credential::CredentialBuilder;
274  use crate::credential::Subject;
275  use crate::sd_jwt_vc::tests::TestSigner;
276
277  #[tokio::test]
278  async fn building_valid_vc_works() -> anyhow::Result<()> {
279    let credential = json!({
280      "name": "John Doe",
281      "birthdate": "1970-01-01"
282    });
283
284    SdJwtVcBuilder::new(credential)?
285      .vct("https://bmi.bund.example/credential/pid/1.0".parse::<Url>()?)
286      .iat(Timestamp::now_utc())
287      .iss("https://example.com/".parse()?)
288      .make_concealable("/birthdate")?
289      .finish(&TestSigner, "HS256")
290      .await?;
291
292    Ok(())
293  }
294
295  #[tokio::test]
296  async fn building_vc_with_missing_mandatory_claims_fails() -> anyhow::Result<()> {
297    let credential = json!({
298      "name": "John Doe",
299      "birthdate": "1970-01-01"
300    });
301
302    let err = SdJwtVcBuilder::new(credential)?
303      .vct("https://bmi.bund.example/credential/pid/1.0".parse::<Url>()?)
304      .iat(Timestamp::now_utc())
305      // issuer is missing.
306      .make_concealable("/birthdate")?
307      .finish(&TestSigner, "HS256")
308      .await
309      .unwrap_err();
310    assert!(matches!(err, Error::MissingClaim("iss")));
311
312    Ok(())
313  }
314
315  #[tokio::test]
316  async fn building_vc_with_invalid_mandatory_claims_fails() -> anyhow::Result<()> {
317    let credential = json!({
318      "name": "John Doe",
319      "birthdate": "1970-01-01",
320      "vct": { "id": 1234567890 }
321    });
322
323    let err = SdJwtVcBuilder::new(credential)?
324      .iat(Timestamp::now_utc())
325      .iss("https://example.com".parse()?)
326      .make_concealable("/birthdate")?
327      .finish(&TestSigner, "HS256")
328      .await
329      .unwrap_err();
330
331    assert!(matches!(err, Error::InvalidClaimValue { name: "vct", .. }));
332
333    Ok(())
334  }
335
336  #[tokio::test]
337  async fn building_vc_with_disclosed_mandatory_claim_fails() -> anyhow::Result<()> {
338    let credential = json!({
339      "name": "John Doe",
340      "birthdate": "1970-01-01",
341      "vct": { "id": 1234567890 }
342    });
343
344    let err = SdJwtVcBuilder::new(credential)?
345      .iat(Timestamp::now_utc())
346      .iss("https://example.com".parse()?)
347      .make_concealable("/birthdate")?
348      .make_concealable("/vct")?
349      .finish(&TestSigner, "HS256")
350      .await
351      .unwrap_err();
352
353    assert!(matches!(err, Error::DisclosedClaim("vct")));
354
355    Ok(())
356  }
357
358  #[tokio::test]
359  async fn building_sd_jwt_vc_from_credential_works() -> anyhow::Result<()> {
360    let credential = CredentialBuilder::default()
361      .id(Url::parse("https://example.com/credentials/42")?)
362      .issuance_date(Timestamp::now_utc())
363      .issuer(Url::parse("https://example.com/issuers/42")?)
364      .subject(Subject::with_id(Url::parse("https://example.com/subjects/42")?))
365      .build()?;
366
367    let sd_jwt_vc = SdJwtVcBuilder::new_from_credential(credential.clone(), Sha256Hasher)?
368      .vct(Url::parse("https://example.com/types/0")?)
369      .finish(&TestSigner, "HS256")
370      .await?;
371
372    assert_eq!(sd_jwt_vc.claims().nbf.as_ref().unwrap(), &credential.issuance_date);
373    assert_eq!(&sd_jwt_vc.claims().iss, credential.issuer.url());
374    assert_eq!(
375      sd_jwt_vc.claims().sub.as_ref().unwrap().as_url(),
376      credential.credential_subject.first().unwrap().id.as_ref()
377    );
378    assert_eq!(
379      sd_jwt_vc.claims().get("jti"),
380      Some(&json!(credential.id.as_ref().unwrap()))
381    );
382    assert_eq!(sd_jwt_vc.claims().get("type"), Some(&json!("VerifiableCredential")));
383
384    Ok(())
385  }
386}