1use secrecy::ExposeSecret;
2use world_id_primitives::{
3 Credential, FieldElement, ProofRequest, ProofResponse, ProofType, RequestItem, ResponseItem,
4 SessionId, SessionNullifier, ZeroKnowledgeProof,
5};
6use world_id_proof::{
7 AuthenticatorProofInput, FullOprfOutput, OprfEntrypoint, ProofCompression,
8 proof::{CircomGroth16Material, generate_nullifier_proof},
9};
10
11use crate::{
12 api_types::AccountInclusionProof,
13 authenticator::{Authenticator, CredentialInput, ProofResult},
14 error::AuthenticatorError,
15};
16#[cfg(not(target_arch = "wasm32"))]
17use world_id_primitives::OwnershipProof;
18use world_id_primitives::TREE_DEPTH;
19#[cfg(not(target_arch = "wasm32"))]
20use world_id_proof::{
21 circuit_inputs::OwnershipProofCircuitInput,
22 ownership_proof::generate_ownership_proof_with_prover,
23};
24
25#[expect(unused_imports, reason = "used for docs")]
26use world_id_primitives::Nullifier;
27
28impl Authenticator {
29 async fn get_oprf_entrypoint<'a>(
40 &'a self,
41 query_material: &'a CircomGroth16Material,
42 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
43 ) -> Result<OprfEntrypoint<'a>, AuthenticatorError> {
44 let services = self.config.nullifier_oracle_urls();
46 if services.is_empty() {
47 return Err(AuthenticatorError::Generic(
48 "No nullifier oracle URLs configured".to_string(),
49 ));
50 }
51 let requested_threshold = self.config.nullifier_oracle_threshold();
52 if requested_threshold == 0 {
53 return Err(AuthenticatorError::InvalidConfig {
54 attribute: "nullifier_oracle_threshold".to_string(),
55 reason: "must be at least 1".to_string(),
56 });
57 }
58 let threshold = requested_threshold.min(services.len());
59
60 let authenticator_input = self
61 .prepare_authenticator_input(account_inclusion_proof)
62 .await?;
63
64 Ok(OprfEntrypoint::new(
65 services,
66 threshold,
67 query_material,
68 authenticator_input,
69 &self.ws_connector,
70 ))
71 }
72
73 async fn prepare_authenticator_input(
74 &self,
75 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
76 ) -> Result<AuthenticatorProofInput, AuthenticatorError> {
77 let account_inclusion_proof = if let Some(account_inclusion_proof) = account_inclusion_proof
79 {
80 account_inclusion_proof
81 } else {
82 self.fetch_inclusion_proof().await?
83 };
84
85 let key_index = account_inclusion_proof
86 .authenticator_pubkeys
87 .iter()
88 .position(|pk| {
89 pk.as_ref()
90 .is_some_and(|pk| pk.pk == self.offchain_pubkey().pk)
91 })
92 .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
93
94 let authenticator_input = AuthenticatorProofInput::new(
95 account_inclusion_proof.authenticator_pubkeys,
96 account_inclusion_proof.inclusion_proof,
97 self.signer
98 .offchain_signer_private_key()
99 .expose_secret()
100 .clone(),
101 key_index,
102 );
103
104 Ok(authenticator_input)
105 }
106
107 pub async fn generate_nullifier(
130 &self,
131 proof_request: &ProofRequest,
132 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
133 ) -> Result<FullOprfOutput, AuthenticatorError> {
134 proof_request.validate_proof_type()?;
135 let mut rng = rand::rngs::OsRng;
136
137 let query_material = self
138 .zk_artifact_source
139 .query_material()
140 .map_err(AuthenticatorError::ZkArtifactError)?;
141 let oprf_entrypoint = self
142 .get_oprf_entrypoint(&query_material, account_inclusion_proof)
143 .await?;
144
145 Ok(oprf_entrypoint
146 .gen_nullifier(&mut rng, proof_request)
147 .await?)
148 }
149
150 pub async fn generate_credential_blinding_factor(
161 &self,
162 issuer_schema_id: u64,
163 ) -> Result<FieldElement, AuthenticatorError> {
164 let mut rng = rand::rngs::OsRng;
165
166 let query_material = self
168 .zk_artifact_source
169 .query_material()
170 .map_err(AuthenticatorError::ZkArtifactError)?;
171 let oprf_entrypoint = self.get_oprf_entrypoint(&query_material, None).await?;
172
173 let (blinding_factor, _share_epoch) = oprf_entrypoint
174 .gen_credential_blinding_factor(&mut rng, issuer_schema_id)
175 .await?;
176
177 Ok(blinding_factor)
178 }
179
180 pub async fn build_session_id(
205 &self,
206 proof_request: &ProofRequest,
207 session_id_r_seed: Option<FieldElement>,
208 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
209 ) -> Result<(SessionId, FieldElement), AuthenticatorError> {
210 proof_request.validate_proof_type()?;
211 if !proof_request.is_session_proof() {
212 return Err(AuthenticatorError::PrimitiveError(
213 world_id_primitives::PrimitiveError::InvalidInput {
214 attribute: "proof_type".to_string(),
215 reason: "must be create_session or session".to_string(),
216 },
217 ));
218 }
219
220 let mut rng = rand::rngs::OsRng;
221
222 let oprf_seed = match proof_request.session_id {
223 Some(session_id) => session_id.oprf_seed,
224 None => SessionId::generate_oprf_seed(&mut rng),
225 };
226
227 let resolved_session_id_r_seed = match session_id_r_seed {
228 Some(seed) => seed,
229 None => {
230 let query_material = self
231 .zk_artifact_source
232 .query_material()
233 .map_err(AuthenticatorError::ZkArtifactError)?;
234 let entrypoint = self
235 .get_oprf_entrypoint(&query_material, account_inclusion_proof)
236 .await?;
237 let oprf_output = entrypoint
238 .derive_session_id_r_seed(&mut rng, proof_request, oprf_seed)
239 .await?;
240 oprf_output.verifiable_oprf_output.output.into()
241 }
242 };
243
244 let session_id =
245 SessionId::from_r_seed(self.leaf_index(), resolved_session_id_r_seed, oprf_seed)?;
246
247 if let Some(request_session_id) = proof_request.session_id
248 && request_session_id != session_id
249 {
250 return Err(AuthenticatorError::SessionIdMismatch);
251 }
252
253 Ok((session_id, resolved_session_id_r_seed))
254 }
255
256 pub async fn generate_proof(
292 &self,
293 proof_request: &ProofRequest,
294 nullifier: FullOprfOutput,
295 credentials: &[CredentialInput],
296 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
297 session_id_r_seed: Option<FieldElement>,
298 ) -> Result<ProofResult, AuthenticatorError> {
299 proof_request.validate_proof_type()?;
300
301 let available: std::collections::HashSet<u64> = credentials
303 .iter()
304 .map(|c| c.credential.issuer_schema_id)
305 .collect();
306 let items_to_prove = proof_request
307 .credentials_to_prove(&available)
308 .ok_or(AuthenticatorError::UnfullfilableRequest)?;
309
310 let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type {
312 ProofType::Uniqueness => (None, None),
313 ProofType::CreateSession => {
314 let (session_id, seed) = self
315 .build_session_id(proof_request, None, account_inclusion_proof)
316 .await?;
317 (Some(session_id), Some(seed))
318 }
319 ProofType::Session => {
320 let session_id = proof_request
321 .session_id
322 .expect("session proof must have session_id");
323 if let Some(seed) = session_id_r_seed {
324 let computed =
326 SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed)?;
327
328 if computed != session_id {
329 return Err(AuthenticatorError::SessionIdMismatch);
330 }
331 (Some(session_id), Some(seed))
332 } else {
333 let (_session_id, seed) = self
336 .build_session_id(proof_request, None, account_inclusion_proof)
337 .await?;
338 (Some(session_id), Some(seed))
339 }
340 }
341 };
342
343 let nullifier_material = self
344 .zk_artifact_source
345 .nullifier_material()
346 .map_err(AuthenticatorError::ZkArtifactError)?;
347
348 let creds_by_schema: std::collections::HashMap<u64, &CredentialInput> = credentials
350 .iter()
351 .map(|c| (c.credential.issuer_schema_id, c))
352 .collect();
353
354 let mut responses = Vec::with_capacity(items_to_prove.len());
355 for request_item in &items_to_prove {
356 let cred_input = creds_by_schema[&request_item.issuer_schema_id];
357
358 let response_item = self.generate_credential_proof(
359 &nullifier_material,
360 nullifier.clone(),
361 request_item,
362 &cred_input.credential,
363 cred_input.blinding_factor,
364 resolved_session_seed,
365 resolved_session_id,
366 proof_request.created_at,
367 )?;
368 responses.push(response_item);
369 }
370
371 let proof_response = ProofResponse {
373 id: proof_request.id.clone(),
374 version: proof_request.version,
375 session_id: resolved_session_id,
376 responses,
377 error: None,
378 };
379
380 proof_request.validate_response(&proof_response)?;
382 Ok(ProofResult {
383 session_id_r_seed: resolved_session_seed,
384 proof_response,
385 })
386 }
387
388 #[expect(clippy::too_many_arguments)]
413 fn generate_credential_proof(
414 &self,
415 nullifier_material: &CircomGroth16Material,
416 oprf_nullifier: FullOprfOutput,
417 request_item: &RequestItem,
418 credential: &Credential,
419 credential_sub_blinding_factor: FieldElement,
420 session_id_r_seed: Option<FieldElement>,
421 session_id: Option<SessionId>,
422 request_timestamp: u64,
423 ) -> Result<ResponseItem, AuthenticatorError> {
424 let mut rng = rand::rngs::OsRng;
425
426 let merkle_root: FieldElement = oprf_nullifier.query_proof_input.merkle_root.into();
427 let action_from_query: FieldElement = oprf_nullifier.query_proof_input.action.into();
428
429 let expires_at_min = request_item.effective_expires_at_min(request_timestamp);
430
431 let (proof, _public_inputs, nullifier) = generate_nullifier_proof(
432 nullifier_material,
433 &mut rng,
434 credential,
435 credential_sub_blinding_factor,
436 oprf_nullifier,
437 request_item,
438 session_id.map(|v| v.commitment),
439 session_id_r_seed,
440 expires_at_min,
441 )?;
442
443 let proof = ZeroKnowledgeProof::from_groth16_proof(&proof, merkle_root);
444
445 let nullifier_fe: FieldElement = nullifier.into();
447 let response_item = if session_id.is_some() {
448 let session_nullifier = SessionNullifier::new(nullifier_fe, action_from_query)?;
449 ResponseItem::new_session(
450 request_item.identifier.clone(),
451 request_item.issuer_schema_id,
452 proof,
453 session_nullifier,
454 expires_at_min,
455 )
456 } else {
457 ResponseItem::new_uniqueness(
458 request_item.identifier.clone(),
459 request_item.issuer_schema_id,
460 proof,
461 nullifier_fe.into(),
462 expires_at_min,
463 )
464 };
465
466 Ok(response_item)
467 }
468
469 #[cfg(not(target_arch = "wasm32"))]
482 pub async fn prove_credential_sub(
483 &self,
484 nonce: FieldElement,
485 credential_blinding_factor: FieldElement,
486 sub: FieldElement,
487 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
488 ) -> Result<OwnershipProof, AuthenticatorError> {
489 use world_id_proof::ownership_proof::DS_OWNERSHIP_PROOF;
490
491 let authenticator_input = self
492 .prepare_authenticator_input(account_inclusion_proof)
493 .await?;
494
495 let commitment = Credential::compute_sub(self.leaf_index(), credential_blinding_factor);
496
497 if commitment != sub {
498 return Err(AuthenticatorError::InvalidSubOrBlindingFactor);
499 }
500
501 let mut message = [
502 *FieldElement::from_be_bytes_mod_order(DS_OWNERSHIP_PROOF),
503 *commitment,
504 *nonce,
505 ];
506 poseidon2::bn254::t3::permutation_in_place(&mut message);
507
508 let signature = self
509 .signer
510 .offchain_signer_private_key()
511 .expose_secret()
512 .sign(message[1]);
513
514 let input = OwnershipProofCircuitInput {
515 key_index: authenticator_input.key_index,
516 key_set: authenticator_input.key_set.clone(),
517 inclusion_proof: authenticator_input.inclusion_proof.clone(),
518 nonce,
519 signature,
520 commitment_blinder: credential_blinding_factor,
521 };
522
523 let prover = self
524 .zk_artifact_source
525 .ownership_prover()
526 .map_err(AuthenticatorError::ZkArtifactError)?;
527
528 Ok(generate_ownership_proof_with_prover(input, prover)?)
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use crate::{
535 authenticator::Authenticator,
536 error::AuthenticatorError,
537 service_client::{ServiceClient, ServiceKind},
538 };
539 use alloy::primitives::address;
540 use ruint::aliases::U256;
541 use std::sync::Arc;
542 use taceo_oprf::client::Connector;
543 use world_id_primitives::{
544 Config, FieldElement, ServiceEndpoint, Signer, TREE_DEPTH, merkle::AccountInclusionProof,
545 };
546 use world_id_proof::artifacts::{ZkArtifactSource, dummy::DummyZkArtifactSource};
547 use world_id_test_utils::fixtures::single_leaf_merkle_fixture;
548
549 fn build_test_authenticator(
550 seed: &[u8; 32],
551 leaf_index: u64,
552 zk_artifact_source: Arc<dyn ZkArtifactSource>,
553 ) -> (Authenticator, AccountInclusionProof<TREE_DEPTH>) {
554 let signer = Signer::from_seed_bytes(seed).expect("valid seed");
555 let pubkey = signer.offchain_signer_pubkey();
556
557 let fixture =
558 single_leaf_merkle_fixture(vec![pubkey], leaf_index).expect("valid merkle fixture");
559 let account_inclusion_proof =
560 AccountInclusionProof::new(fixture.inclusion_proof, fixture.key_set);
561
562 let config = Config::new(
563 None,
564 1,
565 address!("0x0000000000000000000000000000000000000001"),
566 ServiceEndpoint::direct("http://indexer.example.com".to_string()),
567 ServiceEndpoint::direct("http://gateway.example.com".to_string()),
568 Vec::new(),
569 2,
570 )
571 .expect("valid config");
572
573 let http_client = reqwest::Client::new();
574 let authenticator = Authenticator {
575 config: config.clone(),
576 packed_account_data: U256::from(leaf_index),
577 signer,
578 registry: None,
579 indexer_client: ServiceClient::new(
580 http_client.clone(),
581 ServiceKind::Indexer,
582 config.indexer(),
583 )
584 .expect("valid indexer client"),
585 gateway_client: ServiceClient::new(http_client, ServiceKind::Gateway, config.gateway())
586 .expect("valid gateway client"),
587 ws_connector: Connector::Plain,
588 zk_artifact_source,
589 };
590
591 (authenticator, account_inclusion_proof)
592 }
593
594 #[tokio::test]
595 async fn test_prove_credential_sub_rejects_wrong_sub() {
596 let leaf_index = 1u64;
597 let (authenticator, inclusion_proof) =
598 build_test_authenticator(&[42u8; 32], leaf_index, Arc::new(DummyZkArtifactSource));
599
600 let blinding_factor = FieldElement::from(999u64);
601 let wrong_sub = FieldElement::from(123u64);
602
603 let result = authenticator
604 .prove_credential_sub(
605 FieldElement::from(1_234_567_890u64),
606 blinding_factor,
607 wrong_sub,
608 Some(inclusion_proof),
609 )
610 .await;
611
612 assert!(matches!(
613 result,
614 Err(AuthenticatorError::InvalidSubOrBlindingFactor)
615 ));
616 }
617
618 #[tokio::test]
619 #[cfg(all(
620 not(target_arch = "wasm32"),
621 feature = "embed-zkeys",
622 feature = "embed-ownership-prover"
623 ))]
624 async fn test_prove_credential_sub_succeeds_with_correct_sub() {
625 use world_id_primitives::Credential;
626 use world_id_proof::artifacts::{ZkArtifactSourceExt as _, embedded::EmbeddedZkArtifacts};
627
628 let leaf_index = 1u64;
629 let zk_artifact_source = EmbeddedZkArtifacts.cached();
630 let (authenticator, inclusion_proof) =
631 build_test_authenticator(&[42u8; 32], leaf_index, Arc::new(zk_artifact_source));
632
633 let blinding_factor = FieldElement::from(999u64);
634 let correct_sub = Credential::compute_sub(leaf_index, blinding_factor);
635 let nonce = FieldElement::from(1_234_567_890u64);
636
637 authenticator
638 .prove_credential_sub(nonce, blinding_factor, correct_sub, Some(inclusion_proof))
639 .await
640 .expect("proof generation should succeed");
641 }
642}