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