Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion walletkit-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ mod auth;
mod credential;
mod proof;
mod recovery_agent;
mod recovery_binding;
mod wallet;

use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use clap::{Parser, Subcommand};
use eyre::WrapErr as _;

use walletkit_core::storage::{cache_embedded_groth16_material, CredentialStore};
use walletkit_core::Authenticator;

Expand Down Expand Up @@ -104,6 +105,12 @@ pub enum Command {
#[command(subcommand)]
action: recovery_agent::RecoveryAgentCommand,
},
/// Recovery binding management.
#[command(name = "recovery-binding")]
RecoveryBinding {
#[command(subcommand)]
action: recovery_binding::RecoveryBindingCommand,
},
}

/// Resolves the wallet root directory, defaulting to `~/.walletkit`.
Expand Down Expand Up @@ -240,5 +247,9 @@ pub async fn run(cli: Cli) -> eyre::Result<()> {
Command::Credential { action } => credential::run(&cli, action).await,
Command::Proof { action } => proof::run(&cli, action).await,
Command::RecoveryAgent { action } => recovery_agent::run(&cli, action).await,
Command::RecoveryBinding { action } => {
let environment = resolve_environment(&cli)?;
recovery_binding::run(&cli, action, &environment).await
}
}
}
49 changes: 49 additions & 0 deletions walletkit-cli/src/commands/recovery_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//! `walletkit recovery-agent` subcommands — recovery agent management.

use clap::Subcommand;

use super::{init_authenticator, Cli};
use walletkit_core::issuers::RecoveryBindingManager;
use walletkit_core::Environment;

#[derive(Subcommand)]
pub enum RecoveryBindingCommand {
/// Register bindings for a recovery agent.
RegisterBindings {
sub: String,
},
UnregisterBindings {
sub: String,
},
}

pub async fn run(
cli: &Cli,
action: &RecoveryBindingCommand,
environment: &Environment,
) -> eyre::Result<()> {
let (authenticator, _store) = init_authenticator(cli).await?;

match action {
RecoveryBindingCommand::RegisterBindings { sub } => {
let recovery_agent_address = environment.poh_recovery_agent_address();
let recovery_binding_manager =
RecoveryBindingManager::new(environment).unwrap();
recovery_binding_manager
.bind_recovery_agent(
&authenticator,
sub.clone(),
recovery_agent_address.clone(),
)
.await?;
}
RecoveryBindingCommand::UnregisterBindings { sub } => {
let recovery_binding_manager =
RecoveryBindingManager::new(environment).unwrap();
recovery_binding_manager
.unbind_recovery_agent(&authenticator, sub.clone())
.await?;
}
}
Ok(())
}
34 changes: 34 additions & 0 deletions walletkit-core/src/issuers/pop_backend_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ pub struct ManageRecoveryBindingRequest {
/// The authenticator's leaf index in the World ID Merkle tree.
#[serde(rename = "leafIndex")]
pub leaf_index: u64,
/// The signature of the recovery agent update.
pub signature: String,
/// The nonce of the recovery agent update.
pub nonce: String,
/// The checksummed hex address of the recovery agent (e.g. `"0x1234…"`).
#[serde(rename = "recoveryAgent")]
pub recovery_agent: String,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -216,16 +223,23 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let url = server.url();

let recovery_agent = "0x1234567890abcdef".to_string();
let request = ManageRecoveryBindingRequest {
sub: "test-sub-123".to_string(),
leaf_index: 42,
signature: "0x1234567890abcdef".to_string(),
nonce: "0x1234567890abcdef1".to_string(),
recovery_agent: recovery_agent.clone(),
};

let mock = server
.mock("POST", "/api/v1/recovery-binding")
.match_body(mockito::Matcher::Json(serde_json::json!({
"sub": "test-sub-123",
"leafIndex": 42,
"signature": "0x1234567890abcdef",
"nonce": "0x1234567890abcdef1",
"recoveryAgent": "0x1234567890abcdef",
})))
.match_header("X-Auth-Signature", "security_token")
.match_header("X-Auth-Challenge", "challenge")
Expand Down Expand Up @@ -254,16 +268,23 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let url = server.url();

let recovery_agent = "0x1234567890abcdef".to_string();
let request = ManageRecoveryBindingRequest {
sub: "test-sub-123".to_string(),
leaf_index: 42,
signature: "0x1234567890abcdef".to_string(),
nonce: "0x1234567890abcdef1".to_string(),
recovery_agent: recovery_agent.clone(),
};

let mock = server
.mock("POST", "/api/v1/recovery-binding")
.match_body(mockito::Matcher::Json(serde_json::json!({
"sub": "test-sub-123",
"leafIndex": 42,
"signature": "0x1234567890abcdef",
"nonce": "0x1234567890abcdef1",
"recoveryAgent": "0x1234567890abcdef",
})))
.match_header("X-Auth-Signature", "security_token")
.match_header("X-Auth-Challenge", "challenge")
Expand Down Expand Up @@ -300,13 +321,19 @@ mod tests {
let request = ManageRecoveryBindingRequest {
sub: "test-sub-123".to_string(),
leaf_index: 42,
signature: "0x1234567890abcdef".to_string(),
nonce: "0x1234567890abcdef1".to_string(),
recovery_agent: "0x0000000000000000000000000000000000000000".to_string(),
};

let mock = server
.mock("DELETE", "/api/v1/recovery-binding")
.match_body(mockito::Matcher::Json(serde_json::json!({
"sub": "test-sub-123",
"leafIndex": 42,
"signature": "0x1234567890abcdef",
"nonce": "0x1234567890abcdef1",
"recoveryAgent": "0x0000000000000000000000000000000000000000",
})))
.match_header("X-Auth-Signature", "security_token")
.match_header("X-Auth-Challenge", "challenge")
Expand Down Expand Up @@ -335,16 +362,23 @@ mod tests {
let mut server = mockito::Server::new_async().await;
let url = server.url();

let recovery_agent = "0x0000000000000000000000000000000000000000".to_string();
let request = ManageRecoveryBindingRequest {
sub: "test-sub-123".to_string(),
leaf_index: 42,
signature: "0x1234567890abcdef".to_string(),
nonce: "0x1234567890abcdef1".to_string(),
recovery_agent: recovery_agent.clone(),
};

let mock = server
.mock("DELETE", "/api/v1/recovery-binding")
.match_body(mockito::Matcher::Json(serde_json::json!({
"sub": "test-sub-123",
"leafIndex": 42,
"signature": "0x1234567890abcdef",
"nonce": "0x1234567890abcdef1",
"recoveryAgent": "0x0000000000000000000000000000000000000000",
})))
.match_header("X-Auth-Signature", "security_token")
.match_header("X-Auth-Challenge", "challenge")
Expand Down
82 changes: 55 additions & 27 deletions walletkit-core/src/issuers/recovery_bindings_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use crate::issuers::pop_backend_client::ManageRecoveryBindingRequest;
use crate::issuers::PopBackendClient;
use crate::Environment;
use alloy_primitives::keccak256;
use alloy_primitives::Address;
use std::string::String;

/// Client for registering and unregistering recovery agents with the `PoP` backend.
///
Expand Down Expand Up @@ -64,8 +66,8 @@ impl RecoveryBindingManager {
/// # Arguments
///
/// * `authenticator` — The authenticator whose signing key authorizes the request.
/// * `leaf_index` — The authenticator's leaf index in the World ID Merkle tree.
/// * `sub` — Hex-encoded subject identifier of the recovery agent to register.
/// * `recovery_agent_address` — The checksummed hex address of the new recovery agent (e.g. `"0x1234…"`).
///
/// # Errors
///
Expand All @@ -74,11 +76,21 @@ impl RecoveryBindingManager {
pub async fn bind_recovery_agent(
&self,
authenticator: &Authenticator,
leaf_index: u64,
sub: String,
recovery_agent_address: String,
) -> Result<(), WalletKitError> {
let challenge = self.pop_backend_client.get_challenge().await?;
let request = ManageRecoveryBindingRequest { sub, leaf_index };
let leaf_index = authenticator.leaf_index();
let sig_recovery_update = authenticator
.danger_sign_initiate_recovery_agent_update(recovery_agent_address.clone())
.await?;
let request = ManageRecoveryBindingRequest {
sub,
leaf_index,
signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
nonce: sig_recovery_update.nonce.to_string(),
recovery_agent: recovery_agent_address.clone(),
};
let security_token = Self::generate_recovery_agent_security_token(
authenticator,
&request,
Expand All @@ -96,7 +108,6 @@ impl RecoveryBindingManager {
/// # Arguments
///
/// * `authenticator` — The authenticator whose signing key authorizes the request.
/// * `leaf_index` — The authenticator's leaf index in the World ID Merkle tree.
/// * `sub` — Hex-encoded subject identifier of the recovery agent to remove.
///
/// # Errors
Expand All @@ -106,10 +117,20 @@ impl RecoveryBindingManager {
pub async fn unbind_recovery_agent(
&self,
authenticator: &Authenticator,
leaf_index: u64,
sub: String,
) -> Result<(), WalletKitError> {
let request = ManageRecoveryBindingRequest { sub, leaf_index };
let leaf_index = authenticator.leaf_index();
let recovery_agent = Address::ZERO.to_string();
let sig_recovery_update = authenticator
.danger_sign_initiate_recovery_agent_update(recovery_agent.clone())
.await?;
let request = ManageRecoveryBindingRequest {
sub,
leaf_index,
signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
nonce: sig_recovery_update.nonce.to_string(),
recovery_agent,
};
let challenge = self.pop_backend_client.get_challenge().await?;
let security_token = Self::generate_recovery_agent_security_token(
authenticator,
Expand Down Expand Up @@ -206,7 +227,6 @@ mod tests {
#[tokio::test]
async fn test_recovery_agent_token_generator_success() {
let mut pop_api_server = mockito::Server::new_async().await;
let leaf_index = 42;
let sub = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
.to_string();

Expand All @@ -224,17 +244,27 @@ mod tests {

// Mock the recovery binding registration endpoint
let url_path = "/api/v1/recovery-binding".to_string();

let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
let private_key =
"d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
let private_key_bytes = hex::decode(private_key).unwrap();
let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
let rpc_url = mock_eth_server.url();
let authenticator =
create_test_authenticator(&private_key_bytes, rpc_url).await;
let leaf_index = authenticator.leaf_index();
let mock = pop_api_server
.mock("POST", url_path.as_str())
.match_header(
"X-Auth-Signature",
mockito::Matcher::Regex(".*".to_string()),
)
.match_header("X-Auth-Challenge", challenge.as_str())
.match_body(mockito::Matcher::Json(serde_json::json!({
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"sub": sub.as_str(),
"leafIndex": leaf_index,
"recoveryAgent": recovery_agent.as_str(),

})))
.with_status(201)
.with_body("{}")
Expand All @@ -245,16 +275,8 @@ mod tests {
RecoveryBindingManager::new_with_base_url(pop_api_server.url().as_str())
.unwrap();

let private_key =
"d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
let private_key_bytes = hex::decode(private_key).unwrap();
let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
let rpc_url = mock_eth_server.url();
let authenticator =
create_test_authenticator(&private_key_bytes, rpc_url).await;

let result = recovery_binding_manager
.bind_recovery_agent(&authenticator, leaf_index, sub.clone())
.bind_recovery_agent(&authenticator, sub.clone(), recovery_agent.clone())
.await;
assert!(
result.is_ok(),
Expand All @@ -270,7 +292,6 @@ mod tests {

#[tokio::test]
async fn test_recovery_bindings_signature() {
let leaf_index = 42;
let sub = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
.to_string();
let challenge =
Expand All @@ -279,29 +300,34 @@ mod tests {
let private_key =
"d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
let private_key_bytes = hex::decode(private_key).unwrap();

let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
let rpc_url = mock_eth_server.url();
let authenticator =
create_test_authenticator(&private_key_bytes, rpc_url).await;
let leaf_index = authenticator.leaf_index();
let message_bytes =
RecoveryBindingManager::create_bytes_to_sign(&challenge, leaf_index, &sub)
.unwrap();
log::info!("message_bytes: {:?}", hex::encode(message_bytes.clone()));
assert_eq!(hex::encode(message_bytes.clone()), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2000000000000002aabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");

let signature = "0x01".to_string();
let nonce = "0x02".to_string();
let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
let request = ManageRecoveryBindingRequest {
sub: sub.clone(),
leaf_index,
signature: signature.clone(),
nonce: nonce.clone(),
recovery_agent: recovery_agent.clone(),
};
let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
let rpc_url = mock_eth_server.url();

let authenticator =
create_test_authenticator(&private_key_bytes, rpc_url).await;
let security_token =
RecoveryBindingManager::generate_recovery_agent_security_token(
&authenticator,
&request,
&challenge,
)
.unwrap();

assert!(
!security_token.is_empty(),
"Expected success, but got error: {security_token:?}"
Expand Down Expand Up @@ -350,10 +376,12 @@ mod tests {
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": "0x0000000000000000000000000000000000000000000000000000000000000001"
"result": "0x000000000000000000000000000000000000000000000000000000000000002a"
})
.to_string(),
)
.expect_at_least(1)
.expect_at_most(2)
.create_async()
.await;
(mock_eth_server, mock)
Expand Down
Loading