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(
110 &self,
111 authenticator: &Authenticator,
112 sub: String,
113 recovery_agent_address: String,
114 ) -> Result<(), WalletKitError> {
115 let challenge = self.pop_backend_client.get_challenge().await?;
116 let leaf_index = authenticator.leaf_index();
117 let sig_recovery_update = authenticator
118 .danger_sign_initiate_recovery_agent_update(recovery_agent_address.clone())
119 .await?;
120 let request = ManageRecoveryBindingRequest {
121 sub,
122 leaf_index,
123 signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
124 nonce: sig_recovery_update.nonce.to_string(),
125 recovery_agent: recovery_agent_address.clone(),
126 };
127 let security_token = Self::generate_recovery_agent_security_token(
128 authenticator,
129 &request,
130 &challenge,
131 )?;
132
133 self.pop_backend_client
134 .bind_recovery_agent(request, security_token, challenge)
135 .await?;
136 Ok(())
137 }
138
139 pub async fn unbind_recovery_agent(
151 &self,
152 authenticator: &Authenticator,
153 sub: String,
154 ) -> Result<(), WalletKitError> {
155 let leaf_index = authenticator.leaf_index();
156 let recovery_agent = Address::ZERO.to_string();
157 let sig_recovery_update = authenticator
158 .danger_sign_initiate_recovery_agent_update(recovery_agent.clone())
159 .await?;
160 let request = ManageRecoveryBindingRequest {
161 sub,
162 leaf_index,
163 signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
164 nonce: sig_recovery_update.nonce.to_string(),
165 recovery_agent,
166 };
167 let challenge = self.pop_backend_client.get_challenge().await?;
168 let security_token = Self::generate_recovery_agent_security_token(
169 authenticator,
170 &request,
171 &challenge,
172 )?;
173 self.pop_backend_client
174 .unbind_recovery_agent(request, security_token, challenge)
175 .await?;
176 Ok(())
177 }
178
179 pub async fn get_recovery_binding(
190 &self,
191 leaf_index: u64,
192 ) -> Result<RecoveryBinding, WalletKitError> {
193 let recovery_binding = self
194 .pop_backend_client
195 .get_recovery_binding(leaf_index)
196 .await?;
197 Ok(recovery_binding.into())
198 }
199}
200
201impl RecoveryBindingManager {
202 fn generate_recovery_agent_security_token(
205 authenticator: &Authenticator,
206 request: &ManageRecoveryBindingRequest,
207 challenge: &str,
208 ) -> Result<String, WalletKitError> {
209 let message_bytes =
210 Self::create_bytes_to_sign(challenge, request.leaf_index, &request.sub)?;
211 let commitment = keccak256(&message_bytes);
212 let signature: Vec<u8> =
213 authenticator.danger_sign_challenge(commitment.to_vec())?;
214 Ok(format!("0x{}", hex::encode(signature)))
215 }
216
217 fn create_bytes_to_sign(
223 challenge: &str,
224 leaf_index: u64,
225 sub: &str,
226 ) -> Result<Vec<u8>, WalletKitError> {
227 let challenge_bytes =
228 hex::decode(challenge.trim_start_matches("0x")).map_err(|e| {
229 WalletKitError::Generic {
230 error: e.to_string(),
231 }
232 })?;
233
234 let leaf_index_bytes = leaf_index.to_be_bytes();
235
236 let sub_bytes = hex::decode(sub.trim_start_matches("0x")).map_err(|e| {
237 WalletKitError::Generic {
238 error: e.to_string(),
239 }
240 })?;
241
242 let mut concatenated = Vec::new();
243 concatenated.extend_from_slice(&challenge_bytes);
244 concatenated.extend_from_slice(&leaf_index_bytes);
245 concatenated.extend_from_slice(&sub_bytes);
246
247 Ok(concatenated)
248 }
249}
250
251#[cfg(test)]
252#[cfg(all(not(target_arch = "wasm32"), feature = "embed-zkeys"))]
253mod tests {
254 use super::*;
255 use crate::authenticator::artifacts::caching::CachingZkArtifacts;
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 const GET_RECOVERY_COUNTER_SELECTOR: &[u8] = b"3a51ad3d";
263
264 #[tokio::test]
265 async fn test_recovery_agent_token_generator_success() {
266 let mut pop_api_server = mockito::Server::new_async().await;
267 let sub = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
268 .to_string();
269
270 let challenge_url_path = "/api/v1/challenge".to_string();
272 let challenge =
273 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
274 .to_string();
275 let challenge_mock = pop_api_server
276 .mock("GET", challenge_url_path.as_str())
277 .with_status(200)
278 .with_body(format!("{{\"challenge\": \"{challenge}\"}}"))
279 .create_async()
280 .await;
281
282 let url_path = "/api/v1/recovery-binding".to_string();
284 let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
285 let private_key =
286 "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
287 let private_key_bytes = hex::decode(private_key).unwrap();
288 let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
289 let rpc_url = mock_eth_server.url();
290 let authenticator =
291 create_test_authenticator(&private_key_bytes, rpc_url).await;
292 let leaf_index = authenticator.leaf_index();
293 let mock = pop_api_server
294 .mock("POST", url_path.as_str())
295 .match_header(
296 "X-Auth-Signature",
297 mockito::Matcher::Regex(".*".to_string()),
298 )
299 .match_header("X-Auth-Challenge", challenge.as_str())
300 .match_body(mockito::Matcher::PartialJson(serde_json::json!({
301 "sub": sub.as_str(),
302 "leafIndex": leaf_index,
303 "recoveryAgent": recovery_agent.as_str(),
304
305 })))
306 .with_status(201)
307 .with_body("{}")
308 .create_async()
309 .await;
310
311 let recovery_binding_manager = RecoveryBindingManager::new_with_base_url(
312 pop_api_server.url().as_str(),
313 &UserAgentBuilder::new().with_walletkit_segment(),
314 )
315 .unwrap();
316
317 let result = recovery_binding_manager
318 .bind_recovery_agent(&authenticator, sub.clone(), recovery_agent.clone())
319 .await;
320 assert!(
321 result.is_ok(),
322 "Expected success, but got error: {result:?}"
323 );
324 challenge_mock.assert_async().await;
325
326 mock.assert_async().await;
327 eth_mock.assert_async().await;
328 drop(pop_api_server);
329 drop(mock_eth_server);
330 }
331
332 #[tokio::test]
333 async fn test_recovery_bindings_signature() {
334 let sub = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
335 .to_string();
336 let challenge =
337 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
338 .to_string();
339 let private_key =
340 "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
341 let private_key_bytes = hex::decode(private_key).unwrap();
342 let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
343 let rpc_url = mock_eth_server.url();
344 let authenticator =
345 create_test_authenticator(&private_key_bytes, rpc_url).await;
346 let leaf_index = authenticator.leaf_index();
347 let message_bytes =
348 RecoveryBindingManager::create_bytes_to_sign(&challenge, leaf_index, &sub)
349 .unwrap();
350 log::info!("message_bytes: {:?}", hex::encode(message_bytes.clone()));
351 assert_eq!(hex::encode(message_bytes.clone()), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2000000000000002aabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
352 let signature = "0x01".to_string();
353 let nonce = "0x02".to_string();
354 let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
355 let request = ManageRecoveryBindingRequest {
356 sub: sub.clone(),
357 leaf_index,
358 signature: signature.clone(),
359 nonce: nonce.clone(),
360 recovery_agent: recovery_agent.clone(),
361 };
362 let security_token =
363 RecoveryBindingManager::generate_recovery_agent_security_token(
364 &authenticator,
365 &request,
366 &challenge,
367 )
368 .unwrap();
369
370 assert!(
371 !security_token.is_empty(),
372 "Expected success, but got error: {security_token:?}"
373 );
374 let expect_signature = "0x72ec312737276c94e3ac32ab1c393a63b9474480d3a9eb434b8bf6927b7222ef7eb1fea0812ff62a7fb144db9631751e505969162a9c590cabb27bf0bd5005581c";
375 assert_eq!(security_token, expect_signature);
376 eth_mock.assert_async().await;
377 drop(mock_eth_server);
378 }
379
380 async fn create_test_authenticator(seed: &[u8], rpc_url: String) -> Authenticator {
381 let _ = rustls::crypto::ring::default_provider().install_default();
382 let store = create_test_credential_store();
383
384 let artifacts =
385 Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
386
387 let authenticator = Authenticator::init_with_defaults(
388 seed.to_vec(),
389 Some(rpc_url.clone()),
390 &Environment::Staging,
391 None,
392 artifacts,
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_from_request(|request| {
416 let result = if request
417 .body()
418 .expect("request body")
419 .windows(GET_RECOVERY_COUNTER_SELECTOR.len())
420 .any(|window| window == GET_RECOVERY_COUNTER_SELECTOR)
421 {
422 "0x0000000000000000000000000000000000000000000000000000000000000000"
423 } else {
424 "0x000000000000000000000000000000000000000000000000000000000000002a"
425 };
426 serde_json::json!({
427 "jsonrpc": "2.0",
428 "id": 1,
429 "result": result
430 })
431 .to_string()
432 .into_bytes()
433 })
434 .expect_at_least(1)
435 .expect_at_most(3)
436 .create_async()
437 .await;
438 (mock_eth_server, mock)
439 }
440}