Skip to content

Commit

Permalink
Protect metadata with Oblivious HTTP
Browse files Browse the repository at this point in the history
  • Loading branch information
DanGould committed Oct 2, 2023
1 parent 5deba12 commit f6efdab
Show file tree
Hide file tree
Showing 11 changed files with 839 additions and 210 deletions.
431 changes: 395 additions & 36 deletions Cargo.lock

Large diffs are not rendered by default.

135 changes: 80 additions & 55 deletions payjoin-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ use clap::ArgMatches;
use config::{Config, File, FileFormat};
use payjoin::bitcoin::psbt::Psbt;
use payjoin::bitcoin::{self, base64};
use payjoin::receive::{Error, PayjoinProposal, ProvisionalProposal, UncheckedProposal};
use payjoin::receive::{
EnrollContext, Error, PayjoinProposal, ProvisionalProposal, UncheckedProposal,
};
use payjoin::send::RequestContext;
#[cfg(not(feature = "v2"))]
use rouille::{Request, Response};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -44,61 +47,61 @@ impl App {

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

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

log::debug!("Awaiting response");
let res = Self::long_poll_post(&client, req).await?;
let mut res = std::io::Cursor::new(&res);
self.process_pj_response(ctx, &mut res)?;
let res = self.long_poll_post(&client, req_ctx).await?;
self.process_pj_response(res)?;
Ok(())
}

#[cfg(feature = "v2")]
async fn long_poll_post(
&self,
client: &reqwest::Client,
req: payjoin::send::Request,
) -> Result<Vec<u8>, reqwest::Error> {
req_ctx: payjoin::send::RequestContext<'_>,
) -> Result<Psbt> {
loop {
let (req, ctx) = req_ctx.extract_v2(&self.config.ohttp_proxy)?;
let response = client
.post(req.url.as_str())
.post(req.url)
.body(req.body.clone())
.header("Content-Type", "text/plain")
.send()
.await?;

if response.status() == reqwest::StatusCode::OK {
let body = response.bytes().await?.to_vec();
return Ok(body);
} else if response.status() == reqwest::StatusCode::ACCEPTED {
let bytes = response.bytes().await?;
let mut cursor = std::io::Cursor::new(bytes);
let psbt = ctx.process_response(&mut cursor)?;
if let Some(psbt) = psbt {
return Ok(psbt);
} else {
log::info!("No response yet for POST payjoin request, retrying some seconds");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} else {
log::error!("Unexpected response status: {}", response.status());
// TODO handle error
panic!("Unexpected response status: {}", response.status())
}
}
}

#[cfg(feature = "v2")]
async fn long_poll_get(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, reqwest::Error> {
async fn long_poll_get(
&self,
client: &reqwest::Client,
enroll_context: &mut EnrollContext,
) -> Result<UncheckedProposal, reqwest::Error> {
loop {
let response = client.get(url).send().await?;

if response.status().is_success() {
let body = response.bytes().await?;
if !body.is_empty() {
return Ok(body.to_vec());
} else {
log::info!("No response yet for GET payjoin request, retrying in 5 seconds");
}

tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let (enroll_body, context) = enroll_context.enroll_body();
let ohttp_response =
client.post(&self.config.ohttp_proxy).body(enroll_body).send().await?;
let ohttp_response = ohttp_response.bytes().await?;
let proposal = enroll_context.parse_proposal(ohttp_response.as_ref(), context).unwrap();
match proposal {
Some(proposal) => return Ok(proposal),
None => tokio::time::sleep(std::time::Duration::from_secs(5)).await,
}
}
}
Expand All @@ -122,10 +125,7 @@ impl App {
Ok(())
}

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

Expand Down Expand Up @@ -167,22 +167,16 @@ impl App {
.psbt;
let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?;
log::debug!("Original psbt: {:#?}", psbt);

let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri)
let req_ctx = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri)
.with_context(|| "Failed to build payjoin request")?
.build_recommended(fee_rate)
.with_context(|| "Failed to build payjoin request")?;

Ok((req, ctx))
Ok(req_ctx)
}

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

#[cfg(feature = "v2")]
pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> {
let context = payjoin::receive::ProposalContext::new();
let mut context = EnrollContext::from_relay_config(
&self.config.pj_endpoint,
&self.config.ohttp_config,
&self.config.ohttp_proxy,
);
let pj_uri_string =
self.construct_payjoin_uri(amount_arg, Some(&context.subdirectory()))?;
println!(
Expand All @@ -231,25 +229,26 @@ impl App {
.danger_accept_invalid_certs(self.config.danger_accept_invalid_certs)
.build()
.with_context(|| "Failed to build reqwest http client")?;
log::debug!("Awaiting request");
let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir());
let mut buffer = Self::long_poll_get(&client, &receive_endpoint).await?;
log::debug!("Awaiting proposal");
let proposal = self.long_poll_get(&client, &mut context).await?;

log::debug!("Received request");
let proposal = context
.parse_proposal(&mut buffer)
.map_err(|e| anyhow!("Failed to parse into UncheckedProposal {}", e))?;
log::debug!("Received proposal");
let payjoin_proposal = self
.process_proposal(proposal)
.map_err(|e| anyhow!("Failed to process UncheckedProposal {}", e))?;

let body = payjoin_proposal.serialize_body();
let _ = client
.post(receive_endpoint)
let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir());
let (body, ohttp_ctx) =
payjoin_proposal.extract_v2_req(&self.config.ohttp_config, &receive_endpoint);
let res = client
.post(&self.config.ohttp_proxy)
.body(body)
.send()
.await
.with_context(|| "HTTP request failed")?;
let res = res.bytes().await?;
let res = payjoin_proposal.deserialize_res(res.to_vec(), ohttp_ctx);
log::debug!("Received response {:?}", res);
Ok(())
}

Expand Down Expand Up @@ -286,14 +285,15 @@ impl App {
let amount = Amount::from_sat(amount_arg.parse()?);
//let subdir = self.config.pj_endpoint + pubkey.map_or(&String::from(""), |s| &format!("/{}", s));
let pj_uri_string = format!(
"{}?amount={}&pj={}",
"{}?amount={}&pj={}&ohttp={}",
pj_receiver_address.to_qr_uri(),
amount.to_btc(),
format!(
"{}{}",
self.config.pj_endpoint,
pubkey.map_or(String::from(""), |s| format!("/{}", s))
)
),
self.config.ohttp_config,
);

// check validity
Expand Down Expand Up @@ -472,6 +472,19 @@ impl App {
}
}

fn serialize_request_to_bytes(req: reqwest::Request) -> Vec<u8> {
let mut serialized_request =
format!("{} {} HTTP/1.1\r\n", req.method(), req.url()).into_bytes();

for (name, value) in req.headers().iter() {
let header_line = format!("{}: {}\r\n", name.as_str(), value.to_str().unwrap());
serialized_request.extend(header_line.as_bytes());
}

serialized_request.extend(b"\r\n");
serialized_request
}

struct SeenInputs {
set: OutPointSet,
file: std::fs::File,
Expand Down Expand Up @@ -511,6 +524,8 @@ pub(crate) struct AppConfig {
pub bitcoind_cookie: Option<String>,
pub bitcoind_rpcuser: String,
pub bitcoind_rpcpass: String,
pub ohttp_config: String,
pub ohttp_proxy: String,

// send-only
pub danger_accept_invalid_certs: bool,
Expand Down Expand Up @@ -544,6 +559,16 @@ impl AppConfig {
"bitcoind_rpcpass",
matches.get_one::<String>("rpcpass").map(|s| s.as_str()),
)?
.set_default("ohttp_config", "")?
.set_override_option(
"ohttp_config",
matches.get_one::<String>("ohttp_config").map(|s| s.as_str()),
)?
.set_default("ohttp_proxy", "")?
.set_override_option(
"ohttp_proxy",
matches.get_one::<String>("ohttp_proxy").map(|s| s.as_str()),
)?
// Subcommand defaults without which file serialization fails.
.set_default("danger_accept_invalid_certs", false)?
.set_default("pj_host", "0.0.0.0:3000")?
Expand Down
6 changes: 6 additions & 0 deletions payjoin-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ fn cli() -> ArgMatches {
.long("rpcpass")
.help("The password for the bitcoin node"))
.subcommand_required(true)
.arg(Arg::new("ohttp_config")
.long("ohttp-config")
.help("The ohttp config file"))
.arg(Arg::new("ohttp_proxy")
.long("ohttp-proxy")
.help("The ohttp proxy url"))
.subcommand(
Command::new("send")
.arg_required_else_help(true)
Expand Down
7 changes: 7 additions & 0 deletions payjoin-relay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ edition = "2021"
[dependencies]
axum = "0.6.2"
anyhow = "1.0.71"
hyper = "0.14.27"
http = "0.2.4"
# ohttp = "0.4.0"
httparse = "1.8.0"
ohttp = { path = "../../ohttp/ohttp" }
bhttp = { version = "0.4.0", features = ["http"] }
payjoin = { path = "../payjoin", features = ["v2"] }
sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] }
tokio = { version = "1.12.0", features = ["full"] }
tower-service = "0.3.2"
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
Loading

0 comments on commit f6efdab

Please sign in to comment.