1use crate::authenticator::Authenticator;
16use crate::error::WalletKitError;
17use crate::issuers::pop_backend_client::ManageRecoveryBindingRequest;
18use crate::issuers::pop_backend_client::RecoveryBindingResponse;
19use crate::issuers::PopBackendClient;
20use crate::user_agent::UserAgentBuilder;
21use crate::Environment;
22use alloy_core::primitives::keccak256;
23use alloy_core::primitives::Address;
24use std::string::String;
25#[derive(Debug, PartialEq, Eq, uniffi::Record)]
27pub struct RecoveryBinding {
28 pub recovery_agent: Option<String>,
30 pub pending_recovery_agent: Option<String>,
32 pub execute_after: Option<String>,
34}
35
36impl From<RecoveryBindingResponse> for RecoveryBinding {
37 fn from(response: RecoveryBindingResponse) -> Self {
38 Self {
39 recovery_agent: response.recovery_agent,
40 pending_recovery_agent: response.pending_recovery_agent,
41 execute_after: response.execute_after,
42 }
43 }
44}
45
46#[derive(uniffi::Object)]
51pub struct RecoveryBindingManager {
52 pop_backend_client: PopBackendClient,
53}
54
55#[uniffi::export]
56impl RecoveryBindingManager {
57 #[uniffi::constructor]
63 pub fn new(
64 environment: &Environment,
65 user_agent_builder: &UserAgentBuilder,
66 ) -> Result<Self, WalletKitError> {
67 let base_url = match environment {
68 Environment::Staging => "https://app.stage.orb.worldcoin.org",
69 Environment::Production => "https://app.orb.worldcoin.org",
70 }
71 .to_string();
72 Self::new_with_base_url(base_url.as_str(), user_agent_builder)
73 }
74
75 #[uniffi::constructor]
81 pub fn new_with_base_url(
82 base_url: &str,
83 user_agent_builder: &UserAgentBuilder,
84 ) -> Result<Self, WalletKitError> {
85 let user_agent = user_agent_builder.build().to_string();
86 let pop_backend_client =
87 PopBackendClient::new(base_url.to_string(), user_agent);
88 Ok(Self { pop_backend_client })
89 }
90}
91
92#[uniffi::export(async_runtime = "tokio")]
93impl RecoveryBindingManager {
94 pub async fn bind_recovery_agent(
109 &self,
110 authenticator: &Authenticator,
111 sub: String,
112 recovery_agent_address: String,
113 ) -> Result<(), WalletKitError> {
114 let challenge = self.pop_backend_client.get_challenge().await?;
115 let leaf_index = authenticator.leaf_index();
116 let sig_recovery_update = authenticator
117 .danger_sign_initiate_recovery_agent_update(recovery_agent_address.clone())
118 .await?;
119 let request = ManageRecoveryBindingRequest {
120 sub,
121 leaf_index,
122 signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
123 nonce: sig_recovery_update.nonce.to_string(),
124 recovery_agent: recovery_agent_address.clone(),
125 };
126 let security_token = Self::generate_recovery_agent_security_token(
127 authenticator,
128 &request,
129 &challenge,
130 )?;
131
132 self.pop_backend_client
133 .bind_recovery_agent(request, security_token, challenge)
134 .await?;
135 Ok(())
136 }
137
138 pub async fn unbind_recovery_agent(
150 &self,
151 authenticator: &Authenticator,
152 sub: String,
153 ) -> Result<(), WalletKitError> {
154 let leaf_index = authenticator.leaf_index();
155 let recovery_agent = Address::ZERO.to_string();
156 let sig_recovery_update = authenticator
157 .danger_sign_initiate_recovery_agent_update(recovery_agent.clone())
158 .await?;
159 let request = ManageRecoveryBindingRequest {
160 sub,
161 leaf_index,
162 signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
163 nonce: sig_recovery_update.nonce.to_string(),
164 recovery_agent,
165 };
166 let challenge = self.pop_backend_client.get_challenge().await?;
167 let security_token = Self::generate_recovery_agent_security_token(
168 authenticator,
169 &request,
170 &challenge,
171 )?;
172 self.pop_backend_client
173 .unbind_recovery_agent(request, security_token, challenge)
174 .await?;
175 Ok(())
176 }
177
178 pub async fn get_recovery_binding(
189 &self,
190 leaf_index: u64,
191 ) -> Result<RecoveryBinding, WalletKitError> {
192 let recovery_binding = self
193 .pop_backend_client
194 .get_recovery_binding(leaf_index)
195 .await?;
196 Ok(recovery_binding.into())
197 }
198}
199
200impl RecoveryBindingManager {
201 fn generate_recovery_agent_security_token(
204 authenticator: &Authenticator,
205 request: &ManageRecoveryBindingRequest,
206 challenge: &str,
207 ) -> Result<String, WalletKitError> {
208 let message_bytes =
209 Self::create_bytes_to_sign(challenge, request.leaf_index, &request.sub)?;
210 let commitment = keccak256(&message_bytes);
211 let signature: Vec<u8> =
212 authenticator.danger_sign_challenge(commitment.as_slice())?;
213 Ok(format!("0x{}", hex::encode(signature)))
214 }
215
216 fn create_bytes_to_sign(
222 challenge: &str,
223 leaf_index: u64,
224 sub: &str,
225 ) -> Result<Vec<u8>, WalletKitError> {
226 let challenge_bytes =
227 hex::decode(challenge.trim_start_matches("0x")).map_err(|e| {
228 WalletKitError::Generic {
229 error: e.to_string(),
230 }
231 })?;
232
233 let leaf_index_bytes = leaf_index.to_be_bytes();
234
235 let sub_bytes = hex::decode(sub.trim_start_matches("0x")).map_err(|e| {
236 WalletKitError::Generic {
237 error: e.to_string(),
238 }
239 })?;
240
241 let mut concatenated = Vec::new();
242 concatenated.extend_from_slice(&challenge_bytes);
243 concatenated.extend_from_slice(&leaf_index_bytes);
244 concatenated.extend_from_slice(&sub_bytes);
245
246 Ok(concatenated)
247 }
248}
249
250#[cfg(test)]
251#[cfg(all(not(target_arch = "wasm32"), feature = "embed-zkeys"))]
252mod tests {
253 use super::*;
254 use crate::authenticator::Groth16Materials;
255 use crate::storage::cache_embedded_groth16_material;
256 use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
257 use crate::storage::CredentialStore;
258 use mockito::ServerGuard;
259 use std::sync::Arc;
260
261 #[tokio::test]
262 async fn test_recovery_agent_token_generator_success() {
263 let mut pop_api_server = mockito::Server::new_async().await;
264 let sub = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
265 .to_string();
266
267 let challenge_url_path = "/api/v1/challenge".to_string();
269 let challenge =
270 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
271 .to_string();
272 let challenge_mock = pop_api_server
273 .mock("GET", challenge_url_path.as_str())
274 .with_status(200)
275 .with_body(format!("{{\"challenge\": \"{challenge}\"}}"))
276 .create_async()
277 .await;
278
279 let url_path = "/api/v1/recovery-binding".to_string();
281 let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
282 let private_key =
283 "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
284 let private_key_bytes = hex::decode(private_key).unwrap();
285 let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
286 let rpc_url = mock_eth_server.url();
287 let authenticator =
288 create_test_authenticator(&private_key_bytes, rpc_url).await;
289 let leaf_index = authenticator.leaf_index();
290 let mock = pop_api_server
291 .mock("POST", url_path.as_str())
292 .match_header(
293 "X-Auth-Signature",
294 mockito::Matcher::Regex(".*".to_string()),
295 )
296 .match_header("X-Auth-Challenge", challenge.as_str())
297 .match_body(mockito::Matcher::PartialJson(serde_json::json!({
298 "sub": sub.as_str(),
299 "leafIndex": leaf_index,
300 "recoveryAgent": recovery_agent.as_str(),
301
302 })))
303 .with_status(201)
304 .with_body("{}")
305 .create_async()
306 .await;
307
308 let recovery_binding_manager = RecoveryBindingManager::new_with_base_url(
309 pop_api_server.url().as_str(),
310 &UserAgentBuilder::new().with_walletkit_segment(),
311 )
312 .unwrap();
313
314 let result = recovery_binding_manager
315 .bind_recovery_agent(&authenticator, sub.clone(), recovery_agent.clone())
316 .await;
317 assert!(
318 result.is_ok(),
319 "Expected success, but got error: {result:?}"
320 );
321 challenge_mock.assert_async().await;
322
323 mock.assert_async().await;
324 eth_mock.assert_async().await;
325 drop(pop_api_server);
326 drop(mock_eth_server);
327 }
328
329 #[tokio::test]
330 async fn test_recovery_bindings_signature() {
331 let sub = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
332 .to_string();
333 let challenge =
334 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
335 .to_string();
336 let private_key =
337 "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
338 let private_key_bytes = hex::decode(private_key).unwrap();
339 let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
340 let rpc_url = mock_eth_server.url();
341 let authenticator =
342 create_test_authenticator(&private_key_bytes, rpc_url).await;
343 let leaf_index = authenticator.leaf_index();
344 let message_bytes =
345 RecoveryBindingManager::create_bytes_to_sign(&challenge, leaf_index, &sub)
346 .unwrap();
347 log::info!("message_bytes: {:?}", hex::encode(message_bytes.clone()));
348 assert_eq!(hex::encode(message_bytes.clone()), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2000000000000002aabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
349 let signature = "0x01".to_string();
350 let nonce = "0x02".to_string();
351 let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
352 let request = ManageRecoveryBindingRequest {
353 sub: sub.clone(),
354 leaf_index,
355 signature: signature.clone(),
356 nonce: nonce.clone(),
357 recovery_agent: recovery_agent.clone(),
358 };
359 let security_token =
360 RecoveryBindingManager::generate_recovery_agent_security_token(
361 &authenticator,
362 &request,
363 &challenge,
364 )
365 .unwrap();
366
367 assert!(
368 !security_token.is_empty(),
369 "Expected success, but got error: {security_token:?}"
370 );
371 let expect_signature = "0x72ec312737276c94e3ac32ab1c393a63b9474480d3a9eb434b8bf6927b7222ef7eb1fea0812ff62a7fb144db9631751e505969162a9c590cabb27bf0bd5005581c";
372 assert_eq!(security_token, expect_signature);
373 eth_mock.assert_async().await;
374 drop(mock_eth_server);
375 }
376
377 async fn create_test_authenticator(seed: &[u8], rpc_url: String) -> Authenticator {
378 let _ = rustls::crypto::ring::default_provider().install_default();
379 let store = create_test_credential_store();
380 let paths = store.storage_paths().unwrap();
381 cache_embedded_groth16_material(&paths).expect("cache groth16 material");
382 let materials = Arc::new(
383 Groth16Materials::from_cache(Arc::new(paths.clone()))
384 .expect("load groth16 material"),
385 );
386
387 let authenticator = Authenticator::init_with_defaults(
388 seed,
389 Some(rpc_url.clone()),
390 &Environment::Staging,
391 None,
392 materials,
393 store.clone(),
394 )
395 .await
396 .unwrap();
397
398 authenticator
399 }
400
401 fn create_test_credential_store() -> Arc<CredentialStore> {
402 let root = temp_root_path();
403 let provider = InMemoryStorageProvider::new(&root);
404 Arc::new(
405 CredentialStore::from_provider(&provider).expect("create credential store"),
406 )
407 }
408
409 async fn create_mock_eth_server() -> (ServerGuard, mockito::Mock) {
410 let mut mock_eth_server = mockito::Server::new_async().await;
411 let mock = mock_eth_server
412 .mock("POST", "/")
413 .with_status(200)
414 .with_header("content-type", "application/json")
415 .with_body(
416 serde_json::json!({
417 "jsonrpc": "2.0",
418 "id": 1,
419 "result": "0x000000000000000000000000000000000000000000000000000000000000002a"
420 })
421 .to_string(),
422 )
423 .expect_at_least(1)
424 .expect_at_most(2)
425 .create_async()
426 .await;
427 (mock_eth_server, mock)
428 }
429}