Skip to main content

identity_kit/vc/
credential.rs

1//! `Credential`: a Verifiable Credentials Data Model 1.1 credential and its JWT profile.
2
3use chrono::{DateTime, Utc};
4use indexmap::IndexMap;
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use serde_json::Value;
7
8use crate::did::{Context, OneOrMany, Uri};
9
10use super::model::{CONTEXT_V1, append_context, check_context_and_type, push_type};
11use super::{CredentialError, JwtClaims, Party, Subject, TypedObject, claims};
12
13/// A verifiable credential of the Verifiable Credentials Data Model 1.1, as data.
14///
15/// The properties the data model defines are fields; everything else, `proof` included, lives in
16/// `properties` in insertion order. Deserialization checks the structural requirements of the
17/// data model: the first `@context` is the base context, `type` includes
18/// `VerifiableCredential`, there is at least one subject. Nothing is verified: a credential is
19/// secured by signing its JWT form, see [`to_jwt_claims`](Self::to_jwt_claims).
20///
21/// # Examples
22///
23/// ```
24/// use chrono::{TimeZone as _, Utc};
25/// use identity_kit::did::Uri;
26/// use identity_kit::vc::{Credential, Subject};
27/// use serde_json::json;
28///
29/// // VCDM 1.1 Example 1, as data.
30/// let credential = Credential::new(
31///     Uri::parse("https://example.edu/issuers/565049")?,
32///     Utc.with_ymd_and_hms(2010, 1, 1, 0, 0, 0).unwrap(),
33///     Subject::new(Uri::parse("did:example:ebfeb1f712ebc6f1c276e12ec21")?)
34///         .with_property("alumniOf", json!({ "id": "did:example:c276e12ec21ebfeb1f712ebc6f1" })),
35/// )
36/// .with_context("https://www.w3.org/2018/credentials/examples/v1")
37/// .with_id(Uri::parse("http://example.edu/credentials/1872")?)
38/// .with_type("AlumniCredential");
39///
40/// let json = serde_json::to_value(&credential)?;
41/// assert_eq!(json["type"], json!(["VerifiableCredential", "AlumniCredential"]));
42/// assert_eq!(json["issuanceDate"], "2010-01-01T00:00:00Z");
43/// assert_eq!(serde_json::from_value::<Credential>(json)?, credential);
44/// # Ok::<(), Box<dyn std::error::Error>>(())
45/// ```
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[non_exhaustive]
48#[serde(remote = "Self")]
49pub struct Credential {
50    /// `@context`, starting with [`Credential::CONTEXT`].
51    #[serde(rename = "@context")]
52    pub context: Context,
53    /// The identifier of the credential.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub id: Option<Uri>,
56    /// `type`, including [`Credential::TYPE`].
57    #[serde(rename = "type")]
58    pub types: OneOrMany<String>,
59    /// The issuer.
60    pub issuer: Party,
61    /// When the credential becomes valid.
62    #[serde(rename = "issuanceDate")]
63    pub issuance_date: DateTime<Utc>,
64    /// When the credential ceases to be valid.
65    #[serde(
66        rename = "expirationDate",
67        default,
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub expiration_date: Option<DateTime<Utc>>,
71    /// The subjects and the claims about them; one, in a credential that has a JWT form.
72    #[serde(rename = "credentialSubject")]
73    pub credential_subject: OneOrMany<Subject>,
74    /// `credentialStatus`: where to learn whether the credential is revoked or suspended.
75    #[serde(
76        rename = "credentialStatus",
77        default,
78        skip_serializing_if = "Option::is_none"
79    )]
80    pub credential_status: Option<TypedObject>,
81    /// `credentialSchema`.
82    #[serde(
83        rename = "credentialSchema",
84        default,
85        skip_serializing_if = "Option::is_none"
86    )]
87    pub credential_schema: Option<OneOrMany<TypedObject>>,
88    /// `refreshService`.
89    #[serde(
90        rename = "refreshService",
91        default,
92        skip_serializing_if = "Option::is_none"
93    )]
94    pub refresh_service: Option<OneOrMany<TypedObject>>,
95    /// `termsOfUse`.
96    #[serde(
97        rename = "termsOfUse",
98        default,
99        skip_serializing_if = "Option::is_none"
100    )]
101    pub terms_of_use: Option<OneOrMany<TypedObject>>,
102    /// `evidence`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub evidence: Option<OneOrMany<TypedObject>>,
105    /// Every other property, in insertion order.
106    #[serde(flatten)]
107    pub properties: IndexMap<String, Value>,
108}
109
110impl Serialize for Credential {
111    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
112        Self::serialize(self, serializer)
113    }
114}
115
116impl<'de> Deserialize<'de> for Credential {
117    /// Reads the credential and checks it with [`Credential::check`].
118    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
119        let credential = Self::deserialize(deserializer)?;
120        credential.check().map_err(de::Error::custom)?;
121        Ok(credential)
122    }
123}
124
125impl Credential {
126    /// The base context, `https://www.w3.org/2018/credentials/v1`.
127    pub const CONTEXT: &'static str = CONTEXT_V1;
128
129    /// The type every credential has, `VerifiableCredential`.
130    pub const TYPE: &'static str = "VerifiableCredential";
131
132    /// A credential of type `VerifiableCredential` in the base context, issued by `issuer` at
133    /// `issuance_date` about `subject`.
134    pub fn new(issuer: impl Into<Party>, issuance_date: DateTime<Utc>, subject: Subject) -> Self {
135        Self {
136            context: Context::uri(Self::CONTEXT),
137            id: None,
138            types: OneOrMany::One(Self::TYPE.to_owned()),
139            issuer: issuer.into(),
140            issuance_date,
141            expiration_date: None,
142            credential_subject: OneOrMany::One(subject),
143            credential_status: None,
144            credential_schema: None,
145            refresh_service: None,
146            terms_of_use: None,
147            evidence: None,
148            properties: IndexMap::new(),
149        }
150    }
151
152    /// This credential with `context` appended to its `@context`.
153    #[must_use]
154    pub fn with_context(mut self, context: impl Into<String>) -> Self {
155        self.context = append_context(self.context, context.into());
156        self
157    }
158
159    /// This credential with its `id` set.
160    #[must_use]
161    pub fn with_id(mut self, id: impl Into<Uri>) -> Self {
162        self.id = Some(id.into());
163        self
164    }
165
166    /// This credential with `credential_type` appended to its `type`.
167    #[must_use]
168    pub fn with_type(mut self, credential_type: impl Into<String>) -> Self {
169        push_type(&mut self.types, credential_type.into());
170        self
171    }
172
173    /// This credential with its `expirationDate` set.
174    #[must_use]
175    pub fn with_expiration_date(mut self, expiration_date: DateTime<Utc>) -> Self {
176        self.expiration_date = Some(expiration_date);
177        self
178    }
179
180    /// This credential with another subject.
181    #[must_use]
182    pub fn with_subject(mut self, subject: Subject) -> Self {
183        let mut subjects =
184            core::mem::replace(&mut self.credential_subject, OneOrMany::Many(Vec::new()))
185                .into_vec();
186        subjects.push(subject);
187        self.credential_subject = OneOrMany::Many(subjects);
188        self
189    }
190
191    /// This credential with its `credentialStatus` set.
192    #[must_use]
193    pub fn with_status(mut self, status: TypedObject) -> Self {
194        self.credential_status = Some(status);
195        self
196    }
197
198    /// This credential with a `credentialSchema` added.
199    #[must_use]
200    pub fn with_schema(mut self, schema: TypedObject) -> Self {
201        self.credential_schema = Some(push(self.credential_schema.take(), schema));
202        self
203    }
204
205    /// This credential with a `termsOfUse` added.
206    #[must_use]
207    pub fn with_terms_of_use(mut self, terms: TypedObject) -> Self {
208        self.terms_of_use = Some(push(self.terms_of_use.take(), terms));
209        self
210    }
211
212    /// This credential with an `evidence` added.
213    #[must_use]
214    pub fn with_evidence(mut self, evidence: TypedObject) -> Self {
215        self.evidence = Some(push(self.evidence.take(), evidence));
216        self
217    }
218
219    /// This credential with the property `name` set to `value`.
220    #[must_use]
221    pub fn with_property(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
222        self.properties.insert(name.into(), value.into());
223        self
224    }
225
226    /// The one subject, when there is exactly one.
227    pub fn subject(&self) -> Option<&Subject> {
228        (self.credential_subject.len() == 1).then(|| self.credential_subject.first())?
229    }
230
231    /// Whether `credential_type` is among the types.
232    pub fn has_type(&self, credential_type: &str) -> bool {
233        self.types.iter().any(|t| t == credential_type)
234    }
235
236    /// Checks the structural requirements of the data model: the first `@context` is the base
237    /// context, `type` includes `VerifiableCredential`, there is a subject, and
238    /// `expirationDate` is not before `issuanceDate`.
239    ///
240    /// # Errors
241    ///
242    /// The first [`CredentialError`] found.
243    pub fn check(&self) -> Result<(), CredentialError> {
244        check_context_and_type(&self.context, Self::CONTEXT, &self.types, Self::TYPE)?;
245        if self.credential_subject.is_empty() {
246            return Err(CredentialError::NoSubject);
247        }
248        if self
249            .expiration_date
250            .is_some_and(|until| until < self.issuance_date)
251        {
252            return Err(CredentialError::InvalidValidity {
253                from: "issuanceDate",
254                until: "expirationDate",
255            });
256        }
257        Ok(())
258    }
259
260    /// The JWT claims set of the credential (VCDM 1.1 §6.3.1 "JWT Encoding"): `iss` is the
261    /// issuer, `jti` the id, `nbf` the issuance date, `exp` the expiration date, `sub` the id of
262    /// the subject, and the credential without those properties is the `vc` claim. An issuer
263    /// object stays in `vc`, as `iss` carries only its `id`. A `NumericDate` counts whole
264    /// seconds, so a fraction of a second or a leap second in a date is dropped from `nbf` and
265    /// `exp`.
266    ///
267    /// # Errors
268    ///
269    /// What [`check`](Self::check) reports, and [`CredentialError::MultipleSubjects`] for a
270    /// credential with several subjects, which a JWT cannot express.
271    pub fn to_jwt_claims(&self) -> Result<JwtClaims, CredentialError> {
272        self.check()?;
273        let mut vc = claims::to_object(self);
274        let jti = claims::take_id(&mut vc, "id");
275        let iss = claims::take_id(&mut vc, "issuer");
276        vc.shift_remove("issuanceDate");
277        vc.shift_remove("expirationDate");
278        let sub = claims::take_subject(&mut vc)?;
279        Ok(JwtClaims {
280            iss,
281            sub,
282            aud: None,
283            exp: self.expiration_date.map(JwtClaims::numeric_date),
284            nbf: Some(JwtClaims::numeric_date(self.issuance_date)),
285            iat: None,
286            jti,
287            additional: IndexMap::from([("vc".to_owned(), Value::Object(vc))]),
288        })
289    }
290
291    /// The credential of a JWT claims set (VCDM 1.1 §6.3.1 "JWT Decoding"): the `vc` claim with
292    /// `iss`, `jti`, `nbf`, `exp` and `sub` mapped back to its properties. A property present
293    /// in both places must agree with the claim.
294    ///
295    /// # Errors
296    ///
297    /// [`CredentialError::MissingClaim`] without a `vc` claim, [`CredentialError::InvalidClaim`]
298    /// when a claim or the credential it yields is malformed, [`CredentialError::ClaimMismatch`]
299    /// when a claim and its property disagree, and what [`check`](Self::check) reports.
300    pub fn from_jwt_claims(jwt_claims: &JwtClaims) -> Result<Self, CredentialError> {
301        let mut vc = claims::claim_object(jwt_claims, "vc")?;
302        claims::merge_id(&mut vc, "id", "jti", jwt_claims.jti.as_deref())?;
303        claims::merge_id(&mut vc, "issuer", "iss", jwt_claims.iss.as_deref())?;
304        claims::merge_time(&mut vc, "issuanceDate", "nbf", jwt_claims.nbf)?;
305        claims::merge_time(&mut vc, "expirationDate", "exp", jwt_claims.exp)?;
306        claims::merge_subject(&mut vc, jwt_claims.sub.as_deref())?;
307        let credential: Self = serde_json::from_value(Value::Object(vc)).map_err(|e| {
308            CredentialError::InvalidClaim {
309                claim: "vc",
310                reason: e.to_string(),
311            }
312        })?;
313        Ok(credential)
314    }
315}
316
317/// `list` with `item` appended.
318pub(super) fn push<T>(list: Option<OneOrMany<T>>, item: T) -> OneOrMany<T> {
319    match list {
320        None => OneOrMany::One(item),
321        Some(list) => {
322            let mut items = list.into_vec();
323            items.push(item);
324            OneOrMany::Many(items)
325        }
326    }
327}