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