Skip to main content

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}