Skip to main content

walletkit_core/issuers/
recovery_bindings_manager.rs

1//! Bindings for managing recovery agents via the Proof-of-Personhood (`PoP`) backend.
2//!
3//! A recovery agent is an entity authorized to initiate account recovery on behalf of
4//! a user. This module provides [`RecoveryBindingManager`], which handles the authenticated
5//! registration and removal of recovery agents through a challenge-response protocol
6//! secured by the authenticator's signing key.
7//!
8//! ## Protocol
9//!
10//! 1. Fetch a one-time challenge from the `PoP` backend.
11//! 2. Construct a commitment: `keccak256(challenge || leaf_index || sub)`.
12//! 3. Sign the commitment with the authenticator's key to produce a security token.
13//! 4. Submit the request with the signature and challenge as auth headers.
14
15use 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/// Represents a recovery binding.
26#[derive(Debug, PartialEq, Eq, uniffi::Record)]
27pub struct RecoveryBinding {
28    /// The hex address of the recovery agent (e.g. `"0x1234…"`).
29    pub recovery_agent: Option<String>,
30    /// The hex address of the pending recovery agent (e.g. `"0x1234…"`).
31    pub pending_recovery_agent: Option<String>,
32    /// The timestamp of the recovery agent update in seconds since the Unix epoch.
33    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/// Client for registering and unregistering recovery agents with the `PoP` backend.
47///
48/// Each instance is bound to a specific [`Environment`] (staging or production),
49/// which determines the backend URL used for all requests.
50#[derive(uniffi::Object)]
51pub struct RecoveryBindingManager {
52    pop_backend_client: PopBackendClient,
53}
54
55#[uniffi::export]
56impl RecoveryBindingManager {
57    /// Creates a new `RecoveryBindingManager` for the specified environment.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the HTTP client cannot be built.
62    #[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    /// Creates a new `RecoveryBindingManager` for the specified base URL and user agent.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the HTTP client cannot be built.
80    #[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    /// Registers a recovery agent for the given authenticator.
95    ///
96    /// # Arguments
97    ///
98    /// * `authenticator` — The authenticator whose signing key authorizes the request.
99    /// * `sub` — Hex-encoded subject identifier of the recovery agent to register.
100    /// * `recovery_agent_address` — The checksummed hex address of the new recovery agent (e.g. `"0x1234…"`).
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the challenge fetch, signing, or backend request fails,
105    /// or if the user is not eligible for recovery ([`WalletKitError::NotEligibleForRecovery`]).
106    /// or if the debug report is not found ([`WalletKitError::DebugReportNotFound`]).
107    /// or if any other unexpected error occurs ([`WalletKitError::NetworkError`]).
108    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    /// Removes a previously registered recovery agent.
139    ///
140    /// # Arguments
141    ///
142    /// * `authenticator` — The authenticator whose signing key authorizes the request.
143    /// * `sub` — Hex-encoded subject identifier of the recovery agent to remove.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the challenge fetch, signing, or backend request fails,
148    /// or if the account does not exist ([`WalletKitError::AccountDoesNotExist`]).
149    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    /// Fetches a recovery binding via `GET /api/v1/recovery-binding`.
179    ///
180    /// # Arguments
181    ///
182    /// * `leaf_index` — The authenticator's leaf index in the World ID Merkle tree.
183    /// # Errors
184    ///
185    /// * [`WalletKitError::NetworkError`] — non-success HTTP status.
186    /// * [`WalletKitError::SerializationError`] — response body is not valid JSON.
187    /// * [`WalletKitError::RecoveryBindingDoesNotExist`] — HTTP 404 (no binding found).
188    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    /// Builds a hex-encoded security token by signing `keccak256(challenge || leaf_index || sub)`
202    /// with the authenticator's key.
203    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    /// Assembles the byte payload `challenge || leaf_index || sub` used as the
217    /// pre-image for the keccak256 commitment.
218    ///
219    /// Both `challenge` and `sub` are expected as hex strings (with optional `0x` prefix).
220    /// `leaf_index` is encoded as 8 big-endian bytes.
221    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        // Mock the challenge endpoint
268        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        // Mock the recovery binding registration endpoint
280        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}