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}