Skip to content

Commit f6efdab

Browse files
committed
Protect metadata with Oblivious HTTP
1 parent 5deba12 commit f6efdab

File tree

11 files changed

+839
-210
lines changed

11 files changed

+839
-210
lines changed

Cargo.lock

Lines changed: 395 additions & 36 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

payjoin-cli/src/app.rs

Lines changed: 80 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ use clap::ArgMatches;
1111
use config::{Config, File, FileFormat};
1212
use payjoin::bitcoin::psbt::Psbt;
1313
use payjoin::bitcoin::{self, base64};
14-
use payjoin::receive::{Error, PayjoinProposal, ProvisionalProposal, UncheckedProposal};
14+
use payjoin::receive::{
15+
EnrollContext, Error, PayjoinProposal, ProvisionalProposal, UncheckedProposal,
16+
};
17+
use payjoin::send::RequestContext;
1518
#[cfg(not(feature = "v2"))]
1619
use rouille::{Request, Response};
1720
use serde::{Deserialize, Serialize};
@@ -44,61 +47,61 @@ impl App {
4447

4548
#[cfg(feature = "v2")]
4649
pub async fn send_payjoin(&self, bip21: &str) -> Result<()> {
47-
let (req, ctx) = self.create_pj_request(bip21)?;
50+
// TODO extract requests inside poll loop for unique OHTTP payloads
51+
let req_ctx = self.create_pj_request(bip21)?;
4852

4953
let client = reqwest::Client::builder()
5054
.danger_accept_invalid_certs(self.config.danger_accept_invalid_certs)
5155
.build()
5256
.with_context(|| "Failed to build reqwest http client")?;
5357

5458
log::debug!("Awaiting response");
55-
let res = Self::long_poll_post(&client, req).await?;
56-
let mut res = std::io::Cursor::new(&res);
57-
self.process_pj_response(ctx, &mut res)?;
59+
let res = self.long_poll_post(&client, req_ctx).await?;
60+
self.process_pj_response(res)?;
5861
Ok(())
5962
}
6063

6164
#[cfg(feature = "v2")]
6265
async fn long_poll_post(
66+
&self,
6367
client: &reqwest::Client,
64-
req: payjoin::send::Request,
65-
) -> Result<Vec<u8>, reqwest::Error> {
68+
req_ctx: payjoin::send::RequestContext<'_>,
69+
) -> Result<Psbt> {
6670
loop {
71+
let (req, ctx) = req_ctx.extract_v2(&self.config.ohttp_proxy)?;
6772
let response = client
68-
.post(req.url.as_str())
73+
.post(req.url)
6974
.body(req.body.clone())
7075
.header("Content-Type", "text/plain")
7176
.send()
7277
.await?;
73-
74-
if response.status() == reqwest::StatusCode::OK {
75-
let body = response.bytes().await?.to_vec();
76-
return Ok(body);
77-
} else if response.status() == reqwest::StatusCode::ACCEPTED {
78+
let bytes = response.bytes().await?;
79+
let mut cursor = std::io::Cursor::new(bytes);
80+
let psbt = ctx.process_response(&mut cursor)?;
81+
if let Some(psbt) = psbt {
82+
return Ok(psbt);
83+
} else {
7884
log::info!("No response yet for POST payjoin request, retrying some seconds");
7985
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
80-
} else {
81-
log::error!("Unexpected response status: {}", response.status());
82-
// TODO handle error
83-
panic!("Unexpected response status: {}", response.status())
8486
}
8587
}
8688
}
8789

8890
#[cfg(feature = "v2")]
89-
async fn long_poll_get(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, reqwest::Error> {
91+
async fn long_poll_get(
92+
&self,
93+
client: &reqwest::Client,
94+
enroll_context: &mut EnrollContext,
95+
) -> Result<UncheckedProposal, reqwest::Error> {
9096
loop {
91-
let response = client.get(url).send().await?;
92-
93-
if response.status().is_success() {
94-
let body = response.bytes().await?;
95-
if !body.is_empty() {
96-
return Ok(body.to_vec());
97-
} else {
98-
log::info!("No response yet for GET payjoin request, retrying in 5 seconds");
99-
}
100-
101-
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
97+
let (enroll_body, context) = enroll_context.enroll_body();
98+
let ohttp_response =
99+
client.post(&self.config.ohttp_proxy).body(enroll_body).send().await?;
100+
let ohttp_response = ohttp_response.bytes().await?;
101+
let proposal = enroll_context.parse_proposal(ohttp_response.as_ref(), context).unwrap();
102+
match proposal {
103+
Some(proposal) => return Ok(proposal),
104+
None => tokio::time::sleep(std::time::Duration::from_secs(5)).await,
102105
}
103106
}
104107
}
@@ -122,10 +125,7 @@ impl App {
122125
Ok(())
123126
}
124127

125-
fn create_pj_request(
126-
&self,
127-
bip21: &str,
128-
) -> Result<(payjoin::send::Request, payjoin::send::Context)> {
128+
fn create_pj_request<'a>(&self, bip21: &'a str) -> Result<RequestContext<'a>> {
129129
let uri = payjoin::Uri::try_from(bip21)
130130
.map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?;
131131

@@ -167,22 +167,16 @@ impl App {
167167
.psbt;
168168
let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?;
169169
log::debug!("Original psbt: {:#?}", psbt);
170-
171-
let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri)
170+
let req_ctx = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri)
172171
.with_context(|| "Failed to build payjoin request")?
173172
.build_recommended(fee_rate)
174173
.with_context(|| "Failed to build payjoin request")?;
175174

176-
Ok((req, ctx))
175+
Ok(req_ctx)
177176
}
178177

179-
fn process_pj_response(
180-
&self,
181-
ctx: payjoin::send::Context,
182-
response: &mut impl std::io::Read,
183-
) -> Result<bitcoin::Txid> {
178+
fn process_pj_response(&self, psbt: Psbt) -> Result<bitcoin::Txid> {
184179
// TODO display well-known errors and log::debug the rest
185-
let psbt = ctx.process_response(response).with_context(|| "Failed to process response")?;
186180
log::debug!("Proposed psbt: {:#?}", psbt);
187181
let psbt = self
188182
.bitcoind
@@ -218,7 +212,11 @@ impl App {
218212

219213
#[cfg(feature = "v2")]
220214
pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> {
221-
let context = payjoin::receive::ProposalContext::new();
215+
let mut context = EnrollContext::from_relay_config(
216+
&self.config.pj_endpoint,
217+
&self.config.ohttp_config,
218+
&self.config.ohttp_proxy,
219+
);
222220
let pj_uri_string =
223221
self.construct_payjoin_uri(amount_arg, Some(&context.subdirectory()))?;
224222
println!(
@@ -231,25 +229,26 @@ impl App {
231229
.danger_accept_invalid_certs(self.config.danger_accept_invalid_certs)
232230
.build()
233231
.with_context(|| "Failed to build reqwest http client")?;
234-
log::debug!("Awaiting request");
235-
let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir());
236-
let mut buffer = Self::long_poll_get(&client, &receive_endpoint).await?;
232+
log::debug!("Awaiting proposal");
233+
let proposal = self.long_poll_get(&client, &mut context).await?;
237234

238-
log::debug!("Received request");
239-
let proposal = context
240-
.parse_proposal(&mut buffer)
241-
.map_err(|e| anyhow!("Failed to parse into UncheckedProposal {}", e))?;
235+
log::debug!("Received proposal");
242236
let payjoin_proposal = self
243237
.process_proposal(proposal)
244238
.map_err(|e| anyhow!("Failed to process UncheckedProposal {}", e))?;
245239

246-
let body = payjoin_proposal.serialize_body();
247-
let _ = client
248-
.post(receive_endpoint)
240+
let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir());
241+
let (body, ohttp_ctx) =
242+
payjoin_proposal.extract_v2_req(&self.config.ohttp_config, &receive_endpoint);
243+
let res = client
244+
.post(&self.config.ohttp_proxy)
249245
.body(body)
250246
.send()
251247
.await
252248
.with_context(|| "HTTP request failed")?;
249+
let res = res.bytes().await?;
250+
let res = payjoin_proposal.deserialize_res(res.to_vec(), ohttp_ctx);
251+
log::debug!("Received response {:?}", res);
253252
Ok(())
254253
}
255254

@@ -286,14 +285,15 @@ impl App {
286285
let amount = Amount::from_sat(amount_arg.parse()?);
287286
//let subdir = self.config.pj_endpoint + pubkey.map_or(&String::from(""), |s| &format!("/{}", s));
288287
let pj_uri_string = format!(
289-
"{}?amount={}&pj={}",
288+
"{}?amount={}&pj={}&ohttp={}",
290289
pj_receiver_address.to_qr_uri(),
291290
amount.to_btc(),
292291
format!(
293292
"{}{}",
294293
self.config.pj_endpoint,
295294
pubkey.map_or(String::from(""), |s| format!("/{}", s))
296-
)
295+
),
296+
self.config.ohttp_config,
297297
);
298298

299299
// check validity
@@ -472,6 +472,19 @@ impl App {
472472
}
473473
}
474474

475+
fn serialize_request_to_bytes(req: reqwest::Request) -> Vec<u8> {
476+
let mut serialized_request =
477+
format!("{} {} HTTP/1.1\r\n", req.method(), req.url()).into_bytes();
478+
479+
for (name, value) in req.headers().iter() {
480+
let header_line = format!("{}: {}\r\n", name.as_str(), value.to_str().unwrap());
481+
serialized_request.extend(header_line.as_bytes());
482+
}
483+
484+
serialized_request.extend(b"\r\n");
485+
serialized_request
486+
}
487+
475488
struct SeenInputs {
476489
set: OutPointSet,
477490
file: std::fs::File,
@@ -511,6 +524,8 @@ pub(crate) struct AppConfig {
511524
pub bitcoind_cookie: Option<String>,
512525
pub bitcoind_rpcuser: String,
513526
pub bitcoind_rpcpass: String,
527+
pub ohttp_config: String,
528+
pub ohttp_proxy: String,
514529

515530
// send-only
516531
pub danger_accept_invalid_certs: bool,
@@ -544,6 +559,16 @@ impl AppConfig {
544559
"bitcoind_rpcpass",
545560
matches.get_one::<String>("rpcpass").map(|s| s.as_str()),
546561
)?
562+
.set_default("ohttp_config", "")?
563+
.set_override_option(
564+
"ohttp_config",
565+
matches.get_one::<String>("ohttp_config").map(|s| s.as_str()),
566+
)?
567+
.set_default("ohttp_proxy", "")?
568+
.set_override_option(
569+
"ohttp_proxy",
570+
matches.get_one::<String>("ohttp_proxy").map(|s| s.as_str()),
571+
)?
547572
// Subcommand defaults without which file serialization fails.
548573
.set_default("danger_accept_invalid_certs", false)?
549574
.set_default("pj_host", "0.0.0.0:3000")?

payjoin-cli/src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ fn cli() -> ArgMatches {
7373
.long("rpcpass")
7474
.help("The password for the bitcoin node"))
7575
.subcommand_required(true)
76+
.arg(Arg::new("ohttp_config")
77+
.long("ohttp-config")
78+
.help("The ohttp config file"))
79+
.arg(Arg::new("ohttp_proxy")
80+
.long("ohttp-proxy")
81+
.help("The ohttp proxy url"))
7682
.subcommand(
7783
Command::new("send")
7884
.arg_required_else_help(true)

payjoin-relay/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,15 @@ edition = "2021"
88
[dependencies]
99
axum = "0.6.2"
1010
anyhow = "1.0.71"
11+
hyper = "0.14.27"
12+
http = "0.2.4"
13+
# ohttp = "0.4.0"
14+
httparse = "1.8.0"
15+
ohttp = { path = "../../ohttp/ohttp" }
16+
bhttp = { version = "0.4.0", features = ["http"] }
1117
payjoin = { path = "../payjoin", features = ["v2"] }
1218
sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] }
1319
tokio = { version = "1.12.0", features = ["full"] }
20+
tower-service = "0.3.2"
1421
tracing = "0.1.37"
1522
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }

0 commit comments

Comments
 (0)