Skip to content

Commit 515e8e1

Browse files
committed
Secure v2 payloads with authenticated encryption
1 parent 2979602 commit 515e8e1

File tree

6 files changed

+271
-32
lines changed

6 files changed

+271
-32
lines changed

Cargo.lock

Lines changed: 83 additions & 0 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: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -206,13 +206,9 @@ impl App {
206206

207207
#[cfg(feature = "v2")]
208208
pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> {
209-
let secp = bitcoin::secp256k1::Secp256k1::new();
210-
let mut rng = bitcoin::secp256k1::rand::thread_rng();
211-
let key = bitcoin::secp256k1::KeyPair::new(&secp, &mut rng);
212-
let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false);
213-
let pubkey_base64 = base64::encode_config(key.public_key().to_string(), b64_config);
214-
215-
let pj_uri_string = self.construct_payjoin_uri(amount_arg, Some(&pubkey_base64))?;
209+
let context = payjoin::receive::ProposalContext::new();
210+
let pj_uri_string =
211+
self.construct_payjoin_uri(amount_arg, Some(&context.subdirectory()))?;
216212
println!(
217213
"Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:",
218214
self.config.pj_host
@@ -224,20 +220,21 @@ impl App {
224220
.build()
225221
.with_context(|| "Failed to build reqwest http client")?;
226222
log::debug!("Awaiting request");
227-
let receive_endpoint =
228-
format!("{}/{}/{}", self.config.pj_endpoint, pubkey_base64, payjoin::v2::RECEIVE);
229-
let buffer = Self::long_poll(&client, &receive_endpoint).await?;
223+
let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir());
224+
let mut buffer = Self::long_poll(&client, &receive_endpoint).await?;
230225

231226
log::debug!("Received request");
232-
let proposal = UncheckedProposal::from_streamed(&buffer)
227+
let (proposal, e) = context
228+
.parse_proposal(&mut buffer)
233229
.map_err(|e| anyhow!("Failed to parse into UncheckedProposal {}", e))?;
234230
let payjoin_psbt = self
235231
.process_proposal(proposal)
236232
.map_err(|e| anyhow!("Failed to process UncheckedProposal {}", e))?;
237-
let payjoin_psbt_ser = base64::encode(&payjoin_psbt.serialize());
233+
let mut payjoin_bytes = payjoin_psbt.serialize();
234+
let payjoin = payjoin::v2::encrypt_message_b(&mut payjoin_bytes, e);
238235
let _ = client
239236
.post(receive_endpoint)
240-
.body(payjoin_psbt_ser)
237+
.body(payjoin)
241238
.send()
242239
.await
243240
.with_context(|| "HTTP request failed")?;
@@ -368,10 +365,7 @@ impl App {
368365
headers,
369366
)?;
370367

371-
let payjoin_proposal_psbt = self.process_proposal(proposal)?;
372-
log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt);
373-
374-
let payload = base64::encode(&payjoin_proposal_psbt.serialize());
368+
let payload = self.process_proposal(proposal)?;
375369
log::info!("successful response");
376370
Ok(Response::text(payload))
377371
}

payjoin/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ edition = "2018"
1515
[features]
1616
send = []
1717
receive = ["rand"]
18-
v2 = ["serde", "serde_json"]
18+
v2 = ["bitcoin/rand-std", "chacha20poly1305", "serde", "serde_json"]
1919

2020
[dependencies]
2121
bitcoin = { version = "0.30.0", features = ["base64"] }
2222
bip21 = "0.3.1"
23+
chacha20poly1305 = { version = "0.10.1", optional = true }
2324
log = { version = "0.4.14"}
2425
rand = { version = "0.8.4", optional = true }
2526
serde = { version = "1.0", optional = true }

payjoin/src/receive/mod.rs

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ pub use error::{Error, RequestError, SelectionError};
278278
use error::{InternalRequestError, InternalSelectionError};
279279
use rand::seq::SliceRandom;
280280
use rand::Rng;
281+
use serde::Serialize;
281282

282283
use crate::input_type::InputType;
283284
use crate::optional_parameters::Params;
@@ -287,6 +288,45 @@ pub trait Headers {
287288
fn get_header(&self, key: &str) -> Option<&str>;
288289
}
289290

291+
#[cfg(feature = "v2")]
292+
pub struct ProposalContext {
293+
s: bitcoin::secp256k1::KeyPair,
294+
}
295+
296+
impl ProposalContext {
297+
pub fn new() -> Self {
298+
let secp = bitcoin::secp256k1::Secp256k1::new();
299+
let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng);
300+
ProposalContext { s: bitcoin::secp256k1::KeyPair::from_secret_key(&secp, &sk) }
301+
}
302+
303+
pub fn subdirectory(&self) -> String {
304+
let pubkey = &self.s.public_key().serialize();
305+
let b64_config =
306+
bitcoin::base64::Config::new(bitcoin::base64::CharacterSet::UrlSafe, false);
307+
let pubkey_base64 = bitcoin::base64::encode_config(pubkey, b64_config);
308+
pubkey_base64
309+
}
310+
311+
pub fn receive_subdir(&self) -> String {
312+
format!("{}/{}", self.subdirectory(), crate::v2::RECEIVE)
313+
}
314+
315+
pub fn parse_proposal(
316+
self,
317+
encrypted_proposal: &mut [u8],
318+
) -> Result<(UncheckedProposal, bitcoin::secp256k1::PublicKey), RequestError> {
319+
let (proposal, e) = crate::v2::decrypt_message_a(encrypted_proposal, self.s.secret_key());
320+
let mut proposal = serde_json::from_slice::<UncheckedProposal>(&proposal)
321+
.map_err(InternalRequestError::Json)?;
322+
proposal.psbt = proposal.psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?;
323+
log::debug!("Received original psbt: {:?}", proposal.psbt);
324+
log::debug!("Received request with params: {:?}", proposal.params);
325+
326+
Ok((proposal, e))
327+
}
328+
}
329+
290330
/// The sender's original PSBT and optional parameters
291331
///
292332
/// This type is used to proces the request. It is returned by
@@ -341,20 +381,8 @@ where
341381
Ok(unchecked_psbt)
342382
}
343383

344-
#[cfg(feature = "v2")]
345-
impl UncheckedProposal {
346-
pub fn from_streamed(streamed: &[u8]) -> Result<Self, RequestError> {
347-
let mut proposal = serde_json::from_slice::<UncheckedProposal>(streamed)
348-
.map_err(InternalRequestError::Json)?;
349-
proposal.psbt = proposal.psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?;
350-
log::debug!("Received original psbt: {:?}", proposal.psbt);
351-
log::debug!("Received request with params: {:?}", proposal.params);
352-
353-
Ok(proposal)
354-
}
355-
}
356-
357384
impl UncheckedProposal {
385+
#[cfg(not(feature = "v2"))]
358386
pub fn from_request(
359387
mut body: impl std::io::Read,
360388
query: &str,

payjoin/src/send/mod.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,8 @@ pub struct Context {
322322
input_type: InputType,
323323
sequence: Sequence,
324324
payee: ScriptBuf,
325+
#[cfg(feature = "v2")]
326+
e: bitcoin::secp256k1::SecretKey,
325327
}
326328

327329
macro_rules! check_eq {
@@ -348,6 +350,7 @@ impl Context {
348350
/// Call this method with response from receiver to continue BIP78 flow. If the response is
349351
/// valid you will get appropriate PSBT that you should sign and broadcast.
350352
#[inline]
353+
#[cfg(not(feature = "v2"))]
351354
pub fn process_response(
352355
self,
353356
response: &mut impl std::io::Read,
@@ -360,6 +363,19 @@ impl Context {
360363
self.process_proposal(proposal).map(Into::into).map_err(Into::into)
361364
}
362365

366+
#[cfg(feature = "v2")]
367+
pub fn process_response(
368+
self,
369+
response: &mut impl std::io::Read,
370+
) -> Result<Psbt, ValidationError> {
371+
let mut res_buf = Vec::new();
372+
response.read_to_end(&mut res_buf).map_err(InternalValidationError::Io)?;
373+
let psbt = crate::v2::decrypt_message_b(&mut res_buf, self.e);
374+
let proposal = Psbt::deserialize(&psbt).expect("PSBT deserialization failed");
375+
// process in non-generic function
376+
self.process_proposal(proposal).map(Into::into).map_err(Into::into)
377+
}
378+
363379
fn process_proposal(self, proposal: Psbt) -> InternalResult<Psbt> {
364380
self.basic_checks(&proposal)?;
365381
let in_stats = self.check_inputs(&proposal)?;
@@ -833,13 +849,20 @@ pub(crate) fn from_psbt_and_uri(
833849
let sequence = zeroth_input.txin.sequence;
834850
let txout = zeroth_input.previous_txout().expect("We already checked this above");
835851
let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin).unwrap();
836-
let url = uri.extras._endpoint;
852+
let rs_base64 = crate::v2::subdir(uri.extras._endpoint.as_str()).to_string();
853+
log::debug!("rs_base64: {:?}", rs_base64);
854+
let b64_config = bitcoin::base64::Config::new(bitcoin::base64::CharacterSet::UrlSafe, false);
855+
let rs = bitcoin::base64::decode_config(rs_base64, b64_config).unwrap();
856+
log::debug!("rs: {:?}", rs.len());
857+
let rs = bitcoin::secp256k1::PublicKey::from_slice(&rs).unwrap();
837858
let body = serialize_v2_body(
838859
&psbt,
839860
disable_output_substitution,
840861
fee_contribution,
841862
params.min_fee_rate,
842863
);
864+
let (body, e) = crate::v2::encrypt_message_a(&body, rs);
865+
let url = uri.extras._endpoint;
843866
Ok((
844867
Request { url, body },
845868
Context {
@@ -850,13 +873,15 @@ pub(crate) fn from_psbt_and_uri(
850873
input_type,
851874
sequence,
852875
min_fee_rate: params.min_fee_rate,
876+
e,
853877
},
854878
))
855879
}
856880

857881
#[cfg(test)]
858882
mod tests {
859883
#[test]
884+
#[cfg(not(feature = "v2"))]
860885
fn official_vectors() {
861886
use std::str::FromStr;
862887

0 commit comments

Comments
 (0)