1use crate::{
4 authenticator::artifacts::WalletKitZkArtifactSource, defaults,
5 error::WalletKitError, primitives::ParseFromForeignBinding, Environment,
6 FieldElement, Region,
7};
8use alloy_core::primitives::Address;
9use ruint::aliases::U256;
10use ruint_uniffi::Uint256;
11use std::sync::Arc;
12use world_id_core::{
13 api_types::{GatewayErrorCode, GatewayRequestId, GatewayRequestState},
14 primitives::{AuthenticatorPublicKeySet, Config, MAX_AUTHENTICATOR_KEYS},
15 Authenticator as CoreAuthenticator, AuthenticatorError,
16 Credential as CoreCredential, CredentialInput, EdDSAPublicKey,
17 InitializingAuthenticator as CoreInitializingAuthenticator,
18 OnchainKeyRepresentable, Signer,
19};
20use zeroize::Zeroizing;
21
22use crate::requests::{ProofRequest, ProofResponse};
23use crate::storage::CredentialStore;
24use crate::OwnershipProof;
25
26pub mod artifacts;
27mod with_storage;
28
29#[derive(Debug, uniffi::Object)]
31pub struct Authenticator {
32 inner: CoreAuthenticator,
33 store: Arc<CredentialStore>,
34}
35
36impl Authenticator {
37 pub async fn init_with_config(
43 seed: &[u8],
44 config: Config,
45 artifacts: Arc<dyn WalletKitZkArtifactSource>,
46 store: Arc<CredentialStore>,
47 ) -> Result<Self, WalletKitError> {
48 let authenticator = CoreAuthenticator::init(seed, config, artifacts).await?;
49
50 Ok(Self {
51 inner: authenticator,
52 store,
53 })
54 }
55}
56
57fn parse_authenticator_pubkey(
58 attribute: &str,
59 encoded_pubkey: impl AsRef<str>,
60) -> Result<EdDSAPublicKey, WalletKitError> {
61 let encoded_pubkey = encoded_pubkey.as_ref();
62 let invalid_input = |reason: String| WalletKitError::InvalidInput {
63 attribute: attribute.to_string(),
64 reason,
65 };
66 let hex = encoded_pubkey.strip_prefix("0x").ok_or_else(|| {
67 invalid_input("Public key must start with a 0x prefix".to_string())
68 })?;
69
70 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
71 return Err(invalid_input(
72 "Public key must be exactly 32 bytes (64 hex characters) after the 0x prefix"
73 .to_string(),
74 ));
75 }
76
77 let encoded = U256::from_str_radix(hex, 16)
78 .map_err(|error| invalid_input(error.to_string()))?;
79 let pubkey = EdDSAPublicKey::from_compressed_bytes(encoded.to_le_bytes())
80 .map_err(|error| invalid_input(error.to_string()))?;
81
82 let canonical = pubkey
88 .to_ethereum_representation()
89 .map_err(|error| invalid_input(error.to_string()))?;
90 if canonical != encoded {
91 return Err(invalid_input(
92 "Public key is not the canonical compressed point encoding".to_string(),
93 ));
94 }
95 if canonical == U256::from(1u64) {
96 return Err(invalid_input(
97 "Public key must not be the BabyJubJub identity point".to_string(),
98 ));
99 }
100
101 Ok(pubkey)
102}
103
104#[uniffi::export(async_runtime = "tokio")]
105impl Authenticator {
106 #[must_use]
111 pub fn packed_account_data(&self) -> Uint256 {
112 self.inner.packed_account_data.into()
113 }
114
115 #[must_use]
120 pub fn leaf_index(&self) -> u64 {
121 self.inner.leaf_index()
122 }
123
124 #[must_use]
128 pub fn onchain_address(&self) -> String {
129 self.inner.onchain_address().to_string()
130 }
131
132 #[tracing::instrument(
137 target = "walletkit_latency",
138 name = "rpc_account_data",
139 skip_all
140 )]
141 pub async fn get_packed_account_data_remote(
142 &self,
143 ) -> Result<Uint256, WalletKitError> {
144 let packed_account_data = self.inner.fetch_packed_account_data().await?;
145 Ok(packed_account_data.into())
146 }
147
148 #[tracing::instrument(
157 target = "walletkit_latency",
158 name = "oprf_blinding_factor",
159 skip_all
160 )]
161 pub async fn generate_credential_blinding_factor_remote(
162 &self,
163 issuer_schema_id: u64,
164 ) -> Result<FieldElement, WalletKitError> {
165 Ok(self
166 .inner
167 .generate_credential_blinding_factor(issuer_schema_id)
168 .await
169 .map(Into::into)?)
170 }
171
172 #[must_use]
174 pub fn compute_credential_sub(
175 &self,
176 blinding_factor: &FieldElement,
177 ) -> FieldElement {
178 CoreCredential::compute_sub(self.inner.leaf_index(), blinding_factor.0).into()
179 }
180
181 #[allow(
192 clippy::needless_pass_by_value,
193 reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
194 )]
195 pub fn danger_sign_challenge(
196 &self,
197 challenge: Vec<u8>,
198 ) -> Result<Vec<u8>, WalletKitError> {
199 let signature = self.inner.danger_sign_challenge(&challenge)?;
200 Ok(signature.as_bytes().to_vec())
201 }
202
203 pub async fn danger_sign_initiate_recovery_agent_update(
227 &self,
228 new_recovery_agent: String,
229 ) -> Result<RecoveryUpdateSignature, WalletKitError> {
230 let new_recovery_agent =
231 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
232 let (sig, nonce) = self
233 .inner
234 .danger_sign_initiate_recovery_agent_update(new_recovery_agent)
235 .await?;
236 Ok(RecoveryUpdateSignature {
237 signature: sig.as_bytes().to_vec(),
238 nonce: nonce.into(),
239 })
240 }
241
242 pub async fn update_recovery_agent(
260 &self,
261 new_recovery_agent: String,
262 ) -> Result<String, WalletKitError> {
263 let new_recovery_agent =
264 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
265
266 let request_id = self.inner.update_recovery_agent(new_recovery_agent).await?;
267
268 Ok(request_id.to_string())
269 }
270
271 pub async fn revert_recovery_agent_update(&self) -> Result<String, WalletKitError> {
285 let request_id = self.inner.revert_recovery_agent_update().await?;
286
287 Ok(request_id.to_string())
288 }
289
290 #[tracing::instrument(
304 target = "walletkit_latency",
305 name = "gateway_insert_authenticator",
306 skip_all
307 )]
308 pub async fn insert_authenticator(
309 &self,
310 new_authenticator_pubkey: String,
311 new_authenticator_address: String,
312 ) -> Result<String, WalletKitError> {
313 let new_authenticator_pubkey = parse_authenticator_pubkey(
314 "new_authenticator_pubkey",
315 new_authenticator_pubkey,
316 )?;
317 let new_authenticator_address = Address::parse_from_ffi(
318 &new_authenticator_address,
319 "new_authenticator_address",
320 )?;
321
322 let request_id = self
323 .inner
324 .insert_authenticator(new_authenticator_pubkey, new_authenticator_address)
325 .await?;
326
327 Ok(request_id.to_string())
328 }
329
330 #[tracing::instrument(
344 target = "walletkit_latency",
345 name = "indexer_authenticator_pubkeys",
346 skip_all
347 )]
348 pub async fn has_authenticator_pubkey(
349 &self,
350 authenticator_pubkey: String,
351 ) -> Result<bool, WalletKitError> {
352 let authenticator_pubkey =
353 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
354 let pubkeys = self.inner.fetch_authenticator_pubkeys().await?;
355 Ok(pubkeys
356 .iter()
357 .flatten()
358 .any(|existing_pubkey| existing_pubkey == &authenticator_pubkey))
359 }
360
361 #[tracing::instrument(
375 target = "walletkit_latency",
376 name = "indexer_authenticator_pubkeys",
377 skip_all
378 )]
379 pub async fn get_authenticator_pubkeys(
380 &self,
381 ) -> Result<Vec<Option<String>>, WalletKitError> {
382 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
383 key_set
384 .iter()
385 .map(|slot| {
386 slot.as_ref()
387 .map(|pubkey| {
388 let encoded = pubkey.to_ethereum_representation()?;
389 Ok(format!("{encoded:#066x}"))
390 })
391 .transpose()
392 })
393 .collect()
394 }
395
396 #[tracing::instrument(
420 target = "walletkit_latency",
421 name = "gateway_remove_authenticator",
422 skip_all
423 )]
424 pub async fn remove_authenticator(
425 &self,
426 authenticator_address: String,
427 pubkey_id: u32,
428 expected_authenticator_pubkey: String,
429 ) -> Result<String, WalletKitError> {
430 let expected_pubkey = parse_authenticator_pubkey(
431 "expected_authenticator_pubkey",
432 expected_authenticator_pubkey,
433 )?;
434 let authenticator_address =
435 Address::parse_from_ffi(&authenticator_address, "authenticator_address")?;
436
437 if pubkey_id as usize >= MAX_AUTHENTICATOR_KEYS {
438 return Err(WalletKitError::InvalidInput {
439 attribute: "pubkey_id".to_string(),
440 reason: format!(
441 "pubkey_id {pubkey_id} is out of range; the key set has at \
442 most {MAX_AUTHENTICATOR_KEYS} slots"
443 ),
444 });
445 }
446
447 let empty_slot = || WalletKitError::InvalidInput {
448 attribute: "pubkey_id".to_string(),
449 reason: format!("no authenticator at key set slot {pubkey_id}"),
450 };
451 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
452 let actual_pubkey = key_set.get(pubkey_id as usize).ok_or_else(empty_slot)?;
453 if actual_pubkey != &expected_pubkey {
454 return Err(WalletKitError::InvalidInput {
455 attribute: "expected_authenticator_pubkey".to_string(),
456 reason: format!(
457 "key set slot {pubkey_id} holds a different authenticator public key"
458 ),
459 });
460 }
461
462 let request_id = self
463 .inner
464 .remove_authenticator(authenticator_address, pubkey_id)
465 .await
466 .map_err(|error| match error {
467 AuthenticatorError::PublicKeyNotFound => empty_slot(),
471 other => other.into(),
472 })?;
473
474 Ok(request_id.to_string())
475 }
476
477 #[tracing::instrument(
482 target = "walletkit_latency",
483 name = "gateway_poll",
484 skip_all
485 )]
486 pub async fn poll_status(
487 &self,
488 request_id: String,
489 ) -> Result<GatewayRequestStatus, WalletKitError> {
490 let request_id = GatewayRequestId::new(
491 request_id.strip_prefix("gw_").unwrap_or(&request_id),
492 );
493 let status = self.inner.poll_status(&request_id).await?;
494 Ok(status.into())
495 }
496}
497
498#[uniffi::export(async_runtime = "tokio")]
499impl Authenticator {
500 #[uniffi::constructor]
508 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
509 pub async fn init_with_defaults(
510 seed: Vec<u8>,
511 rpc_url: Option<String>,
512 environment: &Environment,
513 region: Option<Region>,
514 artifacts: Arc<dyn WalletKitZkArtifactSource>,
515 store: Arc<CredentialStore>,
516 ) -> Result<Self, WalletKitError> {
517 let seed = Zeroizing::new(seed);
518 let config = defaults::default_config(environment, rpc_url, region)?;
519 Self::init_with_config(&seed, config, artifacts, store).await
520 }
521
522 #[uniffi::constructor]
532 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
533 pub async fn init_with_ohttp_defaults(
534 seed: Vec<u8>,
535 rpc_url: Option<String>,
536 environment: &Environment,
537 region: Option<Region>,
538 artifacts: Arc<dyn WalletKitZkArtifactSource>,
539 store: Arc<CredentialStore>,
540 ) -> Result<Self, WalletKitError> {
541 let seed = Zeroizing::new(seed);
542 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
543 Self::init_with_config(&seed, config, artifacts, store).await
544 }
545
546 #[uniffi::constructor]
554 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
555 pub async fn init(
556 seed: Vec<u8>,
557 config: &str,
558 artifacts: Arc<dyn WalletKitZkArtifactSource>,
559 store: Arc<CredentialStore>,
560 ) -> Result<Self, WalletKitError> {
561 let seed = Zeroizing::new(seed);
562 let config =
563 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
564 attribute: "config".to_string(),
565 reason: "Invalid config".to_string(),
566 })?;
567 Self::init_with_config(&seed, config, artifacts, store).await
568 }
569
570 pub async fn generate_proof(
575 &self,
576 proof_request: &ProofRequest,
577 now: Option<u64>,
578 ) -> Result<ProofResponse, WalletKitError> {
579 let now = resolve_now(now)?;
580
581 let credentials: Vec<_> = self
586 .store
587 .list_credentials(None, now)?
588 .iter()
589 .filter(|c| !c.is_expired)
590 .filter_map(|cred| {
591 if let Ok(Some((credential, blinding_factor))) =
592 self.store.get_credential(cred.issuer_schema_id, now)
593 {
594 Some(CredentialInput {
595 credential: credential.into(),
596 blinding_factor: blinding_factor.into(),
597 })
598 } else {
599 tracing::warn!(
600 issuer_schema_id = %cred.issuer_schema_id,
601 credential_id = %cred.credential_id,
602 "credential listed but not loadable, skipping"
603 );
604 None
605 }
606 })
607 .collect();
608
609 let account_inclusion_proof =
610 self.fetch_inclusion_proof_with_cache(now).await?;
611
612 let nullifier = Box::pin(self.inner.generate_nullifier(
615 &proof_request.0,
616 now,
617 Some(account_inclusion_proof.clone()),
618 ))
619 .await?;
620
621 if self
622 .store
623 .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)?
624 {
625 return Err(WalletKitError::NullifierReplay);
626 }
627
628 let rp_id = proof_request.0.rp_id.into_inner();
630 let session_id_r_seed =
631 proof_request
632 .0
633 .session_id
634 .existing()
635 .and_then(|session_id| match self.store.get_session_seed(
636 rp_id,
637 session_id.oprf_seed,
638 now,
639 ) {
640 Ok(seed) => seed,
641 Err(err) => {
642 tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
643 None
644 }
645 });
646
647 let result = Box::pin(self.inner.generate_proof(
649 &proof_request.0,
650 nullifier.clone(),
651 &credentials,
652 Some(account_inclusion_proof),
653 session_id_r_seed,
654 ))
655 .await?;
656
657 if let Some(seed) = result.session_id_r_seed {
660 if let Some(session_id) = result.proof_response.session_id {
661 if let Err(err) = self.store.store_session_seed(
662 rp_id,
663 session_id.oprf_seed,
664 seed,
665 now,
666 ) {
667 tracing::error!("error caching session_id_r_seed: {}", err);
668 }
669 }
670 }
671
672 self.store
673 .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
674
675 Ok(result.proof_response.into())
676 }
677
678 pub async fn prove_credential_sub(
704 &self,
705 nonce: &FieldElement,
706 context: &FieldElement,
707 blinding_factor: &FieldElement,
708 sub: &FieldElement,
709 ) -> Result<OwnershipProof, WalletKitError> {
710 #[cfg(target_arch = "wasm32")]
711 {
712 let _ = (nonce, context, blinding_factor, sub);
713 return Err(WalletKitError::Generic {
714 error: "credential ownership proofs are not supported on wasm32"
715 .to_string(),
716 });
717 }
718
719 #[cfg(not(target_arch = "wasm32"))]
720 {
721 let now = resolve_now(None)?;
722
723 let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
724 let proof = self
725 .inner
726 .prove_credential_sub(
727 nonce.0,
728 context.0,
729 blinding_factor.0,
730 sub.0,
731 Some(inclusion_proof),
732 )
733 .await?;
734
735 Ok(OwnershipProof(proof))
736 }
737 }
738}
739
740fn resolve_now(now: Option<u64>) -> Result<u64, WalletKitError> {
741 if let Some(now) = now {
742 return Ok(now);
743 }
744
745 #[cfg(target_arch = "wasm32")]
746 {
747 Err(WalletKitError::InvalidInput {
748 attribute: "now".to_string(),
749 reason: "`now` must be provided on wasm32 targets".to_string(),
750 })
751 }
752
753 #[cfg(not(target_arch = "wasm32"))]
754 {
755 std::time::SystemTime::now()
756 .duration_since(std::time::UNIX_EPOCH)
757 .map_err(|e| WalletKitError::Generic {
758 error: format!("Critical. Unable to determine SystemTime: {e}"),
759 })
760 .map(|d| d.as_secs())
761 }
762}
763
764#[derive(Debug, Clone, uniffi::Enum)]
766pub enum RegistrationStatus {
767 Queued,
769 Batching,
771 Submitted,
773 Finalized,
775 Failed {
777 error: String,
779 error_code: Option<String>,
781 },
782}
783
784#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
786pub enum GatewayRequestStatus {
787 Queued,
789 Batching,
791 Submitted {
793 tx_hash: String,
795 },
796 Finalized {
798 tx_hash: String,
800 },
801 Failed {
803 error: String,
805 error_code: Option<String>,
807 },
808}
809
810impl From<GatewayRequestState> for GatewayRequestStatus {
811 fn from(state: GatewayRequestState) -> Self {
812 match state {
813 GatewayRequestState::Queued => Self::Queued,
814 GatewayRequestState::Batching => Self::Batching,
815 GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
816 GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
817 GatewayRequestState::Failed { error, error_code } => Self::Failed {
818 error,
819 error_code: error_code.map(|code| code.to_string()),
820 },
821 }
822 }
823}
824
825impl From<GatewayRequestState> for RegistrationStatus {
826 fn from(state: GatewayRequestState) -> Self {
827 match state {
828 GatewayRequestState::Queued => Self::Queued,
829 GatewayRequestState::Batching => Self::Batching,
830 GatewayRequestState::Submitted { .. } => Self::Submitted,
831 GatewayRequestState::Finalized { .. } => Self::Finalized,
832 GatewayRequestState::Failed { error, error_code } => Self::Failed {
833 error,
834 error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
835 },
836 }
837 }
838}
839
840#[derive(uniffi::Object)]
845pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
846
847#[uniffi::export(async_runtime = "tokio")]
848impl InitializingAuthenticator {
849 #[uniffi::constructor]
857 #[tracing::instrument(
858 target = "walletkit_latency",
859 name = "gateway_register",
860 skip_all
861 )]
862 pub async fn register_with_defaults(
863 seed: Vec<u8>,
864 rpc_url: Option<String>,
865 environment: &Environment,
866 region: Option<Region>,
867 recovery_address: Option<String>,
868 ) -> Result<Self, WalletKitError> {
869 let seed = Zeroizing::new(seed);
870 let recovery_address =
871 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
872
873 let config = defaults::default_config(environment, rpc_url, region)?;
874
875 let initializing_authenticator =
876 CoreAuthenticator::register(&seed, config, recovery_address).await?;
877
878 Ok(Self(initializing_authenticator))
879 }
880
881 #[uniffi::constructor]
891 #[tracing::instrument(
892 target = "walletkit_latency",
893 name = "gateway_register",
894 skip_all
895 )]
896 pub async fn register_with_ohttp_defaults(
897 seed: Vec<u8>,
898 rpc_url: Option<String>,
899 environment: &Environment,
900 region: Option<Region>,
901 recovery_address: Option<String>,
902 ) -> Result<Self, WalletKitError> {
903 let seed = Zeroizing::new(seed);
904 let recovery_address =
905 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
906
907 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
908
909 let initializing_authenticator =
910 CoreAuthenticator::register(&seed, config, recovery_address).await?;
911
912 Ok(Self(initializing_authenticator))
913 }
914
915 #[uniffi::constructor]
923 #[tracing::instrument(
924 target = "walletkit_latency",
925 name = "gateway_register",
926 skip_all
927 )]
928 pub async fn register(
929 seed: Vec<u8>,
930 config: &str,
931 recovery_address: Option<String>,
932 ) -> Result<Self, WalletKitError> {
933 let seed = Zeroizing::new(seed);
934 let recovery_address =
935 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
936
937 let config =
938 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
939 attribute: "config".to_string(),
940 reason: "Invalid config".to_string(),
941 })?;
942
943 let initializing_authenticator =
944 CoreAuthenticator::register(&seed, config, recovery_address).await?;
945
946 Ok(Self(initializing_authenticator))
947 }
948
949 #[tracing::instrument(
954 target = "walletkit_latency",
955 name = "gateway_poll",
956 skip_all
957 )]
958 pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
959 let status = self.0.poll_status().await?;
960 Ok(status.into())
961 }
962}
963
964#[derive(Debug, Clone, uniffi::Record)]
970pub struct RecoveryUpdateSignature {
971 pub signature: Vec<u8>,
974 pub nonce: Uint256,
977}
978
979#[derive(Debug, Clone, uniffi::Record)]
987pub struct RecoveryData {
988 pub authenticator_address: String,
990 pub authenticator_pubkey: String,
992 pub offchain_signer_commitment: String,
994}
995
996impl RecoveryData {
997 pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
1006 let signer = Signer::from_seed_bytes(seed)?;
1007 let authenticator_address = signer.onchain_signer_address().to_checksum(None);
1008 let authenticator_pubkey: U256 = signer
1009 .offchain_signer_pubkey()
1010 .to_ethereum_representation()?;
1011 let mut key_set = AuthenticatorPublicKeySet::default();
1012 key_set.try_push(signer.offchain_signer_pubkey())?;
1013 let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
1014
1015 Ok(Self {
1016 authenticator_address,
1017 authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
1018 offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1019 })
1020 }
1021}
1022
1023#[uniffi::export]
1043pub fn validate_authenticator_pubkey(
1044 authenticator_pubkey: &str,
1045) -> Result<String, WalletKitError> {
1046 let pubkey =
1047 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1048 let encoded = pubkey.to_ethereum_representation()?;
1049 Ok(format!("{encoded:#066x}"))
1050}
1051
1052#[uniffi::export]
1059#[allow(
1060 clippy::needless_pass_by_value,
1061 reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
1062)]
1063pub fn recovery_data_from_seed(seed: Vec<u8>) -> Result<RecoveryData, WalletKitError> {
1064 let seed = Zeroizing::new(seed);
1065 RecoveryData::from_seed(&seed)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 const GET_RECOVERY_COUNTER_SELECTOR: &[u8] = b"3a51ad3d";
1074 const TEST_SEED: [u8; 32] = [1u8; 32];
1075
1076 async fn test_authenticator(
1077 server: &mut mockito::Server,
1078 ) -> (Authenticator, std::path::PathBuf) {
1079 use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1080 use alloy::primitives::address;
1081 use world_id_core::primitives::ServiceEndpoint;
1082 use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1083
1084 let _ = rustls::crypto::ring::default_provider().install_default();
1085
1086 let packed_account_mock = server
1087 .mock("POST", "/packed-account")
1088 .with_status(200)
1089 .with_header("content-type", "application/json")
1090 .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1091 .create_async()
1092 .await;
1093 let config = Config::new(
1094 None,
1095 480,
1096 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1097 ServiceEndpoint::direct(server.url()),
1098 ServiceEndpoint::direct(server.url()),
1099 vec![],
1100 2,
1101 )
1102 .expect("valid config");
1103 let root = temp_root_path();
1104 let provider = InMemoryStorageProvider::new(&root);
1105 let store =
1106 CredentialStore::from_provider(&provider).expect("credential store");
1107 let authenticator = Authenticator::init_with_config(
1108 &TEST_SEED,
1109 config,
1110 Arc::new(DummyZkArtifactSource),
1111 Arc::new(store),
1112 )
1113 .await
1114 .expect("authenticator should initialize");
1115 packed_account_mock.assert_async().await;
1116
1117 (authenticator, root)
1118 }
1119
1120 fn encoded_pubkey(seed: &[u8; 32]) -> String {
1121 let pubkey = Signer::from_seed_bytes(seed)
1122 .expect("valid seed")
1123 .offchain_signer_pubkey()
1124 .to_ethereum_representation()
1125 .expect("public key should encode");
1126 format!("{pubkey:#066x}")
1127 }
1128
1129 async fn mock_authenticator_pubkeys(
1133 server: &mut mockito::Server,
1134 pubkeys: &[Option<&str>],
1135 expected_hits: usize,
1136 ) -> mockito::Mock {
1137 server
1138 .mock("POST", "/authenticator-pubkeys")
1139 .match_body(mockito::Matcher::JsonString(
1140 serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1141 ))
1142 .with_status(200)
1143 .with_header("content-type", "application/json")
1144 .with_body(
1145 serde_json::json!({
1146 "authenticator_pubkeys": pubkeys,
1147 "offchain_signer_commitment": "0x0"
1148 })
1149 .to_string(),
1150 )
1151 .expect(expected_hits)
1152 .create_async()
1153 .await
1154 }
1155
1156 #[test]
1157 fn test_recovery_data_from_seed() {
1158 let seed = [1u8; 32];
1159 let material = RecoveryData::from_seed(&seed).expect("should derive material");
1160
1161 assert!(material.authenticator_address.starts_with("0x"));
1162 assert_eq!(material.authenticator_address.len(), 42);
1163 assert!(material.authenticator_pubkey.starts_with("0x"));
1164 assert!(material.authenticator_pubkey.len() <= 66);
1165 assert!(material.offchain_signer_commitment.starts_with("0x"));
1166 assert!(material.offchain_signer_commitment.len() <= 66);
1167 assert!(material.authenticator_address.len() > 2);
1168 assert!(material.authenticator_pubkey.len() > 2);
1169 assert!(material.offchain_signer_commitment.len() > 2);
1170 }
1171
1172 #[test]
1173 fn test_recovery_data_rejects_invalid_seed() {
1174 assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1175 assert!(RecoveryData::from_seed(&[]).is_err());
1176 }
1177
1178 #[test]
1179 fn test_authenticator_pubkey_validation() {
1180 let canonical = encoded_pubkey(&[2u8; 32]);
1181 assert_eq!(
1182 validate_authenticator_pubkey(&canonical).expect("valid key"),
1183 canonical
1184 );
1185 let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1186 assert_eq!(
1187 validate_authenticator_pubkey(&uppercase)
1188 .expect("uppercase hex should canonicalize"),
1189 canonical
1190 );
1191
1192 for invalid_pubkey in [
1193 "not-a-public-key".to_string(),
1194 format!("0x{}", "ff".repeat(32)),
1195 ] {
1196 assert!(matches!(
1197 validate_authenticator_pubkey(&invalid_pubkey),
1198 Err(WalletKitError::InvalidInput { attribute, .. })
1199 if attribute == "authenticator_pubkey"
1200 ));
1201 }
1202
1203 let identity = format!("0x{}01", "0".repeat(62));
1204 assert!(matches!(
1205 validate_authenticator_pubkey(&identity),
1206 Err(WalletKitError::InvalidInput { attribute, reason })
1207 if attribute == "authenticator_pubkey" && reason.contains("identity")
1208 ));
1209 let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1210 assert!(matches!(
1211 validate_authenticator_pubkey(&sign_bit_alias),
1212 Err(WalletKitError::InvalidInput { attribute, reason })
1213 if attribute == "authenticator_pubkey" && reason.contains("canonical")
1214 ));
1215 }
1216
1217 #[tokio::test]
1218 async fn test_poll_status_normalizes_request_id() {
1219 use crate::storage::tests_utils::cleanup_test_storage;
1220
1221 let mut server = mockito::Server::new_async().await;
1222 let (authenticator, root) = test_authenticator(&mut server).await;
1223 let status_mock = server
1224 .mock("GET", "/status/gw_poll_test")
1225 .with_status(200)
1226 .with_header("content-type", "application/json")
1227 .with_body(
1228 serde_json::json!({
1229 "request_id": "gw_poll_test",
1230 "kind": "insert_authenticator",
1231 "status": {
1232 "state": "finalized",
1233 "tx_hash": "0x1234"
1234 }
1235 })
1236 .to_string(),
1237 )
1238 .expect(2)
1239 .create_async()
1240 .await;
1241
1242 for request_id in ["poll_test", "gw_poll_test"] {
1243 assert_eq!(
1244 authenticator
1245 .poll_status(request_id.to_string())
1246 .await
1247 .expect("status poll should succeed"),
1248 GatewayRequestStatus::Finalized {
1249 tx_hash: "0x1234".to_string()
1250 }
1251 );
1252 }
1253 status_mock.assert_async().await;
1254
1255 drop(server);
1256 cleanup_test_storage(&root);
1257 }
1258
1259 #[tokio::test]
1260 async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1261 use crate::storage::tests_utils::cleanup_test_storage;
1262
1263 let mut server = mockito::Server::new_async().await;
1264 let (authenticator, root) = test_authenticator(&mut server).await;
1265 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1266 let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1267
1268 let pubkeys_mock = mock_authenticator_pubkeys(
1269 &mut server,
1270 &[
1271 Some(existing_pubkey.as_str()),
1272 None,
1273 Some(slot_pubkey.as_str()),
1274 ],
1275 2,
1276 )
1277 .await;
1278 let nonce_mock = server
1279 .mock("POST", "/signature-nonce")
1280 .expect(0)
1281 .create_async()
1282 .await;
1283 let remove_mock = server
1284 .mock("POST", "/remove-authenticator")
1285 .expect(0)
1286 .create_async()
1287 .await;
1288
1289 let mismatched = authenticator
1290 .remove_authenticator(
1291 Address::ZERO.to_string(),
1292 2,
1293 encoded_pubkey(&[3u8; 32]),
1294 )
1295 .await;
1296 assert!(matches!(
1297 mismatched,
1298 Err(WalletKitError::InvalidInput { attribute, .. })
1299 if attribute == "expected_authenticator_pubkey"
1300 ));
1301
1302 let empty_slot = authenticator
1303 .remove_authenticator(
1304 Address::ZERO.to_string(),
1305 1,
1306 encoded_pubkey(&[3u8; 32]),
1307 )
1308 .await;
1309 assert!(matches!(
1310 empty_slot,
1311 Err(WalletKitError::InvalidInput { attribute, reason })
1312 if attribute == "pubkey_id"
1313 && reason.contains("no authenticator at key set slot 1")
1314 ));
1315
1316 let out_of_range = authenticator
1317 .remove_authenticator(
1318 Address::ZERO.to_string(),
1319 7,
1320 encoded_pubkey(&[3u8; 32]),
1321 )
1322 .await;
1323 assert!(matches!(
1324 out_of_range,
1325 Err(WalletKitError::InvalidInput { attribute, reason })
1326 if attribute == "pubkey_id" && reason.contains("out of range")
1327 ));
1328
1329 pubkeys_mock.assert_async().await;
1330 nonce_mock.assert_async().await;
1331 remove_mock.assert_async().await;
1332
1333 drop(server);
1334 cleanup_test_storage(&root);
1335 }
1336
1337 #[tokio::test]
1338 async fn test_key_set_reads_return_slots_and_membership() {
1339 use crate::storage::tests_utils::cleanup_test_storage;
1340
1341 let mut server = mockito::Server::new_async().await;
1342 let (authenticator, root) = test_authenticator(&mut server).await;
1343 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1344 let other_pubkey = encoded_pubkey(&[2u8; 32]);
1345
1346 let pubkeys_mock = mock_authenticator_pubkeys(
1347 &mut server,
1348 &[
1349 Some(existing_pubkey.as_str()),
1350 None,
1351 Some(other_pubkey.as_str()),
1352 ],
1353 3,
1354 )
1355 .await;
1356
1357 assert!(authenticator
1358 .has_authenticator_pubkey(existing_pubkey.clone())
1359 .await
1360 .expect("membership read should succeed"));
1361 assert!(!authenticator
1362 .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1363 .await
1364 .expect("absent key check should succeed"));
1365 assert_eq!(
1366 authenticator
1367 .get_authenticator_pubkeys()
1368 .await
1369 .expect("key set read should succeed"),
1370 vec![Some(existing_pubkey), None, Some(other_pubkey)]
1371 );
1372 pubkeys_mock.assert_async().await;
1373
1374 drop(server);
1375 cleanup_test_storage(&root);
1376 }
1377
1378 #[tokio::test]
1379 async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1380 use crate::storage::tests_utils::cleanup_test_storage;
1381 use std::sync::atomic::{AtomicUsize, Ordering};
1382
1383 let mut server = mockito::Server::new_async().await;
1384 let (authenticator, root) = test_authenticator(&mut server).await;
1385 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1386 let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1387
1388 let full_body = serde_json::json!({
1394 "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1395 "offchain_signer_commitment": "0x0"
1396 })
1397 .to_string();
1398 let emptied_body = serde_json::json!({
1399 "authenticator_pubkeys": [existing_pubkey],
1400 "offchain_signer_commitment": "0x0"
1401 })
1402 .to_string();
1403 let fetches = Arc::new(AtomicUsize::new(0));
1404 let fetches_in_mock = Arc::clone(&fetches);
1405 let pubkeys_mock = server
1406 .mock("POST", "/authenticator-pubkeys")
1407 .with_status(200)
1408 .with_header("content-type", "application/json")
1409 .with_body_from_request(move |_request| {
1410 if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1411 full_body.clone().into_bytes()
1412 } else {
1413 emptied_body.clone().into_bytes()
1414 }
1415 })
1416 .expect(2)
1417 .create_async()
1418 .await;
1419 let nonce_mock = server
1420 .mock("POST", "/signature-nonce")
1421 .with_status(200)
1422 .with_header("content-type", "application/json")
1423 .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1424 .create_async()
1425 .await;
1426 let remove_mock = server
1427 .mock("POST", "/remove-authenticator")
1428 .expect(0)
1429 .create_async()
1430 .await;
1431
1432 let raced = authenticator
1433 .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1434 .await;
1435 assert!(matches!(
1436 raced,
1437 Err(WalletKitError::InvalidInput { attribute, .. })
1438 if attribute == "pubkey_id"
1439 ));
1440
1441 pubkeys_mock.assert_async().await;
1442 nonce_mock.assert_async().await;
1443 remove_mock.assert_async().await;
1444
1445 drop(server);
1446 cleanup_test_storage(&root);
1447 }
1448
1449 #[cfg(feature = "embed-zkeys")]
1450 #[tokio::test]
1451 async fn test_init_with_config_and_materials() {
1452 use crate::{
1453 authenticator::artifacts::caching::CachingZkArtifacts,
1454 storage::tests_utils::{
1455 cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1456 },
1457 };
1458 use alloy::primitives::address;
1459 use world_id_core::primitives::{Config, ServiceEndpoint};
1460
1461 let _ = rustls::crypto::ring::default_provider().install_default();
1462
1463 let mut mock_server = mockito::Server::new_async().await;
1464 mock_server
1465 .mock("POST", "/")
1466 .with_status(200)
1467 .with_header("content-type", "application/json")
1468 .with_body_from_request(|request| {
1469 let result = if request
1470 .body()
1471 .expect("request body")
1472 .windows(GET_RECOVERY_COUNTER_SELECTOR.len())
1473 .any(|window| window == GET_RECOVERY_COUNTER_SELECTOR)
1474 {
1475 "0x0000000000000000000000000000000000000000000000000000000000000000"
1476 } else {
1477 "0x0000000000000000000000000000000000000000000000000000000000000001"
1478 };
1479 serde_json::json!({
1480 "jsonrpc": "2.0",
1481 "id": 1,
1482 "result": result
1483 })
1484 .to_string()
1485 .into_bytes()
1486 })
1487 .create_async()
1488 .await;
1489
1490 let config = Config::new(
1491 Some(mock_server.url()),
1492 480,
1493 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1494 ServiceEndpoint::direct(
1495 "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1496 ),
1497 ServiceEndpoint::direct(
1498 "https://gateway.id-infra.worldcoin.dev".to_string(),
1499 ),
1500 vec![],
1501 2,
1502 )
1503 .unwrap();
1504 let config = serde_json::to_string(&config).unwrap();
1505
1506 let root = temp_root_path();
1507 let provider = InMemoryStorageProvider::new(&root);
1508 let store = CredentialStore::from_provider(&provider).expect("store");
1509 store.init(42, 100).expect("init storage");
1510
1511 let artifacts =
1512 Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1513
1514 let _authenticator = Authenticator::init(
1515 [2u8; 32].to_vec(),
1516 &config,
1517 artifacts,
1518 Arc::new(store),
1519 )
1520 .await
1521 .unwrap();
1522 drop(mock_server);
1523
1524 cleanup_test_storage(&root);
1525 }
1526}