From c606ef351c25301bfc3c04455a6a477f039691b5 Mon Sep 17 00:00:00 2001 From: Mario Zupan Date: Tue, 16 Sep 2025 14:54:10 +0200 Subject: [PATCH] Use Proxy for Identity Proof URL Checks --- CHANGELOG.md | 4 + Cargo.toml | 2 +- .../src/external/identity_proof.rs | 169 ++++++++++++++---- .../src/service/identity_proof_service.rs | 113 ++++++++++-- .../bcr-ebill-wasm/src/api/identity_proof.rs | 13 +- 5 files changed, 244 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a506e8d..fe898790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.4.9 + +* Identity Proof now requests URLs via nostr-relay HTTP proxy + # 0.4.8 * Fix reject block propagation diff --git a/Cargo.toml b/Cargo.toml index c44bc6df..add508d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.4.8" +version = "0.4.9" edition = "2024" license = "MIT" diff --git a/crates/bcr-ebill-api/src/external/identity_proof.rs b/crates/bcr-ebill-api/src/external/identity_proof.rs index 86f14efe..caefab88 100644 --- a/crates/bcr-ebill-api/src/external/identity_proof.rs +++ b/crates/bcr-ebill-api/src/external/identity_proof.rs @@ -3,14 +3,19 @@ use bcr_ebill_core::{ ServiceTraitBounds, identity_proof::{IdentityProofStamp, IdentityProofStatus}, }; +use borsh_derive::BorshSerialize; use log::error; +use nostr::hashes::Hash; +use nostr::{hashes::sha256, nips::nip19::ToBech32}; +use secp256k1::{Keypair, Message, SECP256K1}; +use serde::Serialize; use thiserror::Error; use url::Url; #[cfg(test)] use mockall::automock; -use crate::util; +use crate::{external::file_storage::to_url, util}; /// Generic result type pub type Result = std::result::Result; @@ -30,6 +35,12 @@ pub enum Error { /// all errors originating from interacting with base58 #[error("External Identity Proof Validation error: {0}")] Validation(#[from] util::ValidationError), + /// all nostr key errors + #[error("External Identity Proof Nostr Key Error")] + NostrKey, + /// all borsh errors + #[error("External Identity Proof Borsh Error")] + Borsh(#[from] borsh::io::Error), } #[cfg_attr(test, automock)] @@ -37,9 +48,12 @@ pub enum Error { #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait IdentityProofApi: ServiceTraitBounds { /// Checks if the given identity proof somewhere in the (successful) response of calling the given URL + /// The request is proxied through the given relay and signed by the caller's private key async fn check_url( &self, + relay_url: &str, identity_proof_stamp: &IdentityProofStamp, + private_key: &nostr::SecretKey, url: &Url, ) -> IdentityProofStatus; } @@ -62,48 +76,64 @@ impl ServiceTraitBounds for IdentityProofClient {} #[cfg(test)] impl ServiceTraitBounds for MockIdentityProofApi {} +#[derive(Debug, Clone, Serialize)] +pub struct ProxyReq { + pub payload: ProxyReqPayload, + pub signature: String, +} + +#[derive(Debug, Clone, Serialize, BorshSerialize)] +pub struct ProxyReqPayload { + pub npub: String, + pub url: String, +} + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl IdentityProofApi for IdentityProofClient { async fn check_url( &self, + relay_url: &str, identity_proof: &IdentityProofStamp, + private_key: &nostr::SecretKey, url: &Url, ) -> IdentityProofStatus { - // Make an unauthenticated request to the given URL and retrieve its body - match self.cl.get(url.to_owned()).send().await { + let (proxy_url, proxy_req) = match create_proxy_req(relay_url, private_key, url) { + Ok(r) => r, + Err(e) => { + error!("Error creating proxy request for {url}: {e}"); + return IdentityProofStatus::FailureClient; + } + }; + // Call the Nostr relay's proxy function with a signed payload + match self.cl.post(proxy_url).json(&proxy_req).send().await { Ok(res) => { - match res.error_for_status() { - Ok(resp) => { - match resp.text().await { - Ok(body) => { - // Check if the identity proof is contained in the response - if identity_proof.is_contained_in(&body) { - IdentityProofStatus::Success - } else { - IdentityProofStatus::NotFound - } - } - Err(body_err) => { - error!("Error checking url: {url} for identity proof: {body_err}"); - IdentityProofStatus::FailureClient - } + let status = res.status(); + match res.text().await { + Ok(body) => { + if status.is_client_error() { + error!( + "Error checking url: {url} for identity proof: {status}, {body}" + ); + return IdentityProofStatus::FailureClient; + } else if status.is_server_error() { + error!( + "Error checking url: {url} for identity proof: {status}, {body}" + ); + return IdentityProofStatus::FailureServer; } - } - Err(e) => { - error!("Error checking url: {url} for identity proof: {e}"); - if let Some(status) = e.status() { - if status.is_client_error() { - IdentityProofStatus::FailureClient - } else if status.is_server_error() { - IdentityProofStatus::FailureServer - } else { - IdentityProofStatus::FailureConnect - } + + // Check if the identity proof is contained in the response + if identity_proof.is_contained_in(&body) { + IdentityProofStatus::Success } else { - IdentityProofStatus::FailureConnect + IdentityProofStatus::NotFound } } + Err(body_err) => { + error!("Error checking url: {url} for identity proof: {body_err}"); + IdentityProofStatus::FailureClient + } } } Err(req_err) => { @@ -114,13 +144,78 @@ impl IdentityProofApi for IdentityProofClient { } } +// Returns the relay URL to call and the request +fn create_proxy_req( + relay_url: &str, + private_key: &nostr::SecretKey, + url: &Url, +) -> Result<(Url, ProxyReq)> { + let npub = nostr::Keys::new(private_key.clone()) + .public_key() + .to_bech32() + .map_err(|_| Error::NostrKey)?; + + let payload = ProxyReqPayload { + npub, + url: url.to_string(), + }; + let key_pair = Keypair::from_secret_key(SECP256K1, private_key); + let serialized = borsh::to_vec(&payload).map_err(Error::Borsh)?; + let hash: sha256::Hash = sha256::Hash::hash(&serialized); + let msg = Message::from_digest(*hash.as_ref()); + + let signature = SECP256K1.sign_schnorr(&msg, &key_pair).to_string(); + Ok(( + to_url(relay_url, "proxy/v1/req")?, + ProxyReq { signature, payload }, + )) +} + #[cfg(test)] pub mod tests { use std::str::FromStr; - use crate::tests::tests::node_id_test; + use bcr_ebill_core::util::BcrKeys; + use bitcoin::XOnlyPublicKey; + use nostr::key::SecretKey; + use secp256k1::schnorr::Signature; + + use crate::tests::tests::{node_id_test, private_key_test}; use super::*; + pub fn verify_request(req: &Req, signature: &str, key: &XOnlyPublicKey) -> bool + where + Req: borsh::BorshSerialize, + { + let serialized = borsh::to_vec(&req).unwrap(); + let hash = sha256::Hash::hash(&serialized); + let msg = Message::from_digest(*hash.as_ref()); + let decoded_signature = Signature::from_str(signature).unwrap(); + + SECP256K1 + .verify_schnorr(&decoded_signature, &msg, key) + .is_ok() + } + + #[test] + fn sig_req_proxy_test() { + let relay_url = "wss://bcr-relay-dev.minibill.tech"; + let secret_key = + SecretKey::from_str("8863c82829480536893fc49c4b30e244f97261e989433373d73c648c1a656a79") + .unwrap(); + let x_only_pub = secret_key.public_key(SECP256K1).x_only_public_key().0; + let (proxy_url, proxy_req) = create_proxy_req(relay_url, &secret_key, &Url::parse("https://primal.net/e/nevent1qqs24kk3m0rc8e7a6f8k8daddqes0a2n74jszdszppu84e6y5q8ss3cy2rxs4").unwrap()).expect("creating proxy req works"); + + assert_eq!( + proxy_url, + Url::parse("https://bcr-relay-dev.minibill.tech/proxy/v1/req").unwrap() + ); + assert!(verify_request( + &proxy_req.payload, + &proxy_req.signature, + &x_only_pub + )); + } #[tokio::test] #[ignore] @@ -128,6 +223,12 @@ pub mod tests { // networks interact with the check_url() call. async fn test_check_url() { let node_id = node_id_test(); + let relay_url = "wss://bcr-relay-dev.minibill.tech"; + let private_key = BcrKeys::from_private_key(&private_key_test()) + .unwrap() + .get_nostr_keys() + .secret_key() + .to_owned(); let identity_proof_client = IdentityProofClient::new(); @@ -137,19 +238,19 @@ pub mod tests { let valid_url = Url::parse("https://primal.net/e/nevent1qqs24kk3m0rc8e7a6f8k8daddqes0a2n74jszdszppu84e6y5q8ss3cy2rxs4").unwrap(); let check_url_res = identity_proof_client - .check_url(&identity_proof, &valid_url) + .check_url(relay_url, &identity_proof, &private_key, &valid_url) .await; assert!(matches!(check_url_res, IdentityProofStatus::Success)); let not_found_url = Url::parse("https://primal.net/e/nevent1qqsv64erdk323pkpuzqspyk3e842egaeuu8v6js970tvnyjlkjakzqc0whefs").unwrap(); let check_url_res = identity_proof_client - .check_url(&identity_proof, ¬_found_url) + .check_url(relay_url, &identity_proof, &private_key, ¬_found_url) .await; assert!(matches!(check_url_res, IdentityProofStatus::NotFound)); let invalid_url = Url::parse("https://www.bit.cr/does-not-exist-ever").unwrap(); let check_url_res = identity_proof_client - .check_url(&identity_proof, &invalid_url) + .check_url(relay_url, &identity_proof, &private_key, &invalid_url) .await; assert!(matches!(check_url_res, IdentityProofStatus::FailureClient)); } diff --git a/crates/bcr-ebill-api/src/service/identity_proof_service.rs b/crates/bcr-ebill-api/src/service/identity_proof_service.rs index ae2d6c12..548e2312 100644 --- a/crates/bcr-ebill-api/src/service/identity_proof_service.rs +++ b/crates/bcr-ebill-api/src/service/identity_proof_service.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use async_trait::async_trait; use bcr_ebill_core::{ NodeId, ServiceTraitBounds, ValidationError, + contact::BillParticipant, identity_proof::{IdentityProof, IdentityProofStamp, IdentityProofStatus}, + util::BcrKeys, }; use bcr_ebill_persistence::identity_proof::IdentityProofStoreApi; use url::Url; @@ -19,14 +21,20 @@ pub trait IdentityProofServiceApi: ServiceTraitBounds { /// Adds a new identity proof for the given node id and Url async fn add( &self, - node_id: &NodeId, + signer_public_data: &BillParticipant, + signer_keys: &BcrKeys, url: &Url, stamp: &IdentityProofStamp, ) -> Result; /// Archives the identity proof for the given id async fn archive(&self, node_id: &NodeId, id: &str) -> Result<()>; /// Re-checks (via the URL) the identity proof for the given ID, persisting and returning the result - async fn re_check(&self, node_id: &NodeId, id: &str) -> Result; + async fn re_check( + &self, + signer_public_data: &BillParticipant, + signer_keys: &BcrKeys, + id: &str, + ) -> Result; } /// The identity proof service is responsible for managing identity proofs for local identities @@ -60,15 +68,33 @@ impl IdentityProofServiceApi for IdentityProofService { async fn add( &self, - node_id: &NodeId, + signer_public_data: &BillParticipant, + signer_keys: &BcrKeys, url: &Url, stamp: &IdentityProofStamp, ) -> Result { let now = util::date::now().timestamp() as u64; - if !stamp.verify_against_node_id(node_id) { + let node_id = signer_public_data.node_id(); + if !stamp.verify_against_node_id(&node_id) { return Err(Error::Validation(ValidationError::InvalidSignature)); } - let status = self.identity_proof_client.check_url(stamp, url).await; + + // TODO(multi-relay): don't default to first, but to default relay of receiver with this capability + let nostr_relay = match signer_public_data.nostr_relays().first() { + Some(r) => r.to_string(), + None => { + return Err(Error::Validation(ValidationError::InvalidRelayUrl)); + } + }; + let status = self + .identity_proof_client + .check_url( + &nostr_relay, + stamp, + signer_keys.get_nostr_keys().secret_key(), + url, + ) + .await; let checked = util::date::now().timestamp() as u64; // only add, if the check was successful @@ -79,7 +105,7 @@ impl IdentityProofServiceApi for IdentityProofService { } let identity_proof = IdentityProof { - node_id: node_id.to_owned(), + node_id, stamp: stamp.to_owned(), url: url.to_owned(), timestamp: now, @@ -107,17 +133,36 @@ impl IdentityProofServiceApi for IdentityProofService { } } - async fn re_check(&self, node_id: &NodeId, id: &str) -> Result { + async fn re_check( + &self, + signer_public_data: &BillParticipant, + signer_keys: &BcrKeys, + id: &str, + ) -> Result { match self.store.get_by_id(id).await? { Some(mut identity_proof) => { - if &identity_proof.node_id != node_id { + if identity_proof.node_id != signer_public_data.node_id() { // does not belong to the caller - can't re-check return Err(Error::NotFound); } + + // TODO(multi-relay): don't default to first, but to default relay of receiver with this capability + let nostr_relay = match signer_public_data.nostr_relays().first() { + Some(r) => r.to_string(), + None => { + return Err(Error::Validation(ValidationError::InvalidRelayUrl)); + } + }; + // re-check the status let status = self .identity_proof_client - .check_url(&identity_proof.stamp, &identity_proof.url) + .check_url( + &nostr_relay, + &identity_proof.stamp, + signer_keys.get_nostr_keys().secret_key(), + &identity_proof.url, + ) .await; let checked = util::date::now().timestamp() as u64; @@ -141,7 +186,8 @@ pub mod tests { use crate::{ external::identity_proof::MockIdentityProofApi, tests::tests::{ - MockIdentityProofStore, node_id_test, node_id_test_other, private_key_test, + MockIdentityProofStore, bill_identified_participant_only_node_id, node_id_test, + node_id_test_other, private_key_test, }, }; @@ -156,12 +202,16 @@ pub mod tests { .returning(|_, _, _| Ok(())); ctx.identity_proof_client .expect_check_url() - .returning(|_, _| IdentityProofStatus::Success); + .returning(|_, _, _, _| IdentityProofStatus::Success); let service = get_service(ctx); + let mut signer = bill_identified_participant_only_node_id(node_id_test()); + signer.nostr_relays = vec!["some relay".to_owned()]; + let res = service .add( - &node_id_test(), + &BillParticipant::Ident(signer), + &BcrKeys::from_private_key(&private_key_test()).unwrap(), &Url::parse("https://bit.cr/").expect("valid url"), &IdentityProofStamp::new(&node_id_test(), &private_key_test()).unwrap(), ) @@ -182,12 +232,15 @@ pub mod tests { .returning(|_, _, _| Ok(())); ctx.identity_proof_client .expect_check_url() - .returning(|_, _| IdentityProofStatus::NotFound); + .returning(|_, _, _, _| IdentityProofStatus::NotFound); let service = get_service(ctx); + let mut signer = bill_identified_participant_only_node_id(node_id_test()); + signer.nostr_relays = vec!["some relay".to_owned()]; let res = service .add( - &node_id_test(), + &BillParticipant::Ident(signer), + &BcrKeys::from_private_key(&private_key_test()).unwrap(), &Url::parse("https://bit.cr/").expect("valid url"), &IdentityProofStamp::new(&node_id_test(), &private_key_test()).unwrap(), ) @@ -272,10 +325,18 @@ pub mod tests { .returning(|_, _, _| Ok(())); ctx.identity_proof_client .expect_check_url() - .returning(|_, _| IdentityProofStatus::NotFound); + .returning(|_, _, _, _| IdentityProofStatus::NotFound); let service = get_service(ctx); + let mut signer = bill_identified_participant_only_node_id(node_id_test()); + signer.nostr_relays = vec!["some relay".to_owned()]; - let res = service.re_check(&node_id_test(), "some_id").await; + let res = service + .re_check( + &BillParticipant::Ident(signer), + &BcrKeys::from_private_key(&private_key_test()).unwrap(), + "some_id", + ) + .await; assert!(res.is_ok()); assert!(matches!( res.as_ref().unwrap().status, @@ -297,8 +358,16 @@ pub mod tests { })) }); let service = get_service(ctx); + let mut signer = bill_identified_participant_only_node_id(node_id_test()); + signer.nostr_relays = vec!["some relay".to_owned()]; - let res = service.re_check(&node_id_test(), "some_id").await; + let res = service + .re_check( + &BillParticipant::Ident(signer), + &BcrKeys::from_private_key(&private_key_test()).unwrap(), + "some_id", + ) + .await; assert!(res.is_err()); } @@ -307,8 +376,16 @@ pub mod tests { let mut ctx = get_ctx(); ctx.store.expect_get_by_id().returning(|_| Ok(None)); let service = get_service(ctx); + let mut signer = bill_identified_participant_only_node_id(node_id_test()); + signer.nostr_relays = vec!["some relay".to_owned()]; - let res = service.re_check(&node_id_test(), "some_id").await; + let res = service + .re_check( + &BillParticipant::Ident(signer), + &BcrKeys::from_private_key(&private_key_test()).unwrap(), + "some_id", + ) + .await; assert!(res.is_err()); } diff --git a/crates/bcr-ebill-wasm/src/api/identity_proof.rs b/crates/bcr-ebill-wasm/src/api/identity_proof.rs index f86d5538..d502b144 100644 --- a/crates/bcr-ebill-wasm/src/api/identity_proof.rs +++ b/crates/bcr-ebill-wasm/src/api/identity_proof.rs @@ -52,13 +52,18 @@ impl IdentityProof { /// Add identity proof for the currently selected identity #[wasm_bindgen(unchecked_return_type = "IdentityProofWeb")] pub async fn add(&self, url: &str, stamp: &str) -> Result { - let (signer_public_data, _) = get_signer_public_data_and_keys().await?; + let (signer_public_data, signer_keys) = get_signer_public_data_and_keys().await?; let parsed_url = Url::parse(url).map_err(|_| Error::Validation(ValidationError::InvalidUrl))?; let parsed_stamp = IdentityProofStamp::from_str(stamp)?; let identity_proof: IdentityProofWeb = get_ctx() .identity_proof_service - .add(&signer_public_data.node_id(), &parsed_url, &parsed_stamp) + .add( + &signer_public_data, + &signer_keys, + &parsed_url, + &parsed_stamp, + ) .await? .into(); let res = serde_wasm_bindgen::to_value(&identity_proof)?; @@ -80,10 +85,10 @@ impl IdentityProof { /// returning the new result #[wasm_bindgen(unchecked_return_type = "IdentityProofWeb")] pub async fn re_check(&self, id: &str) -> Result { - let (signer_public_data, _) = get_signer_public_data_and_keys().await?; + let (signer_public_data, signer_keys) = get_signer_public_data_and_keys().await?; let identity_proof: IdentityProofWeb = get_ctx() .identity_proof_service - .re_check(&signer_public_data.node_id(), id) + .re_check(&signer_public_data, &signer_keys, id) .await? .into(); let res = serde_wasm_bindgen::to_value(&identity_proof)?;