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}