world_id_primitives/credential.rs
1use ark_babyjubjub::EdwardsAffine;
2use eddsa_babyjubjub::{EdDSAPublicKey, EdDSASignature};
3use poseidon2::{Poseidon2, POSEIDON2_BN254_T3_PARAMS};
4use rand::Rng;
5use ruint::aliases::U256;
6use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::{sponge::hash_bytes_to_field_element, FieldElement, PrimitiveError};
9
10/// Domain separation tag to avoid collisions with other Poseidon2 usages.
11const ASSOCIATED_DATA_HASH_DS_TAG: &[u8] = b"ASSOCIATED_DATA_HASH_V1";
12const CLAIMS_HASH_DS_TAG: &[u8] = b"CLAIMS_HASH_V1";
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 pub issuer: EdDSAPublicKey,
140}
141
142impl Credential {
143 /// The maximum number of claims that can be included in a credential.
144 pub const MAX_CLAIMS: usize = 16;
145
146 /// Initializes a new credential.
147 ///
148 /// Note default fields occupy a sentinel value of `BaseField::zero()`
149 #[must_use]
150 pub fn new() -> Self {
151 let mut rng = rand::thread_rng();
152 Self {
153 id: rng.gen(),
154 version: CredentialVersion::V1,
155 issuer_schema_id: 0,
156 sub: FieldElement::ZERO,
157 genesis_issued_at: 0,
158 expires_at: 0,
159 claims: vec![FieldElement::ZERO; Self::MAX_CLAIMS],
160 associated_data_hash: FieldElement::ZERO,
161 signature: None,
162 issuer: EdDSAPublicKey {
163 pk: EdwardsAffine::default(),
164 },
165 }
166 }
167
168 /// Set the `id` of the credential.
169 #[must_use]
170 pub const fn id(mut self, id: u64) -> Self {
171 self.id = id;
172 self
173 }
174
175 /// Set the `version` of the credential.
176 #[must_use]
177 pub const fn version(mut self, version: CredentialVersion) -> Self {
178 self.version = version;
179 self
180 }
181
182 /// Set the `issuerSchemaId` of the credential.
183 #[must_use]
184 pub const fn issuer_schema_id(mut self, issuer_schema_id: u64) -> Self {
185 self.issuer_schema_id = issuer_schema_id;
186 self
187 }
188
189 /// Set the `sub` for the credential computed from `leaf_index` and a `blinding_factor`.
190 #[must_use]
191 pub fn sub(mut self, leaf_index: u64, blinding_factor: FieldElement) -> Self {
192 let hasher = Poseidon2::new(&POSEIDON2_BN254_T3_PARAMS);
193 let mut input = [*self.get_sub_ds(), leaf_index.into(), *blinding_factor];
194 hasher.permutation_in_place(&mut input);
195 self.sub = input[1].into();
196 self
197 }
198
199 /// Set the genesis issued at of the credential.
200 #[must_use]
201 pub const fn genesis_issued_at(mut self, genesis_issued_at: u64) -> Self {
202 self.genesis_issued_at = genesis_issued_at;
203 self
204 }
205
206 /// Set the expires at of the credential.
207 #[must_use]
208 pub const fn expires_at(mut self, expires_at: u64) -> Self {
209 self.expires_at = expires_at;
210 self
211 }
212
213 /// Set a claim hash for the credential at an index.
214 ///
215 /// # Errors
216 /// Will error if the index is out of bounds.
217 pub fn claim_hash(mut self, index: usize, claim: U256) -> Result<Self, PrimitiveError> {
218 if index >= self.claims.len() {
219 return Err(PrimitiveError::OutOfBounds);
220 }
221 self.claims[index] = claim.try_into().map_err(|_| PrimitiveError::NotInField)?;
222 Ok(self)
223 }
224
225 /// Set the claim hash at specific index by hashing arbitrary bytes using Poseidon2.
226 ///
227 /// This method accepts arbitrary bytes, converts them to field elements,
228 /// applies a Poseidon2 hash, and stores the result as claim at the provided index.
229 ///
230 /// # Arguments
231 /// * `claim` - Arbitrary bytes to hash (any length).
232 ///
233 /// # Errors
234 /// Will error if the data is empty and if the index is out of bounds.
235 pub fn claim(mut self, index: usize, claim: &[u8]) -> Result<Self, PrimitiveError> {
236 if index >= self.claims.len() {
237 return Err(PrimitiveError::OutOfBounds);
238 }
239 self.claims[index] = hash_bytes_to_field_element(CLAIMS_HASH_DS_TAG, claim)?;
240 Ok(self)
241 }
242 /// Set the associated data hash of the credential from a pre-computed hash.
243 ///
244 /// # Errors
245 /// Will error if the provided hash cannot be lowered into the field.
246 pub fn associated_data_hash(
247 mut self,
248 associated_data_hash: U256,
249 ) -> Result<Self, PrimitiveError> {
250 self.associated_data_hash = associated_data_hash
251 .try_into()
252 .map_err(|_| PrimitiveError::NotInField)?;
253 Ok(self)
254 }
255
256 /// Set the associated data hash by hashing arbitrary bytes using Poseidon2.
257 ///
258 /// This method accepts arbitrary bytes, converts them to field elements,
259 /// applies a Poseidon2 hash, and stores the result as the associated data hash.
260 ///
261 /// # Arguments
262 /// * `data` - Arbitrary bytes to hash (any length).
263 ///
264 /// # Errors
265 /// Will error if the data is empty.
266 pub fn associated_data(mut self, data: &[u8]) -> Result<Self, PrimitiveError> {
267 self.associated_data_hash = hash_bytes_to_field_element(ASSOCIATED_DATA_HASH_DS_TAG, data)?;
268 Ok(self)
269 }
270
271 /// Get the credential domain separator for the given version.
272 #[must_use]
273 pub fn get_cred_ds(&self) -> FieldElement {
274 match self.version {
275 CredentialVersion::V1 => FieldElement::from_be_bytes_mod_order(b"POSEIDON2+EDDSA-BJJ"),
276 }
277 }
278
279 /// Get the sub domain separator for the given version.
280 #[must_use]
281 pub fn get_sub_ds(&self) -> FieldElement {
282 match self.version {
283 CredentialVersion::V1 => FieldElement::from_be_bytes_mod_order(b"H_CS(id, r)"),
284 }
285 }
286}
287
288impl Default for Credential {
289 fn default() -> Self {
290 Self::new()
291 }
292}
293
294/// Serializes the signature as compressed bytes (encoding r and s concatenated)
295/// where `r` is compressed to a single coordinate. Result is hex-encoded.
296#[allow(clippy::ref_option)]
297fn serialize_signature<S>(
298 signature: &Option<EdDSASignature>,
299 serializer: S,
300) -> Result<S::Ok, S::Error>
301where
302 S: Serializer,
303{
304 let Some(signature) = signature else {
305 return serializer.serialize_none();
306 };
307 let sig = signature
308 .to_compressed_bytes()
309 .map_err(serde::ser::Error::custom)?;
310 serializer.serialize_str(&hex::encode(sig))
311}
312
313fn deserialize_signature<'de, D>(deserializer: D) -> Result<Option<EdDSASignature>, D::Error>
314where
315 D: Deserializer<'de>,
316{
317 let maybe_str = Option::<String>::deserialize(deserializer)?;
318
319 let Some(s) = maybe_str else { return Ok(None) };
320
321 let bytes = hex::decode(s).map_err(de::Error::custom)?;
322 if bytes.len() != 64 {
323 return Err(de::Error::custom("Invalid signature. Expected 64 bytes."));
324 }
325 let mut arr = [0u8; 64];
326 arr.copy_from_slice(&bytes);
327 let signature = EdDSASignature::from_compressed_bytes(arr).map_err(de::Error::custom)?;
328 Ok(Some(signature))
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn test_associated_data_matches_direct_hash() {
337 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
338
339 // Using the associated_data method
340 let credential = Credential::new().associated_data(&data).unwrap();
341
342 // Using the hash function directly
343 let direct_hash = hash_bytes_to_field_element(ASSOCIATED_DATA_HASH_DS_TAG, &data).unwrap();
344
345 // Both should produce the same hash
346 assert_eq!(credential.associated_data_hash, direct_hash);
347 }
348
349 #[test]
350 fn test_associated_data_method() {
351 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
352
353 let credential = Credential::new().associated_data(&data).unwrap();
354
355 // Should have a non-zero associated data hash
356 assert_ne!(credential.associated_data_hash, FieldElement::ZERO);
357 }
358
359 #[test]
360 fn test_claim_matches_direct_hash() {
361 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10];
362
363 // Using the claim method
364 let credential = Credential::new().claim(0, &data).unwrap();
365
366 // Using the hash function directly
367 let direct_hash = hash_bytes_to_field_element(CLAIMS_HASH_DS_TAG, &data).unwrap();
368
369 // Both should produce the same hash
370 assert_eq!(credential.claims[0], direct_hash);
371 }
372
373 #[test]
374 fn test_claim_method() {
375 let data = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
376
377 let credential = Credential::new().claim(1, &data).unwrap();
378
379 // Should have a non-zero claim hash
380 assert_ne!(credential.claims[1], FieldElement::ZERO);
381 }
382}