Skip to content

Commit 1a93b4c

Browse files
authored
Identity Proof Checking API (#622)
1 parent 0a3bc94 commit 1a93b4c

File tree

5 files changed

+273
-3
lines changed

5 files changed

+273
-3
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
components: llvm-tools-preview
4040

4141
- name: Activate cache
42-
uses: Swatinem/rust-cache@v2.7.5
42+
uses: Swatinem/rust-cache@v2.8.0
4343

4444
- name: Install grcov
4545
uses: taiki-e/install-action@v2

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 0.4.6
2+
3+
* Add basic logic for implementing (social) identity proofs
4+
15
# 0.4.5
26

37
* Add handling for `RemoveSignatory` from company, which flags the company as not active

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace.package]
2-
version = "0.4.5"
2+
version = "0.4.6"
33
edition = "2024"
44
license = "MIT"
55

@@ -45,7 +45,7 @@ futures = { version = "0.3", default-features = false }
4545
anyhow = { version = "1", default-features = false }
4646
thiserror = { version = "2", default-features = false }
4747
uuid = { version = "1", default-features = false, features = ["v4", "js"] }
48-
bitcoin = { version = "0.32", default-features = false, features = ["serde"] }
48+
bitcoin = { version = "0.32.7", default-features = false, features = ["serde"] }
4949
bip39 = { version = "2.1", features = ["rand"] }
5050
ecies = { version = "0.2", default-features = false, features = ["pure"] }
5151
nostr = { version = "0.43" }
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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, &not_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+
}

crates/bcr-ebill-api/src/external/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod bitcoin;
22
pub mod email;
33
pub mod file_storage;
4+
pub mod identity_proof;
45
pub mod mint;
56
pub mod time;
67

@@ -28,4 +29,8 @@ pub enum Error {
2829
/// all errors originating from the external email API
2930
#[error("External EmailApi error: {0}")]
3031
ExternalEmailApi(#[from] email::Error),
32+
33+
/// all errors originating from the external identity proof API
34+
#[error("External Identity Proof error: {0}")]
35+
ExternalIdentityProofApi(#[from] identity_proof::Error),
3136
}

0 commit comments

Comments
 (0)