world_id_primitives/credential.rs
1use ark_babyjubjub::EdwardsAffine;
2use ark_ff::BigInt;
3use eddsa_babyjubjub::{EdDSAPrivateKey, EdDSAPublicKey, EdDSASignature};
4use rand::Rng;
5use ruint::aliases::U256;
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::{FieldElement, PrimitiveError, sponge::hash_bytes_to_field_element};
9
10/// Domain separation tag to avoid collisions with other Poseidon2 usages.
11const ASSOCIATED_DATA_COMMITMENT_DS_TAG: &[u8] = b"ASSOCIATED_DATA_HASH_V1";
12const CLAIMS_HASH_DS_TAG: &[u8] = b"CLAIMS_HASH_V1";
13const SUB_DS_TAG: &[u8] = b"H_CS(id, r)";
14
15/// Version of the `Credential` object
16#[derive(Default, Debug, PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)]
17#[repr(u8)]
18pub enum CredentialVersion {
19 /// Version 1 of the `Credential`. In addition to the specific attributes,
20 /// - Hashing function: `Poseidon2`
21 /// - Signature scheme: `EdDSA` on `BabyJubJub` Curve
22 /// - Curve (Base) Field (`Fq`): `BabyJubJub` Curve Field (also the BN254 Scalar Field)
23 /// - Scalar Field (`Fr`): `BabyJubJub` Scalar Field
24 #[default]
25 V1 = 1,
26}
27
28/// Base representation of a `Credential` in the World ID Protocol.
29///
30/// A credential is generally a verifiable digital statement about a subject. It is
31/// the canonical object: everything a verifier needs for proofs and authorization.
32///
33/// In the case of World ID these statements are about humans, with the most common
34/// credentials being Orb verification or document verification.
35///
36/// # Credential Lifecycle
37///
38/// The following official terminology is defined for the lifecycle of a Credential.
39/// - **Issuance** (can also be called **Enrollment**): Process by which a credential is initially issued to a user.
40/// - **Renewal**: Process by which a user requests a new Credential from a previously existing active or
41/// expired Credential. This usually happens close to Credential expiration. _It is analogous to
42/// when you request a renewal of your passport, you get a new passport with a new expiration date._
43/// - **Re-Issuance**: Process by which a user obtains a copy of their existing Credential. The copy does not
44/// need to be exact, but the original expiration date MUST be preserved. This usually occurs when a user
45/// accidentally lost their Credential (e.g. disk failure, authenticator loss) and needs to recover for an existing period.
46///
47/// # Associated Data
48///
49/// Credentials have a pre-defined strict structure, which is determined by their version. Issuers
50/// may opt to include additional arbitrary data with the Credential (**Associated Data**). This arbitrary data
51/// can be used to support the issuer in the operation of their Credential (for example it may contain an identifier
52/// to allow credential refresh).
53///
54/// - Associated data is stored by Authenticators with the Credential.
55/// - Introducing associated data is a decision by the issuer. Its structure and content is solely
56/// determined by the issuer and the data will not be exposed to RPs or others.
57/// - An example of associated data use is supporting data to re-issue a credential (e.g. a sign up number).
58/// - Associated data is never exposed to RPs or others. It only lives in the Authenticator and may be provided
59/// to issuers.
60/// - Associated data is authenticated in the Credential through the `associated_data_commitment` field. The issuer
61/// MUST determine how this commitment is computed. Issuers may opt to use the [`Credential::associated_data_commitment_from_raw_bytes`]
62/// helper to ensure their raw data is committed, but other commitment mechanisms may make sense depending on the
63/// structure of the associated data.
64///
65/// ```text
66/// +------------------------------------+
67/// | Credential |
68/// | |
69/// | - associated_data_commitment <----+
70/// | - signature |
71/// +------------------------------------+
72/// ^
73/// |
74/// Commitment(associated_data)
75/// |
76/// Associated Data
77/// +------------------------------------+
78/// | Optional arbitrary data |
79/// +------------------------------------+
80/// ```
81///
82/// # Design Principles:
83/// - A credential clearly separates:
84/// - **Assertion** (the claim being made)
85/// - **Issuer** (who attests to it / vouches for it)
86/// - **Subject** (who it is about)
87/// - **Presenter binding** (who can present it)
88/// - Credentials are **usable across authenticators** without leaking correlate-able identifiers to RPs.
89/// - Revocation, expiry, and re-issuance are **first-class lifecycle properties**.
90/// - Flexibility: credentials may take different formats but share **common metadata** (validity, issuer, trust, type).
91///
92/// All credentials have an issuer and schema, identified with the `issuer_schema_id` field. This identifier
93/// is registered in the `CredentialSchemaIssuerRegistry` contract. It represents a particular schema issued by
94/// a particular issuer. Some schemas are intended to be global (e.g. representing an ICAO-compliant passport) and
95/// some issuer-specific. Schemas should be registered in the `CredentialSchemaIssuerRegistry` contract and should be
96/// publicly accessible.
97///
98/// We want to encourage schemas to be widely distributed and adopted. If everyone uses the same passport schema,
99/// for example, the Protocol will have better interoperability across passport credential issuers, reducing the
100/// burden on holders (to make sense of which passport they have), and similarly, RPs.
101#[derive(Debug, Serialize, Deserialize, Clone)]
102pub struct Credential {
103 /// A reference identifier for the credential. This can be used by issuers
104 /// to manage credential lifecycle.
105 ///
106 /// - This ID is never exposed or used outside of issuer scope. It is never part of proofs
107 /// or exposed to RPs.
108 /// - Generally, it is recommended to maintain the default of a random identifier.
109 ///
110 /// # Example Uses
111 /// - Track issued credentials to later support revocation after refreshing.
112 pub id: u64,
113 /// The version of the Credential determines its structure.
114 pub version: CredentialVersion,
115 /// A reference version field for issuer use. Different versions can determine how
116 /// the issuer-defined fields are constructed for a Credential.
117 ///
118 /// # Usage Examples
119 /// - Different claims values and their meaning could be determined from
120 /// distinct issuer versions.
121 /// - Different construction of associated data or its commitment can be
122 /// determined from distinct issuer versions.
123 #[serde(default)]
124 pub issuer_version: u8,
125 /// Unique issuer schema id represents the unique combination of the credential's
126 /// schema and the issuer.
127 ///
128 /// The `issuer_schema_id` is registered in the `CredentialSchemaIssuerRegistry`. With this
129 /// identifier, the RPs lookup the authorized keys that can sign the credential.
130 pub issuer_schema_id: u64,
131 /// The blinded subject (World ID) for which the credential is issued.
132 ///
133 /// The underlying identifier comes from the `WorldIDRegistry` and is
134 /// the `leaf_index` of the World ID on the Merkle tree. However, this is blinded
135 /// for each `issuer_schema_id` with a blinding factor to prevent correlation of credentials
136 /// by malicious issuers. See [`Self::compute_sub`] for details on how the credential blinding factor
137 /// is computed.
138 pub sub: FieldElement,
139 /// Timestamp of **first issuance** of this credential (unix seconds), i.e. this represents when the holder
140 /// first obtained the credential. Even if the credential has been issued multiple times (e.g. because of a renewal),
141 /// this timestamp should stay constant.
142 ///
143 /// This timestamp can be queried (only as a minimum value) by RPs.
144 pub genesis_issued_at: u64,
145 /// Expiration timestamp (unix seconds)
146 pub expires_at: u64,
147 /// **For Future Use**. Concrete statements that the issuer attests about the receiver.
148 ///
149 /// They can be just commitments to data (e.g. passport image) or
150 /// the value directly (e.g. date of birth).
151 ///
152 /// Currently these statements are not in use in the Proofs yet.
153 pub claims: Vec<FieldElement>,
154 /// The commitment to the Associated Data issued with the Credential.
155 ///
156 /// This may use a common hashing algorithm from the raw bytes of the
157 /// asscociated data and one function is exposed for this convenience,
158 /// [`hash_bytes_to_field_element`]. Each issuer however determines how
159 /// best to construct this value to establish the integrity of their Associated Data.
160 ///
161 /// This commitment is only for issuer use.
162 #[serde(alias = "associated_data_hash")]
163 // this was previously named `associated_data_hash`; fallback will be removed in the next version
164 pub associated_data_commitment: FieldElement,
165 /// The signature of the credential (signed by the issuer's key)
166 #[serde(serialize_with = "serialize_signature")]
167 #[serde(deserialize_with = "deserialize_signature")]
168 #[serde(default)]
169 pub signature: Option<EdDSASignature>,
170 /// The public component of the issuer's key which signed the Credential.
171 #[serde(serialize_with = "serialize_public_key")]
172 #[serde(deserialize_with = "deserialize_public_key")]
173 pub issuer: EdDSAPublicKey,
174}
175
176impl Credential {
177 /// The maximum number of claims that can be included in a credential.
178 pub const MAX_CLAIMS: usize = 16;
179
180 /// Initializes a new credential.
181 ///
182 /// Note default fields occupy a sentinel value of `BaseField::zero()`
183 #[must_use]
184 pub fn new() -> Self {
185 let mut rng = rand::thread_rng();
186 Self {
187 id: rng.r#gen(),
188 version: CredentialVersion::V1,
189 issuer_version: 0,
190 issuer_schema_id: 0,
191 sub: FieldElement::ZERO,
192 genesis_issued_at: 0,
193 expires_at: 0,
194 claims: vec![FieldElement::ZERO; Self::MAX_CLAIMS],
195 associated_data_commitment: FieldElement::ZERO,
196 signature: None,
197 issuer: EdDSAPublicKey {
198 pk: EdwardsAffine::default(),
199 },
200 }
201 }
202
203 /// Set the `id` of the credential.
204 #[must_use]
205 pub const fn id(mut self, id: u64) -> Self {
206 self.id = id;
207 self
208 }
209
210 /// Set the `version` of the credential.
211 #[must_use]
212 pub const fn version(mut self, version: CredentialVersion) -> Self {
213 self.version = version;
214 self
215 }
216
217 /// Set the `issuer_version` of the credential.
218 #[must_use]
219 pub const fn issuer_version(mut self, issuer_version: u8) -> Self {
220 self.issuer_version = issuer_version;
221 self
222 }
223
224 /// Set the `issuerSchemaId` of the credential.
225 #[must_use]
226 pub const fn issuer_schema_id(mut self, issuer_schema_id: u64) -> Self {
227 self.issuer_schema_id = issuer_schema_id;
228 self
229 }
230
231 /// Set the `sub` for the credential.
232 #[must_use]
233 pub const fn subject(mut self, sub: FieldElement) -> Self {
234 self.sub = sub;
235 self
236 }
237
238 /// Set the genesis issued at of the credential.
239 #[must_use]
240 pub const fn genesis_issued_at(mut self, genesis_issued_at: u64) -> Self {
241 self.genesis_issued_at = genesis_issued_at;
242 self
243 }
244
245 /// Set the expires at of the credential.
246 #[must_use]
247 pub const fn expires_at(mut self, expires_at: u64) -> Self {
248 self.expires_at = expires_at;
249 self
250 }
251
252 /// Set a claim hash for the credential at an index.
253 ///
254 /// # Errors
255 /// Will error if the index is out of bounds.
256 pub fn claim_hash(mut self, index: usize, claim: U256) -> Result<Self, PrimitiveError> {
257 if index >= self.claims.len() {
258 return Err(PrimitiveError::OutOfBounds);
259 }
260 self.claims[index] = claim.try_into().map_err(|_| PrimitiveError::NotInField)?;
261 Ok(self)
262 }
263
264 /// Set the claim hash at specific index by hashing arbitrary bytes using Poseidon2.
265 ///
266 /// This method accepts arbitrary bytes, converts them to field elements,
267 /// applies a Poseidon2 hash, and stores the result as claim at the provided index.
268 ///
269 /// # Arguments
270 /// * `claim` - Arbitrary bytes to hash (any length).
271 ///
272 /// # Errors
273 /// Will error if the data is empty and if the index is out of bounds.
274 pub fn claim(mut self, index: usize, claim: &[u8]) -> Result<Self, PrimitiveError> {
275 if index >= self.claims.len() {
276 return Err(PrimitiveError::OutOfBounds);
277 }
278 self.claims[index] = hash_bytes_to_field_element(CLAIMS_HASH_DS_TAG, claim)?;
279 Ok(self)
280 }
281
282 /// Set the associated data commitment of the credential.
283 ///
284 /// # Errors
285 /// Will error if the provided hash cannot be lowered into the field.
286 pub fn associated_data_commitment(
287 mut self,
288 associated_data_commitment: U256,
289 ) -> Result<Self, PrimitiveError> {
290 self.associated_data_commitment = associated_data_commitment
291 .try_into()
292 .map_err(|_| PrimitiveError::NotInField)?;
293 Ok(self)
294 }
295
296 /// Set the associated data commitment from arbitrary bytes. This can be
297 /// used to construct the associated data commitment in a canonical way.
298 ///
299 /// This method takes arbitrary bytes, converts them to field elements,
300 /// applies a Poseidon2 hash, and stores the result as the associated data commitment.
301 ///
302 /// # Arguments
303 /// * `data` - Arbitrary bytes to be committed (any length).
304 ///
305 /// # Errors
306 /// Will error if the data is empty.
307 pub fn associated_data_commitment_from_raw_bytes(
308 mut self,
309 data: &[u8],
310 ) -> Result<Self, PrimitiveError> {
311 self.associated_data_commitment =
312 hash_bytes_to_field_element(ASSOCIATED_DATA_COMMITMENT_DS_TAG, data)?;
313 Ok(self)
314 }
315
316 /// Get the credential domain separator for the given version.
317 #[must_use]
318 pub fn get_cred_ds(&self) -> FieldElement {
319 match self.version {
320 CredentialVersion::V1 => FieldElement::from_be_bytes_mod_order(b"POSEIDON2+EDDSA-BJJ"),
321 }
322 }
323
324 /// Get the claims hash of the credential.
325 ///
326 /// # Errors
327 /// Will error if there are more claims than the maximum allowed.
328 /// Will error if the claims cannot be lowered into the field. Should not occur in practice.
329 pub fn claims_hash(&self) -> Result<FieldElement, eyre::Error> {
330 if self.claims.len() > Self::MAX_CLAIMS {
331 eyre::bail!("There can be at most {} claims", Self::MAX_CLAIMS);
332 }
333 let mut input = [*FieldElement::ZERO; Self::MAX_CLAIMS];
334 for (i, claim) in self.claims.iter().enumerate() {
335 input[i] = **claim;
336 }
337 poseidon2::bn254::t16::permutation_in_place(&mut input);
338 Ok(input[1].into())
339 }
340
341 /// Computes the canonical hash of the Credential.
342 ///
343 /// The hash is signed by the issuer to provide authenticity for the credential.
344 ///
345 /// # Errors
346 /// - Will error if there are more claims than the maximum allowed.
347 /// - Will error if the claims cannot be lowered into the field. Should not occur in practice.
348 pub fn hash(&self) -> Result<FieldElement, eyre::Error> {
349 match self.version {
350 CredentialVersion::V1 => {
351 let id_issuer_version = BigInt([self.id, self.issuer_version as u64, 0, 0]);
352
353 let mut input = [
354 *self.get_cred_ds(),
355 self.issuer_schema_id.into(),
356 *self.sub,
357 self.genesis_issued_at.into(),
358 self.expires_at.into(),
359 *self.claims_hash()?,
360 *self.associated_data_commitment,
361 id_issuer_version.into(),
362 ];
363 poseidon2::bn254::t8::permutation_in_place(&mut input);
364 Ok(input[1].into())
365 }
366 }
367 }
368
369 /// Sign the credential.
370 ///
371 /// # Errors
372 /// Will error if the credential cannot be hashed.
373 pub fn sign(self, signer: &EdDSAPrivateKey) -> Result<Self, eyre::Error> {
374 let mut credential = self;
375 credential.signature = Some(signer.sign(*credential.hash()?));
376 credential.issuer = signer.public();
377 Ok(credential)
378 }
379
380 /// Verify the signature of the credential against the issuer public key and expected hash.
381 ///
382 /// # Errors
383 /// Will error if the credential is not signed.
384 /// Will error if the credential cannot be hashed.
385 pub fn verify_signature(
386 &self,
387 expected_issuer_pubkey: &EdDSAPublicKey,
388 ) -> Result<bool, eyre::Error> {
389 if &self.issuer != expected_issuer_pubkey {
390 return Err(eyre::eyre!(
391 "Issuer public key does not match expected public key"
392 ));
393 }
394 if let Some(signature) = &self.signature {
395 return Ok(self.issuer.verify(*self.hash()?, signature));
396 }
397 Err(eyre::eyre!("Credential not signed"))
398 }
399
400 /// Compute the `sub` for a credential computed from `leaf_index` and a `blinding_factor`.
401 #[must_use]
402 pub fn compute_sub(leaf_index: u64, blinding_factor: FieldElement) -> FieldElement {
403 let mut input = [
404 *FieldElement::from_be_bytes_mod_order(SUB_DS_TAG),
405 leaf_index.into(),
406 *blinding_factor,
407 ];
408 poseidon2::bn254::t3::permutation_in_place(&mut input);
409 input[1].into()
410 }
411}
412
413impl Default for Credential {
414 fn default() -> Self {
415 Self::new()
416 }
417}
418
419/// Serializes the signature as compressed bytes (encoding r and s concatenated)
420/// where `r` is compressed to a single coordinate. Result is hex-encoded.
421#[expect(clippy::ref_option)]
422fn serialize_signature<S>(
423 signature: &Option<EdDSASignature>,
424 serializer: S,
425) -> Result<S::Ok, S::Error>
426where
427 S: Serializer,
428{
429 let Some(signature) = signature else {
430 return serializer.serialize_none();
431 };
432 let sig = signature
433 .to_compressed_bytes()
434 .map_err(serde::ser::Error::custom)?;
435 if serializer.is_human_readable() {
436 serializer.serialize_str(&hex::encode(sig))
437 } else {
438 serializer.serialize_bytes(&sig)
439 }
440}
441
442fn deserialize_signature<'de, D>(deserializer: D) -> Result<Option<EdDSASignature>, D::Error>
443where
444 D: Deserializer<'de>,
445{
446 let bytes: Option<Vec<u8>> = if deserializer.is_human_readable() {
447 Option::<String>::deserialize(deserializer)?
448 .map(|s| hex::decode(s).map_err(de::Error::custom))
449 .transpose()?
450 } else {
451 Option::<Vec<u8>>::deserialize(deserializer)?
452 };
453
454 let Some(bytes) = bytes else {
455 return Ok(None);
456 };
457
458 if bytes.len() != 64 {
459 return Err(de::Error::custom("Invalid signature. Expected 64 bytes."));
460 }
461
462 let mut arr = [0u8; 64];
463 arr.copy_from_slice(&bytes);
464 EdDSASignature::from_compressed_bytes(arr)
465 .map(Some)
466 .map_err(de::Error::custom)
467}
468
469fn serialize_public_key<S>(public_key: &EdDSAPublicKey, serializer: S) -> Result<S::Ok, S::Error>
470where
471 S: Serializer,
472{
473 let pk = public_key
474 .to_compressed_bytes()
475 .map_err(serde::ser::Error::custom)?;
476 if serializer.is_human_readable() {
477 serializer.serialize_str(&hex::encode(pk))
478 } else {
479 serializer.serialize_bytes(&pk)
480 }
481}
482
483fn deserialize_public_key<'de, D>(deserializer: D) -> Result<EdDSAPublicKey, D::Error>
484where
485 D: Deserializer<'de>,
486{
487 let bytes: Vec<u8> = if deserializer.is_human_readable() {
488 hex::decode(String::deserialize(deserializer)?).map_err(de::Error::custom)?
489 } else {
490 Vec::<u8>::deserialize(deserializer)?
491 };
492
493 if bytes.len() != 32 {
494 return Err(de::Error::custom("Invalid public key. Expected 32 bytes."));
495 }
496
497 let mut arr = [0u8; 32];
498 arr.copy_from_slice(&bytes);
499 EdDSAPublicKey::from_compressed_bytes(arr).map_err(de::Error::custom)
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 /// Tests the hash is deterministically computed for a default credential, this
507 /// helps detect issues where hashing inadvertently changed.
508 ///
509 /// Particularly relevant to ensure the introduction of other attributes into the last
510 /// item of the permutation does not bring in breaking changes.
511 #[test]
512 fn test_deterministic_credential_hash() {
513 let mut credential = Credential::new();
514 credential.id = 1;
515 assert_eq!(
516 hex::encode(credential.hash().unwrap().to_be_bytes()),
517 "2bc705762cbe8f31e0c3045ca347109ba3630b4b7ea955dc71515f182a079ae9"
518 );
519 }
520
521 #[test]
522 fn test_associated_data_matches_direct_hash() {
523 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
524
525 // Using the associated_data_commitment_from_raw_bytes method
526 let credential = Credential::new()
527 .associated_data_commitment_from_raw_bytes(&data)
528 .unwrap();
529
530 // Using the hash function directly
531 let direct_hash =
532 hash_bytes_to_field_element(ASSOCIATED_DATA_COMMITMENT_DS_TAG, &data).unwrap();
533
534 // Both should produce the same hash
535 assert_eq!(credential.associated_data_commitment, direct_hash);
536 }
537
538 #[test]
539 fn test_associated_data_method() {
540 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
541
542 let credential = Credential::new()
543 .associated_data_commitment_from_raw_bytes(&data)
544 .unwrap();
545
546 // Should have a non-zero associated data commitment
547 assert_ne!(credential.associated_data_commitment, FieldElement::ZERO);
548 }
549
550 #[test]
551 fn test_claim_matches_direct_hash() {
552 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
553
554 // Using the claim method
555 let credential = Credential::new().claim(0, &data).unwrap();
556
557 // Using the hash function directly
558 let direct_hash = hash_bytes_to_field_element(CLAIMS_HASH_DS_TAG, &data).unwrap();
559
560 // Both should produce the same hash
561 assert_eq!(credential.claims[0], direct_hash);
562 }
563
564 #[test]
565 fn test_claim_method() {
566 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
567
568 let credential = Credential::new().claim(1, &data).unwrap();
569
570 // Should have a non-zero claim hash
571 assert_ne!(credential.claims[1], FieldElement::ZERO);
572 }
573
574 /// Tests that `issuer_version` is bound into the credential hash so that it
575 /// cannot be tampered with after issuance without invalidating the signature.
576 #[test]
577 fn test_issuer_version_is_bound_to_credential_hash() {
578 let mut credential = Credential::new();
579 credential.id = 1;
580 credential.issuer_version = 1;
581
582 let mut tampered = credential.clone();
583 tampered.issuer_version = 2;
584
585 let original_hash = credential.hash().unwrap();
586 let tampered_hash = tampered.hash().unwrap();
587 assert_ne!(original_hash, tampered_hash);
588
589 let signer = EdDSAPrivateKey::random(&mut rand::thread_rng());
590 let signed = credential.sign(&signer).unwrap();
591 let issuer_pubkey = signer.public();
592
593 assert!(signed.verify_signature(&issuer_pubkey).unwrap());
594
595 let mut tampered_signed = signed.clone();
596 tampered_signed.issuer_version = signed.issuer_version.wrapping_add(1);
597 assert!(
598 !tampered_signed.verify_signature(&issuer_pubkey).unwrap(),
599 "tampering with issuer_version must invalidate the signature"
600 );
601 }
602}