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::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, ownership_proof::generate_ownership_proof,
22};
23
24#[expect(unused_imports, reason = "used for docs")]
25use world_id_primitives::Nullifier;
26
27impl Authenticator {
28 async fn get_oprf_entrypoint(
39 &self,
40 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
41 ) -> Result<OprfEntrypoint<'_>, AuthenticatorError> {
42 let services = self.config.nullifier_oracle_urls();
44 if services.is_empty() {
45 return Err(AuthenticatorError::Generic(
46 "No nullifier oracle URLs configured".to_string(),
47 ));
48 }
49 let requested_threshold = self.config.nullifier_oracle_threshold();
50 if requested_threshold == 0 {
51 return Err(AuthenticatorError::InvalidConfig {
52 attribute: "nullifier_oracle_threshold".to_string(),
53 reason: "must be at least 1".to_string(),
54 });
55 }
56 let threshold = requested_threshold.min(services.len());
57
58 let query_material = self
59 .query_material
60 .as_ref()
61 .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
62
63 let authenticator_input = self
64 .prepare_authenticator_input(account_inclusion_proof)
65 .await?;
66
67 Ok(OprfEntrypoint::new(
68 services,
69 threshold,
70 query_material,
71 authenticator_input,
72 &self.ws_connector,
73 ))
74 }
75
76 async fn prepare_authenticator_input(
77 &self,
78 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
79 ) -> Result<AuthenticatorProofInput, AuthenticatorError> {
80 let account_inclusion_proof = if let Some(account_inclusion_proof) = account_inclusion_proof
82 {
83 account_inclusion_proof
84 } else {
85 self.fetch_inclusion_proof().await?
86 };
87
88 let key_index = account_inclusion_proof
89 .authenticator_pubkeys
90 .iter()
91 .position(|pk| {
92 pk.as_ref()
93 .is_some_and(|pk| pk.pk == self.offchain_pubkey().pk)
94 })
95 .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
96
97 let authenticator_input = AuthenticatorProofInput::new(
98 account_inclusion_proof.authenticator_pubkeys,
99 account_inclusion_proof.inclusion_proof,
100 self.signer
101 .offchain_signer_private_key()
102 .expose_secret()
103 .clone(),
104 key_index,
105 );
106
107 Ok(authenticator_input)
108 }
109
110 pub async fn generate_nullifier(
133 &self,
134 proof_request: &ProofRequest,
135 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
136 ) -> Result<FullOprfOutput, AuthenticatorError> {
137 proof_request.validate_proof_type()?;
138 let mut rng = rand::rngs::OsRng;
139
140 let oprf_entrypoint = self.get_oprf_entrypoint(account_inclusion_proof).await?;
141
142 Ok(oprf_entrypoint
143 .gen_nullifier(&mut rng, proof_request)
144 .await?)
145 }
146
147 pub async fn generate_credential_blinding_factor(
158 &self,
159 issuer_schema_id: u64,
160 ) -> Result<FieldElement, AuthenticatorError> {
161 let mut rng = rand::rngs::OsRng;
162
163 let oprf_entrypoint = self.get_oprf_entrypoint(None).await?;
165
166 let (blinding_factor, _share_epoch) = oprf_entrypoint
167 .gen_credential_blinding_factor(&mut rng, issuer_schema_id)
168 .await?;
169
170 Ok(blinding_factor)
171 }
172
173 pub async fn build_session_id(
198 &self,
199 proof_request: &ProofRequest,
200 session_id_r_seed: Option<FieldElement>,
201 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
202 ) -> Result<(SessionId, FieldElement), AuthenticatorError> {
203 proof_request.validate_proof_type()?;
204 if !proof_request.is_session_proof() {
205 return Err(AuthenticatorError::PrimitiveError(
206 world_id_primitives::PrimitiveError::InvalidInput {
207 attribute: "proof_type".to_string(),
208 reason: "must be create_session or session".to_string(),
209 },
210 ));
211 }
212
213 let mut rng = rand::rngs::OsRng;
214
215 let oprf_seed = match proof_request.session_id {
216 Some(session_id) => session_id.oprf_seed,
217 None => SessionId::generate_oprf_seed(&mut rng),
218 };
219
220 let resolved_session_id_r_seed = match session_id_r_seed {
221 Some(seed) => seed,
222 None => {
223 let entrypoint = self.get_oprf_entrypoint(account_inclusion_proof).await?;
224 let oprf_output = entrypoint
225 .derive_session_id_r_seed(&mut rng, proof_request, oprf_seed)
226 .await?;
227 oprf_output.verifiable_oprf_output.output.into()
228 }
229 };
230
231 let session_id =
232 SessionId::from_r_seed(self.leaf_index(), resolved_session_id_r_seed, oprf_seed)?;
233
234 if let Some(request_session_id) = proof_request.session_id
235 && request_session_id != session_id
236 {
237 return Err(AuthenticatorError::SessionIdMismatch);
238 }
239
240 Ok((session_id, resolved_session_id_r_seed))
241 }
242
243 pub async fn generate_proof(
279 &self,
280 proof_request: &ProofRequest,
281 nullifier: FullOprfOutput,
282 credentials: &[CredentialInput],
283 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
284 session_id_r_seed: Option<FieldElement>,
285 ) -> Result<ProofResult, AuthenticatorError> {
286 proof_request.validate_proof_type()?;
287
288 let available: std::collections::HashSet<u64> = credentials
290 .iter()
291 .map(|c| c.credential.issuer_schema_id)
292 .collect();
293 let items_to_prove = proof_request
294 .credentials_to_prove(&available)
295 .ok_or(AuthenticatorError::UnfullfilableRequest)?;
296
297 let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type {
299 ProofType::Uniqueness => (None, None),
300 ProofType::CreateSession => {
301 let (session_id, seed) = self
302 .build_session_id(proof_request, None, account_inclusion_proof)
303 .await?;
304 (Some(session_id), Some(seed))
305 }
306 ProofType::Session => {
307 let session_id = proof_request
308 .session_id
309 .expect("session proof must have session_id");
310 if let Some(seed) = session_id_r_seed {
311 let computed =
313 SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed)?;
314
315 if computed != session_id {
316 return Err(AuthenticatorError::SessionIdMismatch);
317 }
318 (Some(session_id), Some(seed))
319 } else {
320 let (_session_id, seed) = self
323 .build_session_id(proof_request, None, account_inclusion_proof)
324 .await?;
325 (Some(session_id), Some(seed))
326 }
327 }
328 };
329
330 let creds_by_schema: std::collections::HashMap<u64, &CredentialInput> = credentials
332 .iter()
333 .map(|c| (c.credential.issuer_schema_id, c))
334 .collect();
335
336 let mut responses = Vec::with_capacity(items_to_prove.len());
337 for request_item in &items_to_prove {
338 let cred_input = creds_by_schema[&request_item.issuer_schema_id];
339
340 let response_item = self.generate_credential_proof(
341 nullifier.clone(),
342 request_item,
343 &cred_input.credential,
344 cred_input.blinding_factor,
345 resolved_session_seed,
346 resolved_session_id,
347 proof_request.created_at,
348 )?;
349 responses.push(response_item);
350 }
351
352 let proof_response = ProofResponse {
354 id: proof_request.id.clone(),
355 version: proof_request.version,
356 session_id: resolved_session_id,
357 responses,
358 error: None,
359 };
360
361 proof_request.validate_response(&proof_response)?;
363 Ok(ProofResult {
364 session_id_r_seed: resolved_session_seed,
365 proof_response,
366 })
367 }
368
369 #[expect(clippy::too_many_arguments)]
394 fn generate_credential_proof(
395 &self,
396 oprf_nullifier: FullOprfOutput,
397 request_item: &RequestItem,
398 credential: &Credential,
399 credential_sub_blinding_factor: FieldElement,
400 session_id_r_seed: Option<FieldElement>,
401 session_id: Option<SessionId>,
402 request_timestamp: u64,
403 ) -> Result<ResponseItem, AuthenticatorError> {
404 let mut rng = rand::rngs::OsRng;
405
406 let nullifier_material = self
407 .nullifier_material
408 .as_ref()
409 .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
410
411 let merkle_root: FieldElement = oprf_nullifier.query_proof_input.merkle_root.into();
412 let action_from_query: FieldElement = oprf_nullifier.query_proof_input.action.into();
413
414 let expires_at_min = request_item.effective_expires_at_min(request_timestamp);
415
416 let (proof, _public_inputs, nullifier) = generate_nullifier_proof(
417 nullifier_material,
418 &mut rng,
419 credential,
420 credential_sub_blinding_factor,
421 oprf_nullifier,
422 request_item,
423 session_id.map(|v| v.commitment),
424 session_id_r_seed,
425 expires_at_min,
426 )?;
427
428 let proof = ZeroKnowledgeProof::from_groth16_proof(&proof, merkle_root);
429
430 let nullifier_fe: FieldElement = nullifier.into();
432 let response_item = if session_id.is_some() {
433 let session_nullifier = SessionNullifier::new(nullifier_fe, action_from_query)?;
434 ResponseItem::new_session(
435 request_item.identifier.clone(),
436 request_item.issuer_schema_id,
437 proof,
438 session_nullifier,
439 expires_at_min,
440 )
441 } else {
442 ResponseItem::new_uniqueness(
443 request_item.identifier.clone(),
444 request_item.issuer_schema_id,
445 proof,
446 nullifier_fe.into(),
447 expires_at_min,
448 )
449 };
450
451 Ok(response_item)
452 }
453
454 #[cfg(not(target_arch = "wasm32"))]
467 pub async fn prove_credential_sub(
468 &self,
469 nonce: FieldElement,
470 credential_blinding_factor: FieldElement,
471 sub: FieldElement,
472 account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
473 ) -> Result<OwnershipProof, AuthenticatorError> {
474 let authenticator_input = self
475 .prepare_authenticator_input(account_inclusion_proof)
476 .await?;
477
478 let commitment = Credential::compute_sub(self.leaf_index(), credential_blinding_factor);
479
480 if commitment != sub {
481 return Err(AuthenticatorError::InvalidSubOrBlindingFactor);
482 }
483
484 let signature = self
485 .signer
486 .offchain_signer_private_key()
487 .expose_secret()
488 .sign(*commitment);
489
490 let input = OwnershipProofCircuitInput {
491 key_index: authenticator_input.key_index,
492 key_set: authenticator_input.key_set.clone(),
493 inclusion_proof: authenticator_input.inclusion_proof.clone(),
494 nonce,
495 signature,
496 commitment_blinder: credential_blinding_factor,
497 };
498
499 Ok(generate_ownership_proof(input)?)
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use crate::{
506 authenticator::Authenticator,
507 error::AuthenticatorError,
508 service_client::{ServiceClient, ServiceKind},
509 };
510 use alloy::primitives::address;
511 use ruint::aliases::U256;
512 use taceo_oprf::client::Connector;
513 use world_id_primitives::{
514 Config, Credential, FieldElement, ServiceEndpoint, Signer, TREE_DEPTH,
515 merkle::AccountInclusionProof,
516 };
517 use world_id_test_utils::fixtures::single_leaf_merkle_fixture;
518
519 fn build_test_authenticator(
520 seed: &[u8; 32],
521 leaf_index: u64,
522 ) -> (Authenticator, AccountInclusionProof<TREE_DEPTH>) {
523 let signer = Signer::from_seed_bytes(seed).expect("valid seed");
524 let pubkey = signer.offchain_signer_pubkey();
525
526 let fixture =
527 single_leaf_merkle_fixture(vec![pubkey], leaf_index).expect("valid merkle fixture");
528 let account_inclusion_proof =
529 AccountInclusionProof::new(fixture.inclusion_proof, fixture.key_set);
530
531 let config = Config::new(
532 None,
533 1,
534 address!("0x0000000000000000000000000000000000000001"),
535 ServiceEndpoint::direct("http://indexer.example.com".to_string()),
536 ServiceEndpoint::direct("http://gateway.example.com".to_string()),
537 Vec::new(),
538 2,
539 )
540 .expect("valid config");
541
542 let http_client = reqwest::Client::new();
543 let authenticator = Authenticator {
544 config: config.clone(),
545 packed_account_data: U256::from(leaf_index),
546 signer,
547 registry: None,
548 indexer_client: ServiceClient::new(
549 http_client.clone(),
550 ServiceKind::Indexer,
551 config.indexer(),
552 )
553 .expect("valid indexer client"),
554 gateway_client: ServiceClient::new(http_client, ServiceKind::Gateway, config.gateway())
555 .expect("valid gateway client"),
556 ws_connector: Connector::Plain,
557 query_material: None,
558 nullifier_material: None,
559 };
560
561 (authenticator, account_inclusion_proof)
562 }
563
564 #[tokio::test]
565 async fn test_prove_credential_sub_rejects_wrong_sub() {
566 let leaf_index = 1u64;
567 let (authenticator, inclusion_proof) = build_test_authenticator(&[42u8; 32], leaf_index);
568
569 let blinding_factor = FieldElement::from(999u64);
570 let wrong_sub = FieldElement::from(123u64);
571
572 let result = authenticator
573 .prove_credential_sub(
574 FieldElement::from(1_234_567_890u64),
575 blinding_factor,
576 wrong_sub,
577 Some(inclusion_proof),
578 )
579 .await;
580
581 assert!(matches!(
582 result,
583 Err(AuthenticatorError::InvalidSubOrBlindingFactor)
584 ));
585 }
586
587 #[tokio::test]
588 async fn test_prove_credential_sub_succeeds_with_correct_sub() {
589 let leaf_index = 1u64;
590 let (authenticator, inclusion_proof) = build_test_authenticator(&[42u8; 32], leaf_index);
591
592 let blinding_factor = FieldElement::from(999u64);
593 let correct_sub = Credential::compute_sub(leaf_index, blinding_factor);
594 let nonce = FieldElement::from(1_234_567_890u64);
595
596 authenticator
597 .prove_credential_sub(nonce, blinding_factor, correct_sub, Some(inclusion_proof))
598 .await
599 .expect("proof generation should succeed");
600 }
601}