Skip to main content

world_id_primitives/
credential.rs

1use ark_babyjubjub::EdwardsAffine;
2use eddsa_babyjubjub::{EdDSAPrivateKey, EdDSAPublicKey, EdDSASignature};
3use rand::Rng;
4use ruint::aliases::U256;
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6
7use crate::{FieldElement, PrimitiveError, sponge::hash_bytes_to_field_element};
8
9/// Domain separation tag to avoid collisions with other Poseidon2 usages.
10const ASSOCIATED_DATA_HASH_DS_TAG: &[u8] = b"ASSOCIATED_DATA_HASH_V1";
11const CLAIMS_HASH_DS_TAG: &[u8] = b"CLAIMS_HASH_V1";
12const SUB_DS_TAG: &[u8] = b"H_CS(id, r)";
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    #[default]
24    V1 = 1,
25}
26
27/// Base representation of a `Credential` in the World ID Protocol.
28///
29/// A credential is generally a verifiable digital statement about a subject. It is
30/// the canonical object: everything a verifier needs for proofs and authorization.
31///
32/// In the case of World ID these statements are about humans, with the most common
33/// credentials being Orb verification or document verification.
34///
35/// # Associated Data
36///
37/// Credentials have a pre-defined strict structure, which is determined by their version. Extending this,
38/// issuers may opt to include additional arbitrary data with the Credential. This data is called
39/// **Associated Data**.
40/// - Associated data is stored by Authenticators with the Credential.
41/// - Including associated data is a decision by the issuer. Its structure and content is solely
42///   determined by the issuer and the data will not be exposed to RPs or others.
43/// - An example of associated data use is supporting data to re-issue a credential (e.g. a sign up number).
44/// - Associated data is never exposed to RPs or others. It only lives in the Authenticator.
45/// - Associated data is authenticated in the Credential through the `associated_data_hash` field. The issuer
46///   can determine how this data is hashed. However providing the raw data to `associated_data` can ensure a
47///   consistent hashing into the field.
48/// ```text
49/// +------------------------------+
50/// |          Credential          |
51/// |                              |
52/// |  - associated_data_hash <----+
53/// |  - signature                 |
54/// +------------------------------+
55///           ^
56///           |
57///     Hash(associated_data)
58///           |
59/// Associated Data
60/// +------------------------------+
61/// | Optional arbitrary data      |
62/// +------------------------------+
63/// ```
64///
65/// # Design Principles:
66/// - A credential clearly separates:
67///    - **Assertion** (the claim being made)
68///    - **Issuer** (who attests to it / vouches for it)
69///    - **Subject** (who it is about)
70///    - **Presenter binding** (who can present it)
71/// - Credentials are **usable across authenticators** without leaking correlate-able identifiers to RPs.
72/// - Revocation, expiry, and re-issuance are **first-class lifecycle properties**.
73/// - Flexibility: credentials may take different formats but share **common metadata** (validity, issuer, trust, type).
74///
75/// All credentials have an issuer and schema, identified with the `issuer_schema_id` field. This identifier
76/// is registered in the `CredentialSchemaIssuerRegistry` contract. It represents a particular schema issued by
77/// a particular issuer. Some schemas are intended to be global (e.g. representing an ICAO-compliant passport) and
78/// some issuer-specific. Schemas should be registered in the `CredentialSchemaIssuerRegistry` contract and should be
79/// publicly accessible.
80///
81/// We want to encourage schemas to be widely distributed and adopted. If everyone uses the same passport schema,
82/// for example, the Protocol will have better interoperability across passport credential issuers, reducing the
83/// burden on holders (to make sense of which passport they have), and similarly, RPs.
84#[derive(Debug, Serialize, Deserialize, Clone)]
85pub struct Credential {
86    /// A reference identifier for the credential. This can be used by issuers
87    /// to manage credential lifecycle.
88    ///
89    /// - This ID is never exposed or used outside of issuer scope. It is never part of proofs
90    ///   or exposed to RPs.
91    /// - Generally, it is recommended to maintain the default of a random identifier.
92    ///
93    /// # Example Uses
94    /// - Track issued credentials to later support revocation after refreshing.
95    pub id: u64,
96    /// The version of the Credential determines its structure.
97    pub version: CredentialVersion,
98    /// Unique issuer schema id represents the unique combination of the credential's
99    /// schema and the issuer.
100    ///
101    /// The `issuer_schema_id` is registered in the `CredentialSchemaIssuerRegistry`. With this
102    /// identifier, the RPs lookup the authorized keys that can sign the credential.
103    pub issuer_schema_id: u64,
104    /// The blinded subject (World ID) for which the credential is issued.
105    ///
106    /// The underlying identifier comes from the `WorldIDRegistry` and is
107    /// the `leaf_index` of the World ID on the Merkle tree. However, this is blinded
108    /// for each `issuer_schema_id` with a blinding factor to prevent correlation of credentials
109    /// by malicious issuers.
110    pub sub: FieldElement,
111    /// Timestamp of **first issuance** of this credential (unix seconds), i.e. this represents when the holder
112    /// first obtained the credential. Even if the credential has been issued multiple times (e.g. because of a renewal),
113    /// this timestamp should stay constant.
114    ///
115    /// This timestamp can be queried (only as a minimum value) by RPs.
116    pub genesis_issued_at: u64,
117    /// Expiration timestamp (unix seconds)
118    pub expires_at: u64,
119    /// **For Future Use**. Concrete statements that the issuer attests about the receiver.
120    ///
121    /// They can be just commitments to data (e.g. passport image) or
122    /// the value directly (e.g. date of birth).
123    ///
124    /// Currently these statements are not in use in the Proofs yet.
125    pub claims: Vec<FieldElement>,
126    /// The commitment to the associated data issued with the Credential.
127    ///
128    /// By default this uses the internal `hash_bytes_to_field_element` function,
129    /// but each issuer may determine their own hashing algorithm.
130    ///
131    /// This hash is generally only used by the issuer.
132    pub associated_data_hash: FieldElement,
133    /// The signature of the credential (signed by the issuer's key)
134    #[serde(serialize_with = "serialize_signature")]
135    #[serde(deserialize_with = "deserialize_signature")]
136    #[serde(default)]
137    pub signature: Option<EdDSASignature>,
138    /// The public component of the issuer's key which signed the Credential.
139    #[serde(serialize_with = "serialize_public_key")]
140    #[serde(deserialize_with = "deserialize_public_key")]
141    pub issuer: EdDSAPublicKey,
142}
143
144impl Credential {
145    /// The maximum number of claims that can be included in a credential.
146    pub const MAX_CLAIMS: usize = 16;
147
148    /// Initializes a new credential.
149    ///
150    /// Note default fields occupy a sentinel value of `BaseField::zero()`
151    #[must_use]
152    pub fn new() -> Self {
153        let mut rng = rand::thread_rng();
154        Self {
155            id: rng.r#gen(),
156            version: CredentialVersion::V1,
157            issuer_schema_id: 0,
158            sub: FieldElement::ZERO,
159            genesis_issued_at: 0,
160            expires_at: 0,
161            claims: vec![FieldElement::ZERO; Self::MAX_CLAIMS],
162            associated_data_hash: FieldElement::ZERO,
163            signature: None,
164            issuer: EdDSAPublicKey {
165                pk: EdwardsAffine::default(),
166            },
167        }
168    }
169
170    /// Set the `id` of the credential.
171    #[must_use]
172    pub const fn id(mut self, id: u64) -> Self {
173        self.id = id;
174        self
175    }
176
177    /// Set the `version` of the credential.
178    #[must_use]
179    pub const fn version(mut self, version: CredentialVersion) -> Self {
180        self.version = version;
181        self
182    }
183
184    /// Set the `issuerSchemaId` of the credential.
185    #[must_use]
186    pub const fn issuer_schema_id(mut self, issuer_schema_id: u64) -> Self {
187        self.issuer_schema_id = issuer_schema_id;
188        self
189    }
190
191    /// Set the `sub` for the credential.
192    #[must_use]
193    pub const fn subject(mut self, sub: FieldElement) -> Self {
194        self.sub = sub;
195        self
196    }
197
198    /// Set the genesis issued at of the credential.
199    #[must_use]
200    pub const fn genesis_issued_at(mut self, genesis_issued_at: u64) -> Self {
201        self.genesis_issued_at = genesis_issued_at;
202        self
203    }
204
205    /// Set the expires at of the credential.
206    #[must_use]
207    pub const fn expires_at(mut self, expires_at: u64) -> Self {
208        self.expires_at = expires_at;
209        self
210    }
211
212    /// Set a claim hash for the credential at an index.
213    ///
214    /// # Errors
215    /// Will error if the index is out of bounds.
216    pub fn claim_hash(mut self, index: usize, claim: U256) -> Result<Self, PrimitiveError> {
217        if index >= self.claims.len() {
218            return Err(PrimitiveError::OutOfBounds);
219        }
220        self.claims[index] = claim.try_into().map_err(|_| PrimitiveError::NotInField)?;
221        Ok(self)
222    }
223
224    /// Set the claim hash at specific index by hashing arbitrary bytes using Poseidon2.
225    ///
226    /// This method accepts arbitrary bytes, converts them to field elements,
227    /// applies a Poseidon2 hash, and stores the result as claim at the provided index.
228    ///
229    /// # Arguments
230    /// * `claim` - Arbitrary bytes to hash (any length).
231    ///
232    /// # Errors
233    /// Will error if the data is empty and if the index is out of bounds.
234    pub fn claim(mut self, index: usize, claim: &[u8]) -> Result<Self, PrimitiveError> {
235        if index >= self.claims.len() {
236            return Err(PrimitiveError::OutOfBounds);
237        }
238        self.claims[index] = hash_bytes_to_field_element(CLAIMS_HASH_DS_TAG, claim)?;
239        Ok(self)
240    }
241    /// Set the associated data hash of the credential from a pre-computed hash.
242    ///
243    /// # Errors
244    /// Will error if the provided hash cannot be lowered into the field.
245    pub fn associated_data_hash(
246        mut self,
247        associated_data_hash: U256,
248    ) -> Result<Self, PrimitiveError> {
249        self.associated_data_hash = associated_data_hash
250            .try_into()
251            .map_err(|_| PrimitiveError::NotInField)?;
252        Ok(self)
253    }
254
255    /// Set the associated data hash by hashing arbitrary bytes using Poseidon2.
256    ///
257    /// This method accepts arbitrary bytes, converts them to field elements,
258    /// applies a Poseidon2 hash, and stores the result as the associated data hash.
259    ///
260    /// # Arguments
261    /// * `data` - Arbitrary bytes to hash (any length).
262    ///
263    /// # Errors
264    /// Will error if the data is empty.
265    pub fn associated_data(mut self, data: &[u8]) -> Result<Self, PrimitiveError> {
266        self.associated_data_hash = hash_bytes_to_field_element(ASSOCIATED_DATA_HASH_DS_TAG, data)?;
267        Ok(self)
268    }
269
270    /// Get the credential domain separator for the given version.
271    #[must_use]
272    pub fn get_cred_ds(&self) -> FieldElement {
273        match self.version {
274            CredentialVersion::V1 => FieldElement::from_be_bytes_mod_order(b"POSEIDON2+EDDSA-BJJ"),
275        }
276    }
277
278    /// Get the claims hash of the credential.
279    ///
280    /// # Errors
281    /// Will error if there are more claims than the maximum allowed.
282    /// Will error if the claims cannot be lowered into the field. Should not occur in practice.
283    pub fn claims_hash(&self) -> Result<FieldElement, eyre::Error> {
284        if self.claims.len() > Self::MAX_CLAIMS {
285            eyre::bail!("There can be at most {} claims", Self::MAX_CLAIMS);
286        }
287        let mut input = [*FieldElement::ZERO; Self::MAX_CLAIMS];
288        for (i, claim) in self.claims.iter().enumerate() {
289            input[i] = **claim;
290        }
291        poseidon2::bn254::t16::permutation_in_place(&mut input);
292        Ok(input[1].into())
293    }
294
295    // Computes the specifically designed hash of the credential for the given version.
296    ///
297    /// The hash is signed by the issuer to provide authenticity for the credential.
298    ///
299    /// # Errors
300    /// - Will error if there are more claims than the maximum allowed.
301    /// - Will error if the claims cannot be lowered into the field. Should not occur in practice.
302    pub fn hash(&self) -> Result<FieldElement, eyre::Error> {
303        match self.version {
304            CredentialVersion::V1 => {
305                let mut input = [
306                    *self.get_cred_ds(),
307                    self.issuer_schema_id.into(),
308                    *self.sub,
309                    self.genesis_issued_at.into(),
310                    self.expires_at.into(),
311                    *self.claims_hash()?,
312                    *self.associated_data_hash,
313                    self.id.into(),
314                ];
315                poseidon2::bn254::t8::permutation_in_place(&mut input);
316                Ok(input[1].into())
317            }
318        }
319    }
320
321    /// Sign the credential.
322    ///
323    /// # Errors
324    /// Will error if the credential cannot be hashed.
325    pub fn sign(self, signer: &EdDSAPrivateKey) -> Result<Self, eyre::Error> {
326        let mut credential = self;
327        credential.signature = Some(signer.sign(*credential.hash()?));
328        credential.issuer = signer.public();
329        Ok(credential)
330    }
331
332    /// Verify the signature of the credential against the issuer public key and expected hash.
333    ///
334    /// # Errors
335    /// Will error if the credential is not signed.
336    /// Will error if the credential cannot be hashed.
337    pub fn verify_signature(
338        &self,
339        expected_issuer_pubkey: &EdDSAPublicKey,
340    ) -> Result<bool, eyre::Error> {
341        if &self.issuer != expected_issuer_pubkey {
342            return Err(eyre::eyre!(
343                "Issuer public key does not match expected public key"
344            ));
345        }
346        if let Some(signature) = &self.signature {
347            return Ok(self.issuer.verify(*self.hash()?, signature));
348        }
349        Err(eyre::eyre!("Credential not signed"))
350    }
351
352    /// Compute the `sub` for a credential computed from `leaf_index` and a `blinding_factor`.
353    #[must_use]
354    pub fn compute_sub(leaf_index: u64, blinding_factor: FieldElement) -> FieldElement {
355        let mut input = [
356            *FieldElement::from_be_bytes_mod_order(SUB_DS_TAG),
357            leaf_index.into(),
358            *blinding_factor,
359        ];
360        poseidon2::bn254::t3::permutation_in_place(&mut input);
361        input[1].into()
362    }
363}
364
365impl Default for Credential {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371/// Serializes the signature as compressed bytes (encoding r and s concatenated)
372/// where `r` is compressed to a single coordinate. Result is hex-encoded.
373#[expect(clippy::ref_option)]
374fn serialize_signature<S>(
375    signature: &Option<EdDSASignature>,
376    serializer: S,
377) -> Result<S::Ok, S::Error>
378where
379    S: Serializer,
380{
381    let Some(signature) = signature else {
382        return serializer.serialize_none();
383    };
384    let sig = signature
385        .to_compressed_bytes()
386        .map_err(serde::ser::Error::custom)?;
387    if serializer.is_human_readable() {
388        serializer.serialize_str(&hex::encode(sig))
389    } else {
390        serializer.serialize_bytes(&sig)
391    }
392}
393
394fn deserialize_signature<'de, D>(deserializer: D) -> Result<Option<EdDSASignature>, D::Error>
395where
396    D: Deserializer<'de>,
397{
398    let bytes: Option<Vec<u8>> = if deserializer.is_human_readable() {
399        Option::<String>::deserialize(deserializer)?
400            .map(|s| hex::decode(s).map_err(de::Error::custom))
401            .transpose()?
402    } else {
403        Option::<Vec<u8>>::deserialize(deserializer)?
404    };
405
406    let Some(bytes) = bytes else {
407        return Ok(None);
408    };
409
410    if bytes.len() != 64 {
411        return Err(de::Error::custom("Invalid signature. Expected 64 bytes."));
412    }
413
414    let mut arr = [0u8; 64];
415    arr.copy_from_slice(&bytes);
416    EdDSASignature::from_compressed_bytes(arr)
417        .map(Some)
418        .map_err(de::Error::custom)
419}
420
421fn serialize_public_key<S>(public_key: &EdDSAPublicKey, serializer: S) -> Result<S::Ok, S::Error>
422where
423    S: Serializer,
424{
425    let pk = public_key
426        .to_compressed_bytes()
427        .map_err(serde::ser::Error::custom)?;
428    if serializer.is_human_readable() {
429        serializer.serialize_str(&hex::encode(pk))
430    } else {
431        serializer.serialize_bytes(&pk)
432    }
433}
434
435fn deserialize_public_key<'de, D>(deserializer: D) -> Result<EdDSAPublicKey, D::Error>
436where
437    D: Deserializer<'de>,
438{
439    let bytes: Vec<u8> = if deserializer.is_human_readable() {
440        hex::decode(String::deserialize(deserializer)?).map_err(de::Error::custom)?
441    } else {
442        Vec::<u8>::deserialize(deserializer)?
443    };
444
445    if bytes.len() != 32 {
446        return Err(de::Error::custom("Invalid public key. Expected 32 bytes."));
447    }
448
449    let mut arr = [0u8; 32];
450    arr.copy_from_slice(&bytes);
451    EdDSAPublicKey::from_compressed_bytes(arr).map_err(de::Error::custom)
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_associated_data_matches_direct_hash() {
460        let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
461
462        // Using the associated_data method
463        let credential = Credential::new().associated_data(&data).unwrap();
464
465        // Using the hash function directly
466        let direct_hash = hash_bytes_to_field_element(ASSOCIATED_DATA_HASH_DS_TAG, &data).unwrap();
467
468        // Both should produce the same hash
469        assert_eq!(credential.associated_data_hash, direct_hash);
470    }
471
472    #[test]
473    fn test_associated_data_method() {
474        let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
475
476        let credential = Credential::new().associated_data(&data).unwrap();
477
478        // Should have a non-zero associated data hash
479        assert_ne!(credential.associated_data_hash, FieldElement::ZERO);
480    }
481
482    #[test]
483    fn test_claim_matches_direct_hash() {
484        let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
485
486        // Using the claim method
487        let credential = Credential::new().claim(0, &data).unwrap();
488
489        // Using the hash function directly
490        let direct_hash = hash_bytes_to_field_element(CLAIMS_HASH_DS_TAG, &data).unwrap();
491
492        // Both should produce the same hash
493        assert_eq!(credential.claims[0], direct_hash);
494    }
495
496    #[test]
497    fn test_claim_method() {
498        let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
499
500        let credential = Credential::new().claim(1, &data).unwrap();
501
502        // Should have a non-zero claim hash
503        assert_ne!(credential.claims[1], FieldElement::ZERO);
504    }
505}