|
| 1 | +use std::fmt; |
| 2 | +use std::str::FromStr; |
| 3 | + |
| 4 | +use async_trait::async_trait; |
| 5 | +use bcr_ebill_core::{NodeId, ServiceTraitBounds}; |
| 6 | +use log::error; |
| 7 | +use secp256k1::{SecretKey, schnorr::Signature}; |
| 8 | +use thiserror::Error; |
| 9 | +use url::Url; |
| 10 | + |
| 11 | +#[cfg(test)] |
| 12 | +use mockall::automock; |
| 13 | + |
| 14 | +use crate::util; |
| 15 | + |
| 16 | +/// Generic result type |
| 17 | +pub type Result<T> = std::result::Result<T, super::Error>; |
| 18 | + |
| 19 | +/// Generic error type |
| 20 | +#[derive(Debug, Error)] |
| 21 | +pub enum Error { |
| 22 | + /// all errors originating from secp256k1 |
| 23 | + #[error("External Identity Proof Secp256k1 error: {0}")] |
| 24 | + Secp256k1(#[from] secp256k1::Error), |
| 25 | + /// all errors originating from interacting with the web |
| 26 | + #[error("External Identity Proof Web error: {0}")] |
| 27 | + Api(#[from] reqwest::Error), |
| 28 | + /// all errors originating from interacting with cryptography |
| 29 | + #[error("External Identity Proof Crypto error: {0}")] |
| 30 | + Crypto(#[from] util::crypto::Error), |
| 31 | + /// all errors originating from interacting with base58 |
| 32 | + #[error("External Identity Proof Base58 error: {0}")] |
| 33 | + Base58(#[from] util::Error), |
| 34 | +} |
| 35 | + |
| 36 | +#[cfg_attr(test, automock)] |
| 37 | +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] |
| 38 | +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] |
| 39 | +pub trait IdentityProofApi: ServiceTraitBounds { |
| 40 | + /// Sign the base58 sha256 hash of the given node_id using the given keys and returns the resulting signature |
| 41 | + /// This is the string users are supposed to post on their social media |
| 42 | + fn create_identity_proof( |
| 43 | + &self, |
| 44 | + node_id: &NodeId, |
| 45 | + private_key: &SecretKey, |
| 46 | + ) -> Result<IdentityProof>; |
| 47 | + /// Verifies that the given node_id corresponds to the given identity proof |
| 48 | + fn verify_identity_proof( |
| 49 | + &self, |
| 50 | + node_id: &NodeId, |
| 51 | + identity_proof: &IdentityProof, |
| 52 | + ) -> Result<bool>; |
| 53 | + /// Checks if the given identity proof somewhere in the (successful) response of calling the given URL |
| 54 | + async fn check_url( |
| 55 | + &self, |
| 56 | + identity_proof: &IdentityProof, |
| 57 | + url: &Url, |
| 58 | + ) -> CheckIdentityProofResult; |
| 59 | +} |
| 60 | + |
| 61 | +#[derive(Debug, Clone, Default)] |
| 62 | +pub struct IdentityProofClient { |
| 63 | + cl: reqwest::Client, |
| 64 | +} |
| 65 | + |
| 66 | +impl IdentityProofClient { |
| 67 | + pub fn new() -> Self { |
| 68 | + Self { |
| 69 | + cl: reqwest::Client::new(), |
| 70 | + } |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +impl ServiceTraitBounds for IdentityProofClient {} |
| 75 | + |
| 76 | +#[cfg(test)] |
| 77 | +impl ServiceTraitBounds for MockIdentityProofApi {} |
| 78 | + |
| 79 | +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] |
| 80 | +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] |
| 81 | +impl IdentityProofApi for IdentityProofClient { |
| 82 | + fn create_identity_proof( |
| 83 | + &self, |
| 84 | + node_id: &NodeId, |
| 85 | + private_key: &SecretKey, |
| 86 | + ) -> Result<IdentityProof> { |
| 87 | + let hash = util::sha256_hash(node_id.to_string().as_bytes()); |
| 88 | + let signature = util::crypto::signature(&hash, private_key).map_err(Error::Crypto)?; |
| 89 | + Ok(IdentityProof::from_str(&signature)?) |
| 90 | + } |
| 91 | + |
| 92 | + fn verify_identity_proof( |
| 93 | + &self, |
| 94 | + node_id: &NodeId, |
| 95 | + identity_proof: &IdentityProof, |
| 96 | + ) -> Result<bool> { |
| 97 | + let hash = util::sha256_hash(node_id.to_string().as_bytes()); |
| 98 | + let verified = util::crypto::verify(&hash, &identity_proof.to_string(), &node_id.pub_key()) |
| 99 | + .map_err(Error::Crypto)?; |
| 100 | + Ok(verified) |
| 101 | + } |
| 102 | + |
| 103 | + async fn check_url( |
| 104 | + &self, |
| 105 | + identity_proof: &IdentityProof, |
| 106 | + url: &Url, |
| 107 | + ) -> CheckIdentityProofResult { |
| 108 | + // Make an unauthenticated request to the given URL and retrieve its body |
| 109 | + match self.cl.get(url.to_owned()).send().await { |
| 110 | + Ok(res) => { |
| 111 | + match res.error_for_status() { |
| 112 | + Ok(resp) => { |
| 113 | + match resp.text().await { |
| 114 | + Ok(body) => { |
| 115 | + // Check if the identity proof is contained in the response |
| 116 | + if identity_proof.is_contained_in(&body) { |
| 117 | + CheckIdentityProofResult::Success |
| 118 | + } else { |
| 119 | + CheckIdentityProofResult::NotFound |
| 120 | + } |
| 121 | + } |
| 122 | + Err(body_err) => { |
| 123 | + error!("Error checking url: {url} for identity proof: {body_err}"); |
| 124 | + CheckIdentityProofResult::FailureClient |
| 125 | + } |
| 126 | + } |
| 127 | + } |
| 128 | + Err(e) => { |
| 129 | + error!("Error checking url: {url} for identity proof: {e}"); |
| 130 | + if let Some(status) = e.status() { |
| 131 | + if status.is_client_error() { |
| 132 | + CheckIdentityProofResult::FailureClient |
| 133 | + } else if status.is_server_error() { |
| 134 | + CheckIdentityProofResult::FailureServer |
| 135 | + } else { |
| 136 | + CheckIdentityProofResult::FailureConnect |
| 137 | + } |
| 138 | + } else { |
| 139 | + CheckIdentityProofResult::FailureConnect |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + } |
| 144 | + Err(req_err) => { |
| 145 | + error!("Error checking url: {url} for identity proof: {req_err}"); |
| 146 | + CheckIdentityProofResult::FailureConnect |
| 147 | + } |
| 148 | + } |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +#[derive(Debug, Clone)] |
| 153 | +pub struct IdentityProof { |
| 154 | + inner: Signature, |
| 155 | +} |
| 156 | + |
| 157 | +impl IdentityProof { |
| 158 | + /// Checks if the identity proof signature string is within the given body of text |
| 159 | + pub fn is_contained_in(&self, body: &str) -> bool { |
| 160 | + let self_str = self.to_string(); |
| 161 | + body.contains(&self_str) |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +impl fmt::Display for IdentityProof { |
| 166 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 167 | + write!(f, "{}", util::base58_encode(&self.inner.serialize())) |
| 168 | + } |
| 169 | +} |
| 170 | + |
| 171 | +impl From<Signature> for IdentityProof { |
| 172 | + fn from(value: Signature) -> Self { |
| 173 | + Self { inner: value } |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +impl FromStr for IdentityProof { |
| 178 | + type Err = Error; |
| 179 | + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { |
| 180 | + Ok(Self { |
| 181 | + inner: Signature::from_slice(&util::base58_decode(s)?)?, |
| 182 | + }) |
| 183 | + } |
| 184 | +} |
| 185 | + |
| 186 | +#[derive(Debug, Clone)] |
| 187 | +pub enum CheckIdentityProofResult { |
| 188 | + /// The request succeeded and we found the signature we were looking for in the response |
| 189 | + Success, |
| 190 | + /// The request succeeded, but we didn't find the signature we were looking for in the response |
| 191 | + NotFound, |
| 192 | + /// The request failed with a connection error |
| 193 | + FailureConnect, |
| 194 | + /// The request failed with a client error (4xx) |
| 195 | + FailureClient, |
| 196 | + /// The request failed with a server error (5xx) |
| 197 | + FailureServer, |
| 198 | +} |
| 199 | + |
| 200 | +#[cfg(test)] |
| 201 | +pub mod tests { |
| 202 | + use crate::tests::tests::{node_id_test, private_key_test}; |
| 203 | + |
| 204 | + use super::*; |
| 205 | + |
| 206 | + #[test] |
| 207 | + fn test_create_and_verify() { |
| 208 | + let node_id = node_id_test(); |
| 209 | + let private_key = private_key_test(); |
| 210 | + |
| 211 | + let identity_proof_client = IdentityProofClient::new(); |
| 212 | + |
| 213 | + let identity_proof = identity_proof_client |
| 214 | + .create_identity_proof(&node_id, &private_key) |
| 215 | + .expect("can create identity proof"); |
| 216 | + assert!( |
| 217 | + identity_proof_client |
| 218 | + .verify_identity_proof(&node_id, &identity_proof) |
| 219 | + .expect("can verify identity proof") |
| 220 | + ); |
| 221 | + } |
| 222 | + |
| 223 | + #[tokio::test] |
| 224 | + #[ignore] |
| 225 | + // Ignored by default, since it makes an HTTP request - useful for testing how different social |
| 226 | + // networks interact with the check_url() call. |
| 227 | + async fn test_check_url() { |
| 228 | + let node_id = node_id_test(); |
| 229 | + |
| 230 | + let identity_proof_client = IdentityProofClient::new(); |
| 231 | + |
| 232 | + // is a valid identity proof |
| 233 | + let identity_proof = IdentityProof::from_str("2DmtcWtNk2hvXaBCUAng63Gn1VDBZEojMwoZWr2VqDL5LZNgszj26YT4Pj4MUSf5o4HSmdiAEENyuNQ5UEK7zG1p").expect("is valid"); |
| 234 | + assert!( |
| 235 | + identity_proof_client |
| 236 | + .verify_identity_proof(&node_id, &identity_proof) |
| 237 | + .expect("can verify identity proof") |
| 238 | + ); |
| 239 | + |
| 240 | + let valid_url = Url::parse("https://primal.net/e/nevent1qqs24kk3m0rc8e7a6f8k8daddqes0a2n74jszdszppu84e6y5q8ss3cy2rxs4").unwrap(); |
| 241 | + let check_url_res = identity_proof_client |
| 242 | + .check_url(&identity_proof, &valid_url) |
| 243 | + .await; |
| 244 | + assert!(matches!(check_url_res, CheckIdentityProofResult::Success)); |
| 245 | + |
| 246 | + let not_found_url = Url::parse("https://primal.net/e/nevent1qqsv64erdk323pkpuzqspyk3e842egaeuu8v6js970tvnyjlkjakzqc0whefs").unwrap(); |
| 247 | + let check_url_res = identity_proof_client |
| 248 | + .check_url(&identity_proof, ¬_found_url) |
| 249 | + .await; |
| 250 | + assert!(matches!(check_url_res, CheckIdentityProofResult::NotFound)); |
| 251 | + |
| 252 | + let invalid_url = Url::parse("https://www.bit.cr/does-not-exist-ever").unwrap(); |
| 253 | + let check_url_res = identity_proof_client |
| 254 | + .check_url(&identity_proof, &invalid_url) |
| 255 | + .await; |
| 256 | + assert!(matches!( |
| 257 | + check_url_res, |
| 258 | + CheckIdentityProofResult::FailureClient |
| 259 | + )); |
| 260 | + } |
| 261 | +} |
0 commit comments