Skip to main content

identity_kit/sd_jwt_vc/
credential_v2.rs

1//! VCDM 2.0 credentials secured with SD-JWT (VC-JOSE-COSE §3.2.1): the credential is the
2//! payload of the issuer-signed JWT, `typ` is `vc+sd-jwt`.
3
4use core::fmt;
5use core::str::FromStr;
6use std::collections::BTreeSet;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use serde_json::{Map, Value};
11
12use super::{
13    ClaimPath, Confirmation, Disclosure, HashAlgorithm, KeyBinding, PathComponent, Processed,
14    SdJwt, SdJwtBuilder, SdJwtError,
15};
16use crate::jose::{Jwk, JwsHeader, Signer};
17use crate::status_list::Status;
18use crate::vc::{CredentialError, CredentialV2, JwtClaims};
19
20/// Why an SD-JWT is not a VCDM 2.0 credential secured with SD-JWT.
21#[derive(Debug, thiserror::Error)]
22#[non_exhaustive]
23pub enum SdJwtCredentialV2Error {
24    /// The SD-JWT itself is invalid.
25    #[error(transparent)]
26    SdJwt(#[from] SdJwtError),
27    /// The `typ` is present and is not `vc+sd-jwt` (nor one the caller accepts).
28    #[error("header `typ` is {0:?}, not `vc+sd-jwt`")]
29    UnexpectedTyp(String),
30    /// A claim that must not be selectively disclosed came from a disclosure.
31    #[error("`{0}` must not be selectively disclosed")]
32    DisclosedRegisteredClaim(String),
33    /// The processed payload is not a JWT claims set; the source is the JSON error.
34    #[error("the payload is not a JWT claims set: {0}")]
35    Payload(#[source] serde_json::Error),
36    /// `cnf` or `status` is not what the claim must hold.
37    #[error("claim `{claim}`: {reason}")]
38    InvalidClaim {
39        /// The claim.
40        claim: &'static str,
41        /// What is wrong with it.
42        reason: String,
43    },
44    /// The processed payload is not a VCDM 2.0 credential, or its registered claims disagree
45    /// with it.
46    #[error(transparent)]
47    Credential(#[from] CredentialError),
48}
49
50/// A VCDM 2.0 credential secured with SD-JWT (VC-JOSE-COSE §3.2.1): an [`SdJwt`] whose
51/// processed payload is a [`CredentialV2`], with `typ: vc+sd-jwt`, and `iss`, `cnf` and
52/// `status` beside it.
53///
54/// This is the VCDM counterpart of [`SdJwtVc`](super::SdJwtVc), which models the IETF SD-JWT
55/// VC claims set with its `vct`. Both are SD-JWTs and share [`SdJwt`]; telling them apart is
56/// described in the [module documentation](super#two-sd-jwt-credential-types).
57///
58/// Parsing processes the disclosures (so the digests and the structure are checked), reads
59/// the payload as a credential through [`CredentialV2::from_jwt_claims`] (so `iss`, `jti` and
60/// `sub`, when present, agree with `issuer`, `id` and the subject, and a `vc` claim is
61/// refused) and checks that none of [`NON_DISCLOSABLE_CLAIMS`](Self::NON_DISCLOSABLE_CLAIMS)
62/// came from a disclosure. No signature is verified: that is
63/// [`SdJwtCredentialValidator::validate_v2`](super::SdJwtCredentialValidator::validate_v2).
64///
65/// # Examples
66///
67/// ```
68/// use identity_kit::sd_jwt_vc::{SdJwt, SdJwtCredentialV2};
69///
70/// // The W3C VC-JOSE-COSE test suite's "SD-JWT Basic Credential Verification" input, with
71/// // `firstName` and `lastName` selectively disclosable, both disclosed. (Its `iat` is an RFC
72/// // 3339 string where RFC 7519 wants a NumericDate, so it processes as an SD-JWT but does
73/// // not map to a credential; the crate's tests cover both.)
74/// let sd_jwt: SdJwt = "eyJhbGciOiJFUzM4NCIsImN0eSI6InZjIiwia2lkIjoiOTZxQm5EcHZTNng4WVNDSF94bW5\
75///     MbFhaTWtEaEhCcjhsajk5dTVhb2c3cyIsInR5cCI6InZjK3NkLWp3dCJ9.eyJAY29udGV4dCI6WyJodHRwczovL3\
76///     d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvZ\
77///     XhhbXBsZXMvdjIiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiX3NkIjpbImtuYzN2Z2pmZlpUUDdwRHk2OVAxVndO\
78///     V1RGVFhxblg1b0R1Nl9wU2RTYzAiLCIxeElDZkE3Z3VLVXZwU3F6RF9TelhwTU55U1RyWVRDSWNwNEwxOU1Ydn\
79///     FzIl0sImJpcnRoRGF0ZSI6IjE5OTAtMDEtMDEiLCJpZCI6ImRpZDpleGFtcGxlOmViZmViMWY3MTJlYmM2ZjFj\
80///     Mjc2ZTEyZWMyMSJ9LCJpYXQiOiIyMDI0LTAxLTAxVDAwOjAwOjAwWiIsImlzcyI6Imh0dHBzOi8vZXhhbXBsZS\
81///     5pc3N1ZXIvdmMtam9zZS1jb3NlIiwiaXNzdWVyIjoiaHR0cHM6Ly9leGFtcGxlLmlzc3Vlci92Yy1qb3NlLWNv\
82///     c2UiLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiSWRlbnRpdHlDcmVkZW50aWFsIl0sInZhbGlkRn\
83///     JvbSI6IjIwMjQtMDEtMDFUMDA6MDA6MDBaIn0.bCgz_EgNH5ELRr-xvudGvtIWMQxobkjKLnQer3iXV3iZk4_v\
84///     KpMG8hUzvrrWlcpoxJo-Dh_Usm8LYcFOdnCHXdVNlnfz8baLEvNRcUc-60NkEwD739qjbEEvrCnYY9jb~WyI0b\
85///     UZLME14VEsyRmluZFNxQ0ktSzhBIiwiZmlyc3ROYW1lIiwiSmFuZSJd~WyJvU3lzbndsZ1ZGREUzZzdsbTJKZF\
86///     ZRIiwibGFzdE5hbWUiLCJEb2UiXQ~"
87///     .parse()?;
88/// assert_eq!(
89///     sd_jwt.jwt().decode()?.header().typ.as_deref(),
90///     Some(SdJwtCredentialV2::TYP)
91/// );
92/// let processed = sd_jwt.process()?;
93/// assert_eq!(processed.payload["credentialSubject"]["firstName"], "Jane");
94/// assert_eq!(processed.payload["credentialSubject"]["lastName"], "Doe");
95/// assert_eq!(processed.disclosed.len(), 2);
96/// # Ok::<(), Box<dyn std::error::Error>>(())
97/// ```
98#[derive(Clone, PartialEq, Eq)]
99pub struct SdJwtCredentialV2 {
100    sd_jwt: SdJwt,
101    header: JwsHeader,
102    processed: Processed,
103    claims: JwtClaims,
104    credential: CredentialV2,
105    cnf: Option<Confirmation>,
106    status: Option<Status>,
107}
108
109impl SdJwtCredentialV2 {
110    /// The `typ` of the issuer-signed JWT, `vc+sd-jwt`.
111    pub const TYP: &'static str = "vc+sd-jwt";
112
113    /// The media type, `application/vc+sd-jwt`.
114    pub const MEDIA_TYPE: &'static str = "application/vc+sd-jwt";
115
116    /// The claims that, with their sub-claims, must not be selectively disclosed: what a
117    /// verifier needs to validate the credential (VC-JOSE-COSE §3.2.1 names `@context`,
118    /// `type`, `credentialStatus`, `credentialSchema` and `relatedResource`), the issuer,
119    /// the validity period and the registered claims that carry it, the holder's key, the
120    /// status, and what SD-JWT reserves.
121    ///
122    /// A holder withholds a disclosure at will, so a verifier that received the credential
123    /// without, say, `credentialStatus` could not tell a credential issued without one from
124    /// one whose revocation data was dropped. Keeping these claims out of the disclosures is
125    /// what makes their absence meaningful.
126    pub const NON_DISCLOSABLE_CLAIMS: [&'static str; 14] = [
127        "@context",
128        "type",
129        "issuer",
130        "validFrom",
131        "validUntil",
132        "credentialStatus",
133        "credentialSchema",
134        "relatedResource",
135        "_sd_alg",
136        "iss",
137        "nbf",
138        "exp",
139        "cnf",
140        "status",
141    ];
142
143    /// Parses the compact serialization and processes the disclosures.
144    ///
145    /// # Errors
146    ///
147    /// As [`from_sd_jwt`](Self::from_sd_jwt).
148    pub fn parse(s: &str) -> Result<Self, SdJwtCredentialV2Error> {
149        Self::from_sd_jwt(SdJwt::parse(s)?)
150    }
151
152    /// Takes `sd_jwt` as a VCDM 2.0 credential secured with SD-JWT: `typ`, when present, is
153    /// `vc+sd-jwt`; the disclosures are processed; the payload is read as a credential; none
154    /// of [`NON_DISCLOSABLE_CLAIMS`](Self::NON_DISCLOSABLE_CLAIMS) came from a disclosure.
155    ///
156    /// # Errors
157    ///
158    /// [`SdJwtCredentialV2Error::UnexpectedTyp`],
159    /// [`SdJwtCredentialV2Error::DisclosedRegisteredClaim`],
160    /// [`SdJwtCredentialV2Error::Payload`], [`SdJwtCredentialV2Error::InvalidClaim`],
161    /// [`SdJwtCredentialV2Error::Credential`], or the [`SdJwtError`] of processing.
162    pub fn from_sd_jwt(sd_jwt: SdJwt) -> Result<Self, SdJwtCredentialV2Error> {
163        Self::from_sd_jwt_with(sd_jwt, &BTreeSet::new())
164    }
165
166    /// [`from_sd_jwt`](Self::from_sd_jwt), also accepting a `typ` in `additional_typ`.
167    ///
168    /// # Errors
169    ///
170    /// As [`from_sd_jwt`](Self::from_sd_jwt).
171    pub fn from_sd_jwt_with(
172        sd_jwt: SdJwt,
173        additional_typ: &BTreeSet<String>,
174    ) -> Result<Self, SdJwtCredentialV2Error> {
175        let header = sd_jwt
176            .jwt()
177            .decode()
178            .map_err(SdJwtError::from)?
179            .header()
180            .clone();
181        if let Some(typ) = header.typ.as_deref()
182            && typ != Self::TYP
183            && !additional_typ.contains(typ)
184        {
185            return Err(SdJwtCredentialV2Error::UnexpectedTyp(typ.to_owned()));
186        }
187        let processed = sd_jwt.process()?;
188        for claim in &processed.disclosed {
189            if let Some(PathComponent::Key(name)) = claim.path.components().first()
190                && Self::NON_DISCLOSABLE_CLAIMS.contains(&name.as_str())
191            {
192                return Err(SdJwtCredentialV2Error::DisclosedRegisteredClaim(
193                    name.clone(),
194                ));
195            }
196        }
197        let claims: JwtClaims = serde_json::from_value(Value::Object(processed.payload.clone()))
198            .map_err(SdJwtCredentialV2Error::Payload)?;
199        let mut rest = claims.clone();
200        let cnf = take_claim(&mut rest, "cnf")?;
201        let status = take_claim(&mut rest, "status")?;
202        let credential = CredentialV2::from_jwt_claims(&rest)?;
203        Ok(Self {
204            sd_jwt,
205            header,
206            processed,
207            claims,
208            credential,
209            cnf,
210            status,
211        })
212    }
213
214    /// The SD-JWT.
215    pub fn sd_jwt(&self) -> &SdJwt {
216        &self.sd_jwt
217    }
218
219    /// The SD-JWT.
220    pub fn into_sd_jwt(self) -> SdJwt {
221        self.sd_jwt
222    }
223
224    /// The header of the issuer-signed JWT.
225    pub fn header(&self) -> &JwsHeader {
226        &self.header
227    }
228
229    /// The credential, with the disclosures this SD-JWT carries applied.
230    pub fn credential(&self) -> &CredentialV2 {
231        &self.credential
232    }
233
234    /// The processed payload as a claims set: the registered claims (`iss`, `iat`, `exp`, …)
235    /// as fields, the credential's properties, `cnf` and `status` under `additional`.
236    pub fn claims(&self) -> &JwtClaims {
237        &self.claims
238    }
239
240    /// `cnf`: the holder's key, for key binding.
241    pub fn confirmation(&self) -> Option<&Confirmation> {
242        self.cnf.as_ref()
243    }
244
245    /// `status`: how to learn the status of the credential (Token Status List), beside any
246    /// `credentialStatus` of the credential itself.
247    pub fn status(&self) -> Option<&Status> {
248        self.status.as_ref()
249    }
250
251    /// The processed payload as a JSON object.
252    pub fn payload(&self) -> &Map<String, Value> {
253        &self.processed.payload
254    }
255
256    /// The outcome of processing: which claims the disclosures revealed.
257    pub fn processed(&self) -> &Processed {
258        &self.processed
259    }
260
261    /// The holder's public key from `cnf`, if given by value. A key given by reference
262    /// ([`Confirmation::kid`]) is in [`confirmation`](Self::confirmation), and
263    /// [`KeyReference::from_confirmation`](crate::resolver::KeyReference::from_confirmation)
264    /// reads either.
265    pub fn holder_key(&self) -> Option<&Jwk> {
266        self.cnf.as_ref()?.jwk.as_ref()
267    }
268
269    /// This credential with only the disclosures at `paths` and beneath them, plus those they
270    /// are nested in, without key binding; see [`SdJwt::disclose`].
271    ///
272    /// # Errors
273    ///
274    /// As [`SdJwt::disclose`].
275    pub fn disclose<'a>(
276        &self,
277        paths: impl IntoIterator<Item = &'a ClaimPath>,
278    ) -> Result<Self, SdJwtCredentialV2Error> {
279        Self::from_sd_jwt_with(self.sd_jwt.disclose(paths)?, &self.accepted_typ())
280    }
281
282    /// This credential with only the disclosures `keep` selects, without key binding; see
283    /// [`SdJwt::select`].
284    ///
285    /// # Errors
286    ///
287    /// As [`SdJwt::select`].
288    pub fn select(
289        &self,
290        keep: impl FnMut(&Disclosure) -> bool,
291    ) -> Result<Self, SdJwtCredentialV2Error> {
292        Self::from_sd_jwt_with(self.sd_jwt.select(keep)?, &self.accepted_typ())
293    }
294
295    /// This credential as an SD-JWT+KB; see [`SdJwt::sign_key_binding`].
296    ///
297    /// # Errors
298    ///
299    /// As [`SdJwt::sign_key_binding`].
300    pub async fn sign_key_binding(
301        self,
302        binding: &KeyBinding,
303        signer: &dyn Signer,
304    ) -> Result<Self, SdJwtCredentialV2Error> {
305        let sd_jwt = self.sd_jwt.sign_key_binding(binding, signer).await?;
306        Ok(Self { sd_jwt, ..self })
307    }
308
309    /// The `typ` this credential was accepted with, so that a derived credential is too.
310    fn accepted_typ(&self) -> BTreeSet<String> {
311        self.header.typ.iter().cloned().collect()
312    }
313}
314
315/// Removes `claim` from `claims.additional` and reads it as a `T`.
316fn take_claim<T: serde::de::DeserializeOwned>(
317    claims: &mut JwtClaims,
318    claim: &'static str,
319) -> Result<Option<T>, SdJwtCredentialV2Error> {
320    claims
321        .additional
322        .shift_remove(claim)
323        .map(|value| {
324            serde_json::from_value(value).map_err(|e| SdJwtCredentialV2Error::InvalidClaim {
325                claim,
326                reason: e.to_string(),
327            })
328        })
329        .transpose()
330}
331
332impl fmt::Debug for SdJwtCredentialV2 {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        f.debug_struct("SdJwtCredentialV2")
335            .field("header", &self.header)
336            .field("credential", &self.credential)
337            .field("cnf", &self.cnf)
338            .field("status", &self.status)
339            .field("sd_jwt", &self.sd_jwt)
340            .finish_non_exhaustive()
341    }
342}
343
344impl fmt::Display for SdJwtCredentialV2 {
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        fmt::Display::fmt(&self.sd_jwt, f)
347    }
348}
349
350impl FromStr for SdJwtCredentialV2 {
351    type Err = SdJwtCredentialV2Error;
352
353    fn from_str(s: &str) -> Result<Self, Self::Err> {
354        Self::parse(s)
355    }
356}
357
358impl Serialize for SdJwtCredentialV2 {
359    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
360        serializer.collect_str(self)
361    }
362}
363
364impl<'de> Deserialize<'de> for SdJwtCredentialV2 {
365    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
366        let s = String::deserialize(deserializer)?;
367        Self::parse(&s).map_err(serde::de::Error::custom)
368    }
369}
370
371/// Issues a VCDM 2.0 credential secured with SD-JWT: the credential is the payload, with
372/// `iss` set to its issuer and the registered time claims, `cnf` and `status` given here
373/// beside it; the properties at the paths given to [`conceal`](Self::conceal) are selectively
374/// disclosable; the issuer signs with `typ: vc+sd-jwt`.
375///
376/// # Examples
377///
378/// ```
379/// use identity_kit::did::Uri;
380/// use identity_kit::sd_jwt_vc::SdJwtCredentialV2Builder;
381/// use identity_kit::vc::{CredentialV2, Subject};
382/// use serde_json::json;
383///
384/// let credential = CredentialV2::new(
385///     Uri::parse("did:example:issuer")?,
386///     Subject::new(Uri::parse("did:example:holder")?)
387///         .with_property("given_name", "Erika")
388///         .with_property("degree", json!({ "type": "ExampleBachelorDegree" })),
389/// );
390/// let builder = SdJwtCredentialV2Builder::new(credential)
391///     .conceal(["credentialSubject", "given_name"])
392///     .with_decoys(2);
393/// assert!(builder.check_concealed().is_ok());
394/// // `issuer` and `@context` are needed to validate the credential and cannot be concealed.
395/// assert!(builder.clone_credential().conceal(["issuer"]).check_concealed().is_err());
396/// # Ok::<(), Box<dyn std::error::Error>>(())
397/// ```
398pub struct SdJwtCredentialV2Builder {
399    credential: CredentialV2,
400    iat: Option<i64>,
401    nbf: Option<i64>,
402    exp: Option<i64>,
403    cnf: Option<Confirmation>,
404    status: Option<Status>,
405    kid: Option<String>,
406    concealed: Vec<ClaimPath>,
407    decoys: usize,
408    alg: HashAlgorithm,
409    salts: Option<Box<dyn FnMut() -> String + Send>>,
410}
411
412impl SdJwtCredentialV2Builder {
413    /// A builder over `credential`.
414    pub fn new(credential: CredentialV2) -> Self {
415        Self {
416            credential,
417            iat: None,
418            nbf: None,
419            exp: None,
420            cnf: None,
421            status: None,
422            kid: None,
423            concealed: Vec::new(),
424            decoys: 0,
425            alg: HashAlgorithm::default(),
426            salts: None,
427        }
428    }
429
430    /// The credential.
431    pub fn credential(&self) -> &CredentialV2 {
432        &self.credential
433    }
434
435    /// A builder over a copy of this credential, nothing else set.
436    #[must_use]
437    pub fn clone_credential(&self) -> Self {
438        Self::new(self.credential.clone())
439    }
440
441    /// This builder with `iat`, beside the credential's `validFrom`.
442    #[must_use]
443    pub fn with_issued_at(mut self, time: DateTime<Utc>) -> Self {
444        self.iat = Some(JwtClaims::numeric_date(time));
445        self
446    }
447
448    /// This builder with `nbf`.
449    #[must_use]
450    pub fn with_not_before(mut self, time: DateTime<Utc>) -> Self {
451        self.nbf = Some(JwtClaims::numeric_date(time));
452        self
453    }
454
455    /// This builder with `exp`, beside the credential's `validUntil`.
456    #[must_use]
457    pub fn with_expires_at(mut self, time: DateTime<Utc>) -> Self {
458        self.exp = Some(JwtClaims::numeric_date(time));
459        self
460    }
461
462    /// This builder with `cnf`.
463    #[must_use]
464    pub fn with_confirmation(mut self, cnf: Confirmation) -> Self {
465        self.cnf = Some(cnf);
466        self
467    }
468
469    /// This builder with `cnf` holding the public part of `jwk`.
470    #[must_use]
471    pub fn with_holder_key(self, jwk: &Jwk) -> Self {
472        self.with_confirmation(Confirmation::jwk(jwk))
473    }
474
475    /// This builder with `status`.
476    #[must_use]
477    pub fn with_status(mut self, status: Status) -> Self {
478        self.status = Some(status);
479        self
480    }
481
482    /// This builder signing with `kid` in the header, instead of the signer's.
483    #[must_use]
484    pub fn with_kid(mut self, kid: impl Into<String>) -> Self {
485        self.kid = Some(kid.into());
486        self
487    }
488
489    /// This builder concealing the property at `path` (`credentialSubject.given_name`, an
490    /// array element, …); see [`SdJwtBuilder::conceal`].
491    #[must_use]
492    pub fn conceal(mut self, path: impl Into<ClaimPath>) -> Self {
493        self.concealed.push(path.into());
494        self
495    }
496
497    /// This builder adding `count` decoy digests to every `_sd` array.
498    #[must_use]
499    pub fn with_decoys(mut self, count: usize) -> Self {
500        self.decoys = count;
501        self
502    }
503
504    /// This builder hashing with `alg`.
505    #[must_use]
506    pub fn with_hash_algorithm(mut self, alg: HashAlgorithm) -> Self {
507        self.alg = alg;
508        self
509    }
510
511    /// This builder taking its salts from `salts`; see [`SdJwtBuilder::with_salt_generator`].
512    #[must_use]
513    pub fn with_salt_generator(mut self, salts: impl FnMut() -> String + Send + 'static) -> Self {
514        self.salts = Some(Box::new(salts));
515        self
516    }
517
518    /// Checks that no path to conceal starts at one of
519    /// [`SdJwtCredentialV2::NON_DISCLOSABLE_CLAIMS`].
520    ///
521    /// # Errors
522    ///
523    /// [`SdJwtError::NotConcealable`].
524    pub fn check_concealed(&self) -> Result<(), SdJwtCredentialV2Error> {
525        for path in &self.concealed {
526            if let Some(PathComponent::Key(name)) = path.components().first()
527                && SdJwtCredentialV2::NON_DISCLOSABLE_CLAIMS.contains(&name.as_str())
528            {
529                return Err(SdJwtError::NotConcealable {
530                    path: path.clone(),
531                    reason: format!("a credential secured with SD-JWT keeps `{name}` disclosed"),
532                }
533                .into());
534            }
535        }
536        Ok(())
537    }
538
539    /// The claims set to secure: the credential, `iss`, the time claims, `cnf` and `status`.
540    fn claims(&self) -> JwtClaims {
541        let mut claims = self.credential.to_jwt_claims();
542        claims.iss = Some(self.credential.issuer.id().to_string());
543        claims.iat = self.iat;
544        claims.nbf = self.nbf;
545        claims.exp = self.exp;
546        if let Some(cnf) = &self.cnf {
547            claims.additional.insert(
548                "cnf".to_owned(),
549                serde_json::to_value(cnf).expect("a confirmation serializes"),
550            );
551        }
552        if let Some(status) = &self.status {
553            claims.additional.insert(
554                "status".to_owned(),
555                serde_json::to_value(status).expect("a status serializes"),
556            );
557        }
558        claims
559    }
560
561    /// The credential, signed with `signer` under `alg`, `typ: vc+sd-jwt` and the `kid` given
562    /// or the signer's.
563    ///
564    /// # Errors
565    ///
566    /// [`SdJwtError::NotConcealable`], the errors of [`SdJwtBuilder::sign`], and those of
567    /// [`SdJwtCredentialV2::from_sd_jwt`] should the credential not read back.
568    ///
569    /// # Panics
570    ///
571    /// Never: the claims always serialize to a JSON object.
572    pub async fn sign(
573        self,
574        signer: &dyn Signer,
575    ) -> Result<SdJwtCredentialV2, SdJwtCredentialV2Error> {
576        self.check_concealed()?;
577        let Value::Object(claims) = serde_json::to_value(self.claims()).expect("claims serialize")
578        else {
579            unreachable!("a struct serializes to an object")
580        };
581        let mut header = JwsHeader::new(signer.alg()).with_typ(SdJwtCredentialV2::TYP);
582        header.kid = self.kid.or_else(|| signer.kid().map(str::to_owned));
583        let mut builder = SdJwtBuilder::new(claims)
584            .with_decoys(self.decoys)
585            .with_hash_algorithm(self.alg);
586        for path in self.concealed {
587            builder = builder.conceal(path);
588        }
589        if let Some(salts) = self.salts {
590            builder = builder.with_salt_generator(salts);
591        }
592        let sd_jwt = builder.sign(&header, signer).await?;
593        SdJwtCredentialV2::from_sd_jwt(sd_jwt)
594    }
595}
596
597impl fmt::Debug for SdJwtCredentialV2Builder {
598    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599        f.debug_struct("SdJwtCredentialV2Builder")
600            .field("credential", &self.credential)
601            .field("iat", &self.iat)
602            .field("nbf", &self.nbf)
603            .field("exp", &self.exp)
604            .field("cnf", &self.cnf)
605            .field("status", &self.status)
606            .field("kid", &self.kid)
607            .field("concealed", &self.concealed)
608            .field("decoys", &self.decoys)
609            .field("alg", &self.alg)
610            .finish_non_exhaustive()
611    }
612}