From e3ff8ec2a7fe3e84c8b6558a53f0eabd3362bdd6 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Sep 2024 11:57:45 -0400 Subject: [PATCH 1/7] Use bitcoin-hpke for client aead --- Cargo-minimal.lock | 2 +- Cargo-recent.lock | 2 +- payjoin-cli/src/db/v2.rs | 2 +- payjoin/Cargo.toml | 4 +- payjoin/src/receive/v2/mod.rs | 42 +++--- payjoin/src/send/error.rs | 8 +- payjoin/src/send/mod.rs | 53 +++---- payjoin/src/v2.rs | 251 +++++++++++++++++++++++----------- 8 files changed, 220 insertions(+), 144 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index d40f98f4..054ef62c 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -1582,9 +1582,9 @@ dependencies = [ "bhttp", "bip21", "bitcoin", + "bitcoin-hpke", "bitcoin-ohttp", "bitcoind", - "chacha20poly1305 0.10.1", "http", "log", "ohttp-relay", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index d40f98f4..054ef62c 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -1582,9 +1582,9 @@ dependencies = [ "bhttp", "bip21", "bitcoin", + "bitcoin-hpke", "bitcoin-ohttp", "bitcoind", - "chacha20poly1305 0.10.1", "http", "log", "ohttp-relay", diff --git a/payjoin-cli/src/db/v2.rs b/payjoin-cli/src/db/v2.rs index 7840d974..8ec7250b 100644 --- a/payjoin-cli/src/db/v2.rs +++ b/payjoin-cli/src/db/v2.rs @@ -9,7 +9,7 @@ use super::*; impl Database { pub(crate) fn insert_recv_session(&self, session: ActiveSession) -> Result<()> { let recv_tree = self.0.open_tree("recv_sessions")?; - let key = &session.public_key().serialize(); + let key = &session.id(); let value = serde_json::to_string(&session).map_err(Error::Serialize)?; recv_tree.insert(key.as_slice(), IVec::from(value.as_str()))?; recv_tree.flush()?; diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 821d59de..8412086b 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -19,14 +19,14 @@ exclude = ["tests"] send = [] receive = ["bitcoin/rand"] base64 = ["bitcoin/base64"] -v2 = ["bitcoin/rand", "bitcoin/serde", "chacha20poly1305", "dep:http", "bhttp", "ohttp", "serde", "url/serde"] +v2 = ["bitcoin/rand", "bitcoin/serde", "hpke", "dep:http", "bhttp", "ohttp", "serde", "url/serde"] io = ["reqwest/rustls-tls"] danger-local-https = ["io", "reqwest/rustls-tls", "rustls"] [dependencies] bitcoin = { version = "0.32.2", features = ["base64"] } bip21 = "0.5.0" -chacha20poly1305 = { version = "0.10.1", optional = true } +hpke = { package = "bitcoin-hpke", version = "0.13.0", optional = true } log = { version = "0.4.14"} http = { version = "1", optional = true } bhttp = { version = "=0.5.1", optional = true } diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 4ffbbb6f..8ea6d0c9 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -7,8 +7,8 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; use bitcoin::base64::Engine; use bitcoin::psbt::Psbt; -use bitcoin::secp256k1::{rand, PublicKey}; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut}; +use hpke::Serializable; use serde::de::{self, Deserializer, MapAccess, Visitor}; use serde::ser::SerializeStruct; use serde::{Deserialize, Serialize, Serializer}; @@ -21,7 +21,7 @@ use super::{ }; use crate::psbt::PsbtExt; use crate::receive::optional_parameters::Params; -use crate::v2::OhttpEncapsulationError; +use crate::v2::{HpkePublicKey, HpkeSecretKey, OhttpEncapsulationError}; use crate::{OhttpKeys, PjUriBuilder, Request}; pub(crate) mod error; @@ -36,8 +36,8 @@ struct SessionContext { ohttp_keys: OhttpKeys, expiry: SystemTime, ohttp_relay: url::Url, - s: bitcoin::secp256k1::Keypair, - e: Option, + s: (HpkeSecretKey, HpkePublicKey), + e: Option, } /// Initializes a new payjoin session, including necessary context @@ -70,8 +70,6 @@ impl SessionInitializer { ohttp_relay: Url, expire_after: Option, ) -> Self { - let secp = bitcoin::secp256k1::Secp256k1::new(); - let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng); Self { context: SessionContext { address, @@ -81,7 +79,7 @@ impl SessionInitializer { ohttp_relay, expiry: SystemTime::now() + expire_after.unwrap_or(TWENTY_FOUR_HOURS_DEFAULT_EXPIRY), - s: bitcoin::secp256k1::Keypair::from_secret_key(&secp, &sk), + s: crate::v2::gen_keypair(), e: None, }, } @@ -89,7 +87,7 @@ impl SessionInitializer { pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { let url = self.context.ohttp_relay.clone(); - let subdirectory = subdir_path_from_pubkey(&self.context.s.public_key()); + let subdirectory = subdir_path_from_pubkey(&self.context.s.1); // TODO ensure it's the compressed key let (body, ctx) = crate::v2::ohttp_encapsulate( &mut self.context.ohttp_keys, "POST", @@ -125,8 +123,10 @@ impl SessionInitializer { } } -fn subdir_path_from_pubkey(pubkey: &bitcoin::secp256k1::PublicKey) -> String { - BASE64_URL_SAFE_NO_PAD.encode(pubkey.serialize()) +fn subdir_path_from_pubkey( + pubkey: &::PublicKey, +) -> String { + BASE64_URL_SAFE_NO_PAD.encode(pubkey.to_bytes()) } /// An active payjoin V2 session, allowing for polled requests to the @@ -188,8 +188,7 @@ impl ActiveSession { } fn extract_proposal_from_v2(&mut self, response: Vec) -> Result { - let (payload_bytes, e) = - crate::v2::decrypt_message_a(&response, self.context.s.secret_key())?; + let (payload_bytes, e) = crate::v2::decrypt_message_a(&response, self.context.s.0.clone())?; self.context.e = Some(e); let payload = String::from_utf8(payload_bytes).map_err(InternalRequestError::Utf8)?; Ok(self.unchecked_from_payload(payload)?) @@ -237,7 +236,7 @@ impl ActiveSession { // The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory. // This identifies a session at the payjoin directory server. pub fn pj_url(&self) -> Url { - let pubkey = &self.context.s.public_key().serialize(); + let pubkey = &self.context.s.1.to_bytes(); let pubkey_base64 = BASE64_URL_SAFE_NO_PAD.encode(pubkey); let mut url = self.context.directory.clone(); { @@ -249,7 +248,7 @@ impl ActiveSession { } /// The per-session public key to use as an identifier - pub fn public_key(&self) -> PublicKey { self.context.s.public_key() } + pub fn id(&self) -> Vec { self.context.s.1.to_bytes().to_vec() } } /// The sender's original PSBT and optional parameters @@ -525,15 +524,15 @@ impl PayjoinProposal { #[cfg(feature = "v2")] pub fn extract_v2_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { - let body = match self.context.e { + let body = match &self.context.e { Some(e) => { - let mut payjoin_bytes = self.inner.payjoin_psbt.serialize(); - log::debug!("THERE IS AN e: {}", e); - crate::v2::encrypt_message_b(&mut payjoin_bytes, e) + let payjoin_bytes = self.inner.payjoin_psbt.serialize(); + log::debug!("THERE IS AN e: {:?}", e); + crate::v2::encrypt_message_b(payjoin_bytes, self.context.s.clone(), e) } None => Ok(self.extract_v1_req().as_bytes().to_vec()), }?; - let subdir_path = subdir_path_from_pubkey(&self.context.s.public_key()); + let subdir_path = subdir_path_from_pubkey(&self.context.s.1); let post_payjoin_target = self.context.directory.join(&subdir_path).map_err(|e| Error::Server(e.into()))?; log::debug!("Payjoin post target: {}", post_payjoin_target.as_str()); @@ -739,10 +738,7 @@ mod test { ), ohttp_relay: url::Url::parse("https://relay.com").unwrap(), expiry: SystemTime::now() + Duration::from_secs(60), - s: bitcoin::secp256k1::Keypair::from_secret_key( - &bitcoin::secp256k1::Secp256k1::new(), - &bitcoin::secp256k1::SecretKey::from_slice(&[1; 32]).unwrap(), - ), + s: crate::v2::gen_keypair(), e: None, }, }; diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 681cad51..dfff8005 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -58,7 +58,7 @@ pub(crate) enum InternalValidationError { FeeContributionPaysOutputSizeIncrease, FeeRateBelowMinimum, #[cfg(feature = "v2")] - HpkeError(crate::v2::HpkeError), + Hpke(crate::v2::HpkeError), #[cfg(feature = "v2")] OhttpEncapsulation(crate::v2::OhttpEncapsulationError), #[cfg(feature = "v2")] @@ -108,7 +108,7 @@ impl fmt::Display for ValidationError { FeeContributionPaysOutputSizeIncrease => write!(f, "fee contribution pays for additional outputs"), FeeRateBelowMinimum => write!(f, "the fee rate of proposed transaction is below minimum"), #[cfg(feature = "v2")] - HpkeError(e) => write!(f, "v2 error: {}", e), + Hpke(e) => write!(f, "v2 error: {}", e), #[cfg(feature = "v2")] OhttpEncapsulation(e) => write!(f, "Ohttp encapsulation error: {}", e), #[cfg(feature = "v2")] @@ -153,7 +153,7 @@ impl std::error::Error for ValidationError { FeeContributionPaysOutputSizeIncrease => None, FeeRateBelowMinimum => None, #[cfg(feature = "v2")] - HpkeError(error) => Some(error), + Hpke(error) => Some(error), #[cfg(feature = "v2")] OhttpEncapsulation(error) => Some(error), #[cfg(feature = "v2")] @@ -282,7 +282,7 @@ impl From for CreateRequestError { pub(crate) enum ParseSubdirectoryError { MissingSubdirectory, SubdirectoryNotBase64(bitcoin::base64::DecodeError), - SubdirectoryInvalidPubkey(bitcoin::secp256k1::Error), + SubdirectoryInvalidPubkey(hpke::HpkeError), } #[cfg(feature = "v2")] diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 69275f6b..231594c4 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -27,10 +27,6 @@ use std::str::FromStr; use bitcoin::psbt::Psbt; -#[cfg(feature = "v2")] -use bitcoin::secp256k1::rand; -#[cfg(feature = "v2")] -use bitcoin::secp256k1::PublicKey; use bitcoin::{FeeRate, Script, ScriptBuf, Sequence, TxOut, Weight}; pub use error::{CreateRequestError, ResponseError, ValidationError}; pub(crate) use error::{InternalCreateRequestError, InternalValidationError}; @@ -45,6 +41,8 @@ use url::Url; use crate::input_type::InputType; use crate::psbt::PsbtExt; use crate::request::Request; +#[cfg(feature = "v2")] +use crate::v2::{HpkePublicKey, HpkeSecretKey}; use crate::weight::{varint_size, ComputeWeight}; use crate::PjUri; @@ -238,13 +236,6 @@ impl<'a> RequestBuilder<'a> { let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin) .map_err(InternalCreateRequestError::InputType)?; - #[cfg(feature = "v2")] - let e = { - let secp = bitcoin::secp256k1::Secp256k1::new(); - let (e_sec, _) = secp.generate_keypair(&mut rand::rngs::OsRng); - e_sec - }; - Ok(RequestContext { psbt, endpoint, @@ -255,7 +246,7 @@ impl<'a> RequestBuilder<'a> { sequence, min_fee_rate: self.min_fee_rate, #[cfg(feature = "v2")] - e, + e: crate::v2::gen_keypair().0, }) } } @@ -271,7 +262,7 @@ pub struct RequestContext { sequence: Sequence, payee: ScriptBuf, #[cfg(feature = "v2")] - e: bitcoin::secp256k1::SecretKey, + e: crate::v2::HpkeSecretKey, } #[cfg(feature = "v2")] @@ -340,7 +331,7 @@ impl RequestContext { fn extract_v2_strict( &mut self, ohttp_relay: Url, - rs: PublicKey, + rs: HpkePublicKey, ) -> Result<(Request, ContextV2), CreateRequestError> { use crate::uri::UrlExt; let url = self.endpoint.clone(); @@ -350,7 +341,7 @@ impl RequestContext { self.fee_contribution, self.min_fee_rate, )?; - let body = crate::v2::encrypt_message_a(body, self.e, rs) + let body = crate::v2::encrypt_message_a(body, &self.e.clone(), &rs) .map_err(InternalCreateRequestError::Hpke)?; let mut ohttp = self.endpoint.ohttp().ok_or(InternalCreateRequestError::MissingOhttpConfig)?; @@ -370,14 +361,14 @@ impl RequestContext { sequence: self.sequence, min_fee_rate: self.min_fee_rate, }, - e: Some(self.e), + e: Some(self.e.clone()), ohttp_res: Some(ohttp_res), }, )) } #[cfg(feature = "v2")] - fn extract_rs_pubkey(&self) -> Result { + fn extract_rs_pubkey(&self) -> Result { use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; use bitcoin::base64::Engine; use error::ParseSubdirectoryError; @@ -392,7 +383,7 @@ impl RequestContext { .decode(subdirectory) .map_err(ParseSubdirectoryError::SubdirectoryNotBase64)?; - bitcoin::secp256k1::PublicKey::from_slice(&pubkey_bytes) + HpkePublicKey::from_bytes(&pubkey_bytes) .map_err(ParseSubdirectoryError::SubdirectoryInvalidPubkey) } @@ -417,7 +408,7 @@ impl Serialize for RequestContext { state.serialize_field("input_type", &self.input_type)?; state.serialize_field("sequence", &self.sequence)?; state.serialize_field("payee", &self.payee)?; - state.serialize_field("e", &self.e.secret_bytes())?; + state.serialize_field("e", &self.e)?; state.end() } } @@ -486,13 +477,7 @@ impl<'de> Deserialize<'de> for RequestContext { "input_type" => input_type = Some(map.next_value()?), "sequence" => sequence = Some(map.next_value()?), "payee" => payee = Some(map.next_value()?), - "e" => { - let secret_bytes: Vec = map.next_value()?; - e = Some( - bitcoin::secp256k1::SecretKey::from_slice(&secret_bytes) - .map_err(de::Error::custom)?, - ); - } + "e" => e = Some(map.next_value()?), _ => return Err(de::Error::unknown_field(key.as_str(), FIELDS)), } } @@ -535,7 +520,7 @@ pub struct ContextV1 { #[cfg(feature = "v2")] pub struct ContextV2 { context_v1: ContextV1, - e: Option, + e: Option, ohttp_res: Option, } @@ -576,13 +561,13 @@ impl ContextV2 { response.read_to_end(&mut res_buf).map_err(InternalValidationError::Io)?; let response = crate::v2::ohttp_decapsulate(ohttp_res, &res_buf) .map_err(InternalValidationError::OhttpEncapsulation)?; - let mut body = match response.status() { + let body = match response.status() { http::StatusCode::OK => response.body().to_vec(), http::StatusCode::ACCEPTED => return Ok(None), _ => return Err(InternalValidationError::UnexpectedStatusCode)?, }; - let psbt = crate::v2::decrypt_message_b(&mut body, e) - .map_err(InternalValidationError::HpkeError)?; + let psbt = crate::v2::decrypt_message_b(&body, e) + .map_err(InternalValidationError::Hpke)?; let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; let processed_proposal = self.context_v1.process_proposal(proposal)?; @@ -1109,8 +1094,9 @@ mod test { #[test] #[cfg(feature = "v2")] fn req_ctx_ser_de_roundtrip() { - use super::*; + use hpke::Deserializable; + use super::*; let req_ctx = RequestContext { psbt: Psbt::from_str(ORIGINAL_PSBT).unwrap(), endpoint: Url::parse("http://localhost:1234").unwrap(), @@ -1123,7 +1109,10 @@ mod test { }, sequence: Sequence::MAX, payee: ScriptBuf::from(vec![0x00]), - e: bitcoin::secp256k1::SecretKey::from_slice(&[0x01; 32]).unwrap(), + e: HpkeSecretKey( + ::PrivateKey::from_bytes(&[0x01; 32]) + .unwrap(), + ), }; let serialized = serde_json::to_string(&req_ctx).unwrap(); let deserialized = serde_json::from_str(&serialized).unwrap(); diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index 5df401e4..7accca5b 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -3,91 +3,188 @@ use std::{error, fmt}; use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; use bitcoin::base64::Engine; -use bitcoin::secp256k1::ecdh::SharedSecret; -use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; -use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; -use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Nonce}; +use hpke::aead::ChaCha20Poly1305; +use hpke::kdf::HkdfSha256; +use hpke::kem::SecpK256HkdfSha256; +use hpke::rand_core::OsRng; +use hpke::{Deserializable, OpModeR, OpModeS, Serializable}; pub const PADDED_MESSAGE_BYTES: usize = 7168; // 7KB +pub const INFO_A: &[u8] = b"PjV2MsgA"; +pub const INFO_B: &[u8] = b"PjV2MsgB"; -/// crypto context -/// -/// <- Receiver S -/// -> Sender E, ES(payload), payload protected by knowledge of receiver key -/// <- Receiver E, EE(payload), payload protected by knowledge of sender & receiver key +pub type SecretKey = ::PrivateKey; +pub type PublicKey = ::PublicKey; +pub type EncappedKey = ::EncappedKey; + +fn sk_to_pk(sk: &SecretKey) -> PublicKey { ::sk_to_pk(sk) } + +pub(crate) fn gen_keypair() -> (HpkeSecretKey, HpkePublicKey) { + let (sk, pk) = ::gen_keypair(&mut OsRng); + (HpkeSecretKey(sk), HpkePublicKey(pk)) +} + +#[derive(Clone, PartialEq, Eq)] +pub struct HpkeSecretKey(pub SecretKey); + +impl Deref for HpkeSecretKey { + type Target = SecretKey; + + fn deref(&self) -> &Self::Target { &self.0 } +} + +impl core::fmt::Debug for HpkeSecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SecpHpkeSecretKey({:?})", self.0.to_bytes()) + } +} + +impl serde::Serialize for HpkeSecretKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(&self.0.to_bytes()) + } +} + +impl<'de> serde::Deserialize<'de> for HpkeSecretKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes = Vec::::deserialize(deserializer)?; + Ok(HpkeSecretKey( + SecretKey::from_bytes(&bytes) + .map_err(|_| serde::de::Error::custom("Invalid secret key"))?, + )) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct HpkePublicKey(pub PublicKey); + +impl HpkePublicKey { + pub fn from_bytes(bytes: &[u8]) -> Result { + Ok(HpkePublicKey(PublicKey::from_bytes(bytes)?)) + } +} + +impl Deref for HpkePublicKey { + type Target = PublicKey; + + fn deref(&self) -> &Self::Target { &self.0 } +} + +impl core::fmt::Debug for HpkePublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SecpHpkePublicKey({:?})", self.0) + } +} + +impl serde::Serialize for HpkePublicKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_bytes(&self.0.to_bytes()) + } +} + +impl<'de> serde::Deserialize<'de> for HpkePublicKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes = Vec::::deserialize(deserializer)?; + Ok(HpkePublicKey( + PublicKey::from_bytes(&bytes) + .map_err(|_| serde::de::Error::custom("Invalid public key"))?, + )) + } +} + +/// Message A is sent from the sender to the receiver containing an Original PSBT payload #[cfg(feature = "send")] pub fn encrypt_message_a( - mut raw_msg: Vec, - e_sec: SecretKey, - s: PublicKey, + plaintext: Vec, + sender_sk: &HpkeSecretKey, + receiver_pk: &HpkePublicKey, ) -> Result, HpkeError> { - let secp = Secp256k1::new(); - let e_pub = e_sec.public_key(&secp); - let es = SharedSecret::new(&s, &e_sec); - let cipher = ChaCha20Poly1305::new_from_slice(&es.secret_bytes()) - .map_err(|_| HpkeError::InvalidKeyLength)?; - let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); // key es encrypts only 1 message so 0 is unique - let aad = &e_pub.serialize(); - let msg = pad(&mut raw_msg)?; - let payload = Payload { msg, aad }; - let c_t: Vec = cipher.encrypt(&nonce, payload)?; - let mut message_a = e_pub.serialize().to_vec(); - message_a.extend(&nonce[..]); - message_a.extend(&c_t[..]); - Ok(message_a) + let pk = sk_to_pk(&sender_sk.0); + let (encapsulated_key, mut encryption_context) = + hpke::setup_sender::( + &OpModeS::Auth((sender_sk.0.clone(), pk.clone())), + &receiver_pk.0, + INFO_A, + &mut OsRng, + )?; + let aad = pk.to_bytes().to_vec(); + let ciphertext: Vec = encryption_context.seal(&plaintext, &aad)?; + let mut message_a = encapsulated_key.to_bytes().to_vec(); + message_a.extend(&aad); + message_a.extend(&ciphertext); + //TODO let message_a = pad(&mut message_a).expect("TODO: handle error"); + Ok(message_a.to_vec()) } #[cfg(feature = "receive")] pub fn decrypt_message_a( message_a: &[u8], - s: SecretKey, -) -> Result<(Vec, PublicKey), HpkeError> { - // let message a = [pubkey/AD][nonce][authentication tag][ciphertext] - let e = PublicKey::from_slice(message_a.get(..33).ok_or(HpkeError::PayloadTooShort)?)?; - let nonce = Nonce::from_slice(message_a.get(33..45).ok_or(HpkeError::PayloadTooShort)?); - let es = SharedSecret::new(&e, &s); - let cipher = ChaCha20Poly1305::new_from_slice(&es.secret_bytes()) - .map_err(|_| HpkeError::InvalidKeyLength)?; - let c_t = message_a.get(45..).ok_or(HpkeError::PayloadTooShort)?; - let aad = &e.serialize(); - let payload = Payload { msg: c_t, aad }; - let buffer = cipher.decrypt(nonce, payload)?; - Ok((buffer, e)) + receiver_sk: HpkeSecretKey, +) -> Result<(Vec, HpkePublicKey), HpkeError> { + let enc = message_a.get(..65).ok_or(HpkeError::PayloadTooShort)?; + let enc = EncappedKey::from_bytes(enc)?; + let aad = message_a.get(65..130).ok_or(HpkeError::PayloadTooShort)?; + let pk_s = PublicKey::from_bytes(aad)?; + let mut decryption_ctx = hpke::setup_receiver::< + ChaCha20Poly1305, + HkdfSha256, + SecpK256HkdfSha256, + >(&OpModeR::Auth(pk_s.clone()), &receiver_sk.0, &enc, INFO_A)?; + let ciphertext = message_a.get(130..).ok_or(HpkeError::PayloadTooShort)?; + let plaintext = decryption_ctx.open(ciphertext, aad)?; + Ok((plaintext, HpkePublicKey(pk_s))) } +/// Message B is sent from the receiver to the sender containing a Payjoin PSBT payload or an error #[cfg(feature = "receive")] -pub fn encrypt_message_b(raw_msg: &mut Vec, re_pub: PublicKey) -> Result, HpkeError> { - // let message b = [pubkey/AD][nonce][authentication tag][ciphertext] - let secp = Secp256k1::new(); - let (e_sec, e_pub) = secp.generate_keypair(&mut OsRng); - let ee = SharedSecret::new(&re_pub, &e_sec); - let cipher = ChaCha20Poly1305::new_from_slice(&ee.secret_bytes()) - .map_err(|_| HpkeError::InvalidKeyLength)?; - let nonce = Nonce::from_slice(&[0u8; 12]); // key es encrypts only 1 message so 0 is unique - let aad = &e_pub.serialize(); - let msg = pad(raw_msg)?; - let payload = Payload { msg, aad }; - let c_t = cipher.encrypt(nonce, payload)?; - let mut message_b = e_pub.serialize().to_vec(); - message_b.extend(&nonce[..]); - message_b.extend(&c_t[..]); - Ok(message_b) +pub fn encrypt_message_b( + plaintext: Vec, + receiver_keypair: (HpkeSecretKey, HpkePublicKey), + sender_pk: &HpkePublicKey, +) -> Result, HpkeError> { + let pk = sk_to_pk(&receiver_keypair.0 .0); + let (encapsulated_key, mut encryption_context) = + hpke::setup_sender::( + &OpModeS::Auth((receiver_keypair.0 .0, pk.clone())), + &sender_pk.0, + INFO_B, + &mut OsRng, + )?; + let aad = pk.to_bytes().to_vec(); + let ciphertext = encryption_context.seal(&plaintext, &aad)?; + let mut message_b = encapsulated_key.to_bytes().to_vec(); + message_b.extend(&aad); + message_b.extend(&ciphertext); + //let message_b = pad(&mut message_b).expect("TODO: handle error"); + Ok(message_b.to_vec()) } #[cfg(feature = "send")] -pub fn decrypt_message_b(message_b: &mut [u8], e: SecretKey) -> Result, HpkeError> { - // let message b = [pubkey/AD][nonce][authentication tag][ciphertext] - let re = PublicKey::from_slice(message_b.get(..33).ok_or(HpkeError::PayloadTooShort)?)?; - let nonce = Nonce::from_slice(message_b.get(33..45).ok_or(HpkeError::PayloadTooShort)?); - let ee = SharedSecret::new(&re, &e); - let cipher = ChaCha20Poly1305::new_from_slice(&ee.secret_bytes()) - .map_err(|_| HpkeError::InvalidKeyLength)?; - let payload = Payload { - msg: message_b.get(45..).ok_or(HpkeError::PayloadTooShort)?, - aad: &re.serialize(), - }; - let buffer = cipher.decrypt(nonce, payload)?; - Ok(buffer) +pub fn decrypt_message_b(message_b: &[u8], sender_sk: HpkeSecretKey) -> Result, HpkeError> { + let enc = message_b.get(..65).ok_or(HpkeError::PayloadTooShort)?; + let enc = EncappedKey::from_bytes(enc).unwrap(); + let aad = message_b.get(65..130).ok_or(HpkeError::PayloadTooShort)?; + let pk_s = PublicKey::from_bytes(aad)?; + let mut decryption_ctx = hpke::setup_receiver::< + ChaCha20Poly1305, + HkdfSha256, + SecpK256HkdfSha256, + >(&OpModeR::Auth(pk_s), &sender_sk.0, &enc, INFO_B)?; + let plaintext = + decryption_ctx.open(message_b.get(130..).ok_or(HpkeError::PayloadTooShort)?, aad)?; + Ok(plaintext) } fn pad(msg: &mut Vec) -> Result<&[u8], HpkeError> { @@ -103,19 +200,14 @@ fn pad(msg: &mut Vec) -> Result<&[u8], HpkeError> { /// Error from de/encrypting a v2 Hybrid Public Key Encryption payload. #[derive(Debug)] pub enum HpkeError { - Secp256k1(bitcoin::secp256k1::Error), - ChaCha20Poly1305(chacha20poly1305::aead::Error), + Hpke(hpke::HpkeError), InvalidKeyLength, PayloadTooLarge, PayloadTooShort, } -impl From for HpkeError { - fn from(value: bitcoin::secp256k1::Error) -> Self { Self::Secp256k1(value) } -} - -impl From for HpkeError { - fn from(value: chacha20poly1305::aead::Error) -> Self { Self::ChaCha20Poly1305(value) } +impl From for HpkeError { + fn from(value: hpke::HpkeError) -> Self { Self::Hpke(value) } } impl fmt::Display for HpkeError { @@ -123,8 +215,7 @@ impl fmt::Display for HpkeError { use HpkeError::*; match &self { - Secp256k1(e) => e.fmt(f), - ChaCha20Poly1305(e) => e.fmt(f), + Hpke(e) => e.fmt(f), InvalidKeyLength => write!(f, "Invalid Length"), PayloadTooLarge => write!(f, "Payload too large, max size is {} bytes", PADDED_MESSAGE_BYTES), @@ -138,8 +229,8 @@ impl error::Error for HpkeError { use HpkeError::*; match &self { - Secp256k1(e) => Some(e), - ChaCha20Poly1305(_) | InvalidKeyLength | PayloadTooLarge | PayloadTooShort => None, + Hpke(e) => Some(e), + InvalidKeyLength | PayloadTooLarge | PayloadTooShort => None, } } } From 2b7781ac2016a83102814cffb881a2d89ebe6379 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Sep 2024 12:43:27 -0400 Subject: [PATCH 2/7] Pad plaintext client messages This way an ohttp relay sees uniform ciphertexts --- payjoin/src/v2.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index 7accca5b..cea3fea3 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -3,13 +3,16 @@ use std::{error, fmt}; use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; use bitcoin::base64::Engine; +use bitcoin::key::constants::UNCOMPRESSED_PUBLIC_KEY_SIZE; use hpke::aead::ChaCha20Poly1305; use hpke::kdf::HkdfSha256; use hpke::kem::SecpK256HkdfSha256; use hpke::rand_core::OsRng; use hpke::{Deserializable, OpModeR, OpModeS, Serializable}; -pub const PADDED_MESSAGE_BYTES: usize = 7168; // 7KB +pub const PADDED_MESSAGE_BYTES: usize = 7168; +pub const PADDED_PLAINTEXT_LENGTH: usize = PADDED_MESSAGE_BYTES - UNCOMPRESSED_PUBLIC_KEY_SIZE * 2; + pub const INFO_A: &[u8] = b"PjV2MsgA"; pub const INFO_B: &[u8] = b"PjV2MsgB"; @@ -107,7 +110,7 @@ impl<'de> serde::Deserialize<'de> for HpkePublicKey { /// Message A is sent from the sender to the receiver containing an Original PSBT payload #[cfg(feature = "send")] pub fn encrypt_message_a( - plaintext: Vec, + mut plaintext: Vec, sender_sk: &HpkeSecretKey, receiver_pk: &HpkePublicKey, ) -> Result, HpkeError> { @@ -120,11 +123,11 @@ pub fn encrypt_message_a( &mut OsRng, )?; let aad = pk.to_bytes().to_vec(); - let ciphertext: Vec = encryption_context.seal(&plaintext, &aad)?; + let plaintext = pad_plaintext(&mut plaintext)?; + let ciphertext = encryption_context.seal(plaintext, &aad)?; let mut message_a = encapsulated_key.to_bytes().to_vec(); message_a.extend(&aad); message_a.extend(&ciphertext); - //TODO let message_a = pad(&mut message_a).expect("TODO: handle error"); Ok(message_a.to_vec()) } @@ -150,7 +153,7 @@ pub fn decrypt_message_a( /// Message B is sent from the receiver to the sender containing a Payjoin PSBT payload or an error #[cfg(feature = "receive")] pub fn encrypt_message_b( - plaintext: Vec, + mut plaintext: Vec, receiver_keypair: (HpkeSecretKey, HpkePublicKey), sender_pk: &HpkePublicKey, ) -> Result, HpkeError> { @@ -163,11 +166,11 @@ pub fn encrypt_message_b( &mut OsRng, )?; let aad = pk.to_bytes().to_vec(); - let ciphertext = encryption_context.seal(&plaintext, &aad)?; + let plaintext = pad_plaintext(&mut plaintext)?; + let ciphertext = encryption_context.seal(plaintext, &aad)?; let mut message_b = encapsulated_key.to_bytes().to_vec(); message_b.extend(&aad); message_b.extend(&ciphertext); - //let message_b = pad(&mut message_b).expect("TODO: handle error"); Ok(message_b.to_vec()) } @@ -187,13 +190,11 @@ pub fn decrypt_message_b(message_b: &[u8], sender_sk: HpkeSecretKey) -> Result) -> Result<&[u8], HpkeError> { - if msg.len() > PADDED_MESSAGE_BYTES { +fn pad_plaintext(msg: &mut Vec) -> Result<&[u8], HpkeError> { + if msg.len() > PADDED_PLAINTEXT_LENGTH { return Err(HpkeError::PayloadTooLarge); } - while msg.len() < PADDED_MESSAGE_BYTES { - msg.push(0); - } + msg.resize(PADDED_PLAINTEXT_LENGTH, 0); Ok(msg) } @@ -218,7 +219,7 @@ impl fmt::Display for HpkeError { Hpke(e) => e.fmt(f), InvalidKeyLength => write!(f, "Invalid Length"), PayloadTooLarge => - write!(f, "Payload too large, max size is {} bytes", PADDED_MESSAGE_BYTES), + write!(f, "Plaintext too large, max size is {} bytes", PADDED_PLAINTEXT_LENGTH), PayloadTooShort => write!(f, "Payload too small"), } } From 51b930140fa9c6ca2702dc6781fe8fe60279c4e8 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Sep 2024 23:11:15 -0400 Subject: [PATCH 3/7] Delete verbose serde impl where macros do Almost all of the replaced field serializers have serde impls so macros just work. --- payjoin/src/receive/v2/mod.rs | 159 +++------------------------------- payjoin/src/send/mod.rs | 124 +------------------------- 2 files changed, 16 insertions(+), 267 deletions(-) diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 8ea6d0c9..f23a2094 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -1,17 +1,14 @@ use std::collections::HashMap; -use std::fmt; use std::str::FromStr; use std::time::{Duration, SystemTime}; -use bitcoin::address::NetworkUnchecked; use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; use bitcoin::base64::Engine; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut}; use hpke::Serializable; -use serde::de::{self, Deserializer, MapAccess, Visitor}; -use serde::ser::SerializeStruct; -use serde::{Deserialize, Serialize, Serializer}; +use serde::de::Deserializer; +use serde::{Deserialize, Serialize}; use url::Url; use super::v2::error::{InternalSessionError, SessionError}; @@ -28,8 +25,9 @@ pub(crate) mod error; static TWENTY_FOUR_HOURS_DEFAULT_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct SessionContext { + #[serde(deserialize_with = "deserialize_address_assume_checked")] address: Address, directory: url::Url, subdirectory: Option, @@ -40,6 +38,15 @@ struct SessionContext { e: Option, } +fn deserialize_address_assume_checked<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + let address = Address::from_str(&s).map_err(serde::de::Error::custom)?; + Ok(address.assume_checked()) +} + /// Initializes a new payjoin session, including necessary context /// information for communication and cryptographic operations. #[derive(Debug, Clone)] @@ -571,146 +578,6 @@ impl PayjoinProposal { } } } -impl Serialize for SessionContext { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut state = serializer.serialize_struct("SessionContext", 4)?; - state.serialize_field("address", &self.address)?; - state.serialize_field("directory", &self.directory)?; - state.serialize_field("subdirectory", &self.subdirectory)?; - state.serialize_field("ohttp_keys", &self.ohttp_keys)?; - state.serialize_field("ohttp_relay", &self.ohttp_relay)?; - state.serialize_field("expiry", &self.expiry)?; - state.serialize_field("s", &self.s)?; - state.serialize_field("e", &self.e)?; - - state.end() - } -} - -impl<'de> Deserialize<'de> for SessionContext { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(field_identifier, rename_all = "snake_case")] - enum Field { - Address, - Directory, - Subdirectory, - OhttpKeys, - OhttpRelay, - Expiry, - S, - E, - } - - struct SessionContextVisitor; - - impl<'de> Visitor<'de> for SessionContextVisitor { - type Value = SessionContext; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("struct ActiveSession") - } - - fn visit_map(self, mut map: V) -> Result - where - V: MapAccess<'de>, - { - let mut address: Option> = None; - let mut directory = None; - let mut subdirectory = None; - let mut ohttp_keys = None; - let mut ohttp_relay = None; - let mut expiry = None; - let mut s = None; - let mut e = None; - while let Some(key) = map.next_key()? { - match key { - Field::Address => { - if address.is_some() { - return Err(de::Error::duplicate_field("address")); - } - address = Some(map.next_value()?); - } - Field::Directory => { - if directory.is_some() { - return Err(de::Error::duplicate_field("directory")); - } - directory = Some(map.next_value()?); - } - Field::Subdirectory => { - if subdirectory.is_some() { - return Err(de::Error::duplicate_field("subdirectory")); - } - subdirectory = Some(map.next_value()?); - } - Field::OhttpKeys => { - if ohttp_keys.is_some() { - return Err(de::Error::duplicate_field("ohttp_keys")); - } - ohttp_keys = Some(map.next_value()?); - } - Field::OhttpRelay => { - if ohttp_relay.is_some() { - return Err(de::Error::duplicate_field("ohttp_relay")); - } - ohttp_relay = Some(map.next_value()?); - } - Field::Expiry => { - if expiry.is_some() { - return Err(de::Error::duplicate_field("expiry")); - } - expiry = Some(map.next_value()?); - } - Field::S => { - if s.is_some() { - return Err(de::Error::duplicate_field("s")); - } - s = Some(map.next_value()?); - } - Field::E => { - if e.is_some() { - return Err(de::Error::duplicate_field("e")); - } - e = Some(map.next_value()?); - } - } - } - let address = address - .ok_or_else(|| de::Error::missing_field("address")) - .map(|a| a.assume_checked())?; - let directory = directory.ok_or_else(|| de::Error::missing_field("directory"))?; - let subdirectory = - subdirectory.ok_or_else(|| de::Error::missing_field("subdirectory"))?; - let ohttp_keys = - ohttp_keys.ok_or_else(|| de::Error::missing_field("ohttp_keys"))?; - let ohttp_relay = - ohttp_relay.ok_or_else(|| de::Error::missing_field("ohttp_relay"))?; - let expiry = expiry.ok_or_else(|| de::Error::missing_field("expiry"))?; - let s = s.ok_or_else(|| de::Error::missing_field("s"))?; - let e = e.ok_or_else(|| de::Error::missing_field("e"))?; - Ok(SessionContext { - address, - directory, - subdirectory, - ohttp_keys, - ohttp_relay, - expiry, - s, - e, - }) - } - } - - const FIELDS: &[&str] = &["directory", "ohttp_keys", "ohttp_relay", "expiry", "s", "e"]; - deserializer.deserialize_struct("SessionContext", FIELDS, SessionContextVisitor) - } -} #[cfg(test)] mod test { diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 231594c4..4b79346b 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -31,11 +31,7 @@ use bitcoin::{FeeRate, Script, ScriptBuf, Sequence, TxOut, Weight}; pub use error::{CreateRequestError, ResponseError, ValidationError}; pub(crate) use error::{InternalCreateRequestError, InternalValidationError}; #[cfg(feature = "v2")] -use serde::{ - de::{self, MapAccess, Visitor}, - ser::SerializeStruct, - Deserialize, Deserializer, Serialize, Serializer, -}; +use serde::{Deserialize, Serialize}; use url::Url; use crate::input_type::InputType; @@ -251,7 +247,8 @@ impl<'a> RequestBuilder<'a> { } } -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "v2", derive(Serialize, Deserialize))] pub struct RequestContext { psbt: Psbt, endpoint: Url, @@ -265,9 +262,6 @@ pub struct RequestContext { e: crate::v2::HpkeSecretKey, } -#[cfg(feature = "v2")] -impl Eq for RequestContext {} - impl RequestContext { /// Extract serialized V1 Request and Context froma Payjoin Proposal pub fn extract_v1(&self) -> Result<(Request, ContextV1), CreateRequestError> { @@ -390,118 +384,6 @@ impl RequestContext { pub fn endpoint(&self) -> &Url { &self.endpoint } } -#[cfg(feature = "v2")] -impl Serialize for RequestContext { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut state = serializer.serialize_struct("RequestContext", 8)?; - state.serialize_field("psbt", &self.psbt.to_string())?; - state.serialize_field("endpoint", &self.endpoint.as_str())?; - state.serialize_field("disable_output_substitution", &self.disable_output_substitution)?; - state.serialize_field( - "fee_contribution", - &self.fee_contribution.as_ref().map(|(amount, index)| (amount.to_sat(), *index)), - )?; - state.serialize_field("min_fee_rate", &self.min_fee_rate)?; - state.serialize_field("input_type", &self.input_type)?; - state.serialize_field("sequence", &self.sequence)?; - state.serialize_field("payee", &self.payee)?; - state.serialize_field("e", &self.e)?; - state.end() - } -} - -#[cfg(feature = "v2")] -impl<'de> Deserialize<'de> for RequestContext { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct RequestContextVisitor; - - const FIELDS: &[&str] = &[ - "psbt", - "endpoint", - "ohttp_keys", - "disable_output_substitution", - "fee_contribution", - "min_fee_rate", - "input_type", - "sequence", - "payee", - "e", - ]; - - impl<'de> Visitor<'de> for RequestContextVisitor { - type Value = RequestContext; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("struct RequestContext") - } - - fn visit_map(self, mut map: V) -> Result - where - V: MapAccess<'de>, - { - let mut psbt = None; - let mut endpoint = None; - let mut disable_output_substitution = None; - let mut fee_contribution = None; - let mut min_fee_rate = None; - let mut input_type = None; - let mut sequence = None; - let mut payee = None; - let mut e = None; - - while let Some(key) = map.next_key::()? { - match key.as_str() { - "psbt" => { - let buf: String = map.next_value::()?; - psbt = Some(Psbt::from_str(&buf).map_err(de::Error::custom)?); - } - "endpoint" => - endpoint = Some( - url::Url::from_str(&map.next_value::()?) - .map_err(de::Error::custom)?, - ), - "disable_output_substitution" => - disable_output_substitution = Some(map.next_value()?), - "fee_contribution" => { - let fc: Option<(u64, usize)> = map.next_value()?; - fee_contribution = fc - .map(|(amount, index)| (bitcoin::Amount::from_sat(amount), index)); - } - "min_fee_rate" => min_fee_rate = Some(map.next_value()?), - "input_type" => input_type = Some(map.next_value()?), - "sequence" => sequence = Some(map.next_value()?), - "payee" => payee = Some(map.next_value()?), - "e" => e = Some(map.next_value()?), - _ => return Err(de::Error::unknown_field(key.as_str(), FIELDS)), - } - } - - Ok(RequestContext { - psbt: psbt.ok_or_else(|| de::Error::missing_field("psbt"))?, - endpoint: endpoint.ok_or_else(|| de::Error::missing_field("endpoint"))?, - disable_output_substitution: disable_output_substitution - .ok_or_else(|| de::Error::missing_field("disable_output_substitution"))?, - fee_contribution, - min_fee_rate: min_fee_rate - .ok_or_else(|| de::Error::missing_field("min_fee_rate"))?, - input_type: input_type.ok_or_else(|| de::Error::missing_field("input_type"))?, - sequence: sequence.ok_or_else(|| de::Error::missing_field("sequence"))?, - payee: payee.ok_or_else(|| de::Error::missing_field("payee"))?, - e: e.ok_or_else(|| de::Error::missing_field("e"))?, - }) - } - } - - deserializer.deserialize_struct("RequestContext", FIELDS, RequestContextVisitor) - } -} - /// Data required for validation of response. /// /// This type is used to process the response. Get it from [`RequestBuilder`](crate::send::RequestBuilder)'s build methods. From 4913ac9412372023bbf0944bb2df56b5afce7d34 Mon Sep 17 00:00:00 2001 From: DanGould Date: Sat, 7 Sep 2024 11:05:57 -0400 Subject: [PATCH 4/7] Serialize hpke pubkeys as compressed This reduces the size of the subdirectory pj= string. --- payjoin/src/receive/v2/mod.rs | 13 +++++-------- payjoin/src/send/error.rs | 2 +- payjoin/src/send/mod.rs | 2 +- payjoin/src/v2.rs | 20 ++++++++++++++++++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index f23a2094..8e32ed11 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -6,7 +6,6 @@ use bitcoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; use bitcoin::base64::Engine; use bitcoin::psbt::Psbt; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut}; -use hpke::Serializable; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use url::Url; @@ -94,7 +93,7 @@ impl SessionInitializer { pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { let url = self.context.ohttp_relay.clone(); - let subdirectory = subdir_path_from_pubkey(&self.context.s.1); // TODO ensure it's the compressed key + let subdirectory = subdir_path_from_pubkey(&self.context.s.1); let (body, ctx) = crate::v2::ohttp_encapsulate( &mut self.context.ohttp_keys, "POST", @@ -130,10 +129,8 @@ impl SessionInitializer { } } -fn subdir_path_from_pubkey( - pubkey: &::PublicKey, -) -> String { - BASE64_URL_SAFE_NO_PAD.encode(pubkey.to_bytes()) +fn subdir_path_from_pubkey(pubkey: &HpkePublicKey) -> String { + BASE64_URL_SAFE_NO_PAD.encode(pubkey.to_compressed_bytes()) } /// An active payjoin V2 session, allowing for polled requests to the @@ -243,7 +240,7 @@ impl ActiveSession { // The contents of the `&pj=` query parameter including the base64url-encoded public key receiver subdirectory. // This identifies a session at the payjoin directory server. pub fn pj_url(&self) -> Url { - let pubkey = &self.context.s.1.to_bytes(); + let pubkey = &self.id(); let pubkey_base64 = BASE64_URL_SAFE_NO_PAD.encode(pubkey); let mut url = self.context.directory.clone(); { @@ -255,7 +252,7 @@ impl ActiveSession { } /// The per-session public key to use as an identifier - pub fn id(&self) -> Vec { self.context.s.1.to_bytes().to_vec() } + pub fn id(&self) -> [u8; 33] { self.context.s.1.to_compressed_bytes() } } /// The sender's original PSBT and optional parameters diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index dfff8005..0c955199 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -282,7 +282,7 @@ impl From for CreateRequestError { pub(crate) enum ParseSubdirectoryError { MissingSubdirectory, SubdirectoryNotBase64(bitcoin::base64::DecodeError), - SubdirectoryInvalidPubkey(hpke::HpkeError), + SubdirectoryInvalidPubkey(crate::v2::HpkeError), } #[cfg(feature = "v2")] diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 4b79346b..b2778c7f 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -377,7 +377,7 @@ impl RequestContext { .decode(subdirectory) .map_err(ParseSubdirectoryError::SubdirectoryNotBase64)?; - HpkePublicKey::from_bytes(&pubkey_bytes) + HpkePublicKey::from_compressed_bytes(&pubkey_bytes) .map_err(ParseSubdirectoryError::SubdirectoryInvalidPubkey) } diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index cea3fea3..77c21ce0 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -68,8 +68,17 @@ impl<'de> serde::Deserialize<'de> for HpkeSecretKey { pub struct HpkePublicKey(pub PublicKey); impl HpkePublicKey { - pub fn from_bytes(bytes: &[u8]) -> Result { - Ok(HpkePublicKey(PublicKey::from_bytes(bytes)?)) + pub fn to_compressed_bytes(&self) -> [u8; 33] { + let compressed_key = bitcoin::secp256k1::PublicKey::from_slice(&self.0.to_bytes()) + .expect("Invalid public key from known valid bytes"); + compressed_key.serialize() + } + + pub fn from_compressed_bytes(bytes: &[u8]) -> Result { + let compressed_key = bitcoin::secp256k1::PublicKey::from_slice(bytes)?; + Ok(HpkePublicKey(PublicKey::from_bytes( + compressed_key.serialize_uncompressed().as_slice(), + )?)) } } @@ -201,6 +210,7 @@ fn pad_plaintext(msg: &mut Vec) -> Result<&[u8], HpkeError> { /// Error from de/encrypting a v2 Hybrid Public Key Encryption payload. #[derive(Debug)] pub enum HpkeError { + Secp256k1(bitcoin::secp256k1::Error), Hpke(hpke::HpkeError), InvalidKeyLength, PayloadTooLarge, @@ -211,6 +221,10 @@ impl From for HpkeError { fn from(value: hpke::HpkeError) -> Self { Self::Hpke(value) } } +impl From for HpkeError { + fn from(value: bitcoin::secp256k1::Error) -> Self { Self::Secp256k1(value) } +} + impl fmt::Display for HpkeError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { use HpkeError::*; @@ -221,6 +235,7 @@ impl fmt::Display for HpkeError { PayloadTooLarge => write!(f, "Plaintext too large, max size is {} bytes", PADDED_PLAINTEXT_LENGTH), PayloadTooShort => write!(f, "Payload too small"), + Secp256k1(e) => e.fmt(f), } } } @@ -232,6 +247,7 @@ impl error::Error for HpkeError { match &self { Hpke(e) => Some(e), InvalidKeyLength | PayloadTooLarge | PayloadTooShort => None, + Secp256k1(e) => Some(e), } } } From aa6acbc4663ac6b229fb5d051ef310a6e4563a39 Mon Sep 17 00:00:00 2001 From: DanGould Date: Sun, 8 Sep 2024 11:59:45 -0400 Subject: [PATCH 5/7] Write compressed OhttpKeys to Uri Payjoin-cli reads ohttp-keys from a binary file as a consequence. --- payjoin-cli/example.config.toml | 2 +- payjoin-cli/src/app/config.rs | 12 +++++------ payjoin-cli/src/main.rs | 7 ++---- payjoin-cli/tests/e2e.rs | 5 +++-- payjoin-directory/src/lib.rs | 5 +---- payjoin/src/uri/url_ext.rs | 11 ++++------ payjoin/src/v2.rs | 38 +++++++++++++++++++++++++++++++-- payjoin/tests/integration.rs | 2 +- 8 files changed, 54 insertions(+), 28 deletions(-) diff --git a/payjoin-cli/example.config.toml b/payjoin-cli/example.config.toml index 99de2098..74041ab8 100644 --- a/payjoin-cli/example.config.toml +++ b/payjoin-cli/example.config.toml @@ -54,4 +54,4 @@ ohttp_relay="https://pj.bobspacebkk.com" # (v2 only, optional) The HPKE keys which need to be fetched ahead of time from the pj_endpoint for the payjoin packets to be encrypted. # These can now be fetched and no longer need to be configured. -ohttp_keys="AQAg3c9qovMZvPzLh8XHgD8q86WG7SmPQvPamCTvEoueKBsABAABAAM" +ohttp_keys="./path/to/ohttp_keys" diff --git a/payjoin-cli/src/app/config.rs b/payjoin-cli/src/app/config.rs index 5c275e1a..faf22536 100644 --- a/payjoin-cli/src/app/config.rs +++ b/payjoin-cli/src/app/config.rs @@ -94,8 +94,6 @@ impl AppConfig { #[cfg(feature = "v2")] let builder = { - use payjoin::base64::prelude::BASE64_URL_SAFE_NO_PAD; - use payjoin::base64::Engine; builder .set_override_option( "pj_directory", @@ -104,11 +102,13 @@ impl AppConfig { .set_override_option( "ohttp_keys", matches.get_one::("ohttp_keys").and_then(|s| { - BASE64_URL_SAFE_NO_PAD - .decode(s) + std::fs::read(s) .map_err(|e| { - log::error!("Failed to decode ohttp_keys: {}", e); - ConfigError::Message(format!("Invalid ohttp_keys: {}", e)) + log::error!("Failed to read ohttp_keys file: {}", e); + ConfigError::Message(format!( + "Failed to read ohttp_keys file: {}", + e + )) }) .ok() }), diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index f14c9256..dfd83961 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -145,11 +145,8 @@ fn cli() -> ArgMatches { .help("The directory to store payjoin requests") .value_parser(value_parser!(Url)), ); - receive_cmd = receive_cmd.arg( - Arg::new("ohttp_keys") - .long("ohttp-keys") - .help("The ohttp key config as a base64 encoded string"), - ); + receive_cmd = receive_cmd + .arg(Arg::new("ohttp_keys").long("ohttp-keys").help("The ohttp key config file path")); } cmd = cmd.subcommand(receive_cmd); diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index 126f4100..7eae8b43 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -243,6 +243,8 @@ mod e2e { let ohttp_keys = payjoin::io::fetch_ohttp_keys(ohttp_relay.clone(), directory.clone(), cert.clone()) .await?; + let ohttp_keys_path = temp_dir.join("ohttp_keys"); + tokio::fs::write(&ohttp_keys_path, ohttp_keys.encode()?).await?; let receiver_rpchost = format!("http://{}/wallet/receiver", bitcoind.params.rpc_socket); let sender_rpchost = format!("http://{}/wallet/sender", bitcoind.params.rpc_socket); @@ -268,13 +270,12 @@ mod e2e { .arg("--pj-directory") .arg(&directory) .arg("--ohttp-keys") - .arg(&ohttp_keys.to_string()) + .arg(&ohttp_keys_path) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .expect("Failed to execute payjoin-cli"); let bip21 = get_bip21_from_receiver(cli_receive_initiator).await; - let cli_send_initiator = Command::new(payjoin_cli) .arg("--rpchost") .arg(&sender_rpchost) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index accf46e2..31408368 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -15,7 +15,7 @@ use hyper::{Method, Request, Response, StatusCode, Uri}; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; use tokio::sync::Mutex; -use tracing::{debug, error, info, trace}; +use tracing::{debug, error, trace}; pub const DEFAULT_DIR_PORT: u16 = 8080; pub const DEFAULT_DB_HOST: &str = "localhost:6379"; @@ -138,9 +138,6 @@ fn init_ohttp() -> Result { // create or read from file let server_config = ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC))?; - let encoded_config = server_config.encode()?; - let b64_config = BASE64_URL_SAFE_NO_PAD.encode(encoded_config); - info!("ohttp-keys server config base64 UrlSafe: {:?}", b64_config); Ok(ohttp::Server::new(server_config)?) } diff --git a/payjoin/src/uri/url_ext.rs b/payjoin/src/uri/url_ext.rs index d7d3a55b..c0743ca9 100644 --- a/payjoin/src/uri/url_ext.rs +++ b/payjoin/src/uri/url_ext.rs @@ -91,12 +91,9 @@ mod tests { let mut url = Url::parse("https://example.com").unwrap(); let ohttp_keys = - OhttpKeys::from_str("AQAWBG3fkg7fQCN-bafc-BEJOSnDfq8k1M9Cy1kgQZX42GVOvI0bWVAciTaJCy2A_wy7R7VxtU88xej692bv0uXgt98ABAABAAM").unwrap(); + OhttpKeys::from_str("AQO6SMScPUqSo60A7MY6Ak2hDO0CGAxz7BLYp60syRu0gw").unwrap(); let _ = url.set_ohttp(Some(ohttp_keys.clone())); - assert_eq!( - url.fragment(), - Some("ohttp=AQAWBG3fkg7fQCN-bafc-BEJOSnDfq8k1M9Cy1kgQZX42GVOvI0bWVAciTaJCy2A_wy7R7VxtU88xej692bv0uXgt98ABAABAAM") - ); + assert_eq!(url.fragment(), Some("ohttp=AQO6SMScPUqSo60A7MY6Ak2hDO0CGAxz7BLYp60syRu0gw")); assert_eq!(url.ohttp(), Some(ohttp_keys)); @@ -124,7 +121,7 @@ mod tests { // fragment is not percent encoded so `&ohttp=` is parsed as a query parameter, not a fragment parameter let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pj=https://example.com\ - #exp=1720547781&ohttp=AQAWBG3fkg7fQCN-bafc-BEJOSnDfq8k1M9Cy1kgQZX42GVOvI0bWVAciTaJCy2A_wy7R7VxtU88xej692bv0uXgt98ABAABAAM"; + #exp=1720547781&ohttp=AQO6SMScPUqSo60A7MY6Ak2hDO0CGAxz7BLYp60syRu0gw"; let uri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap(); assert!(uri.extras.endpoint().ohttp().is_none()); } @@ -133,7 +130,7 @@ mod tests { fn test_valid_v2_url_fragment_on_bip21() { let uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=0.01\ &pj=https://example.com\ - #ohttp%3DAQAWBG3fkg7fQCN-bafc-BEJOSnDfq8k1M9Cy1kgQZX42GVOvI0bWVAciTaJCy2A_wy7R7VxtU88xej692bv0uXgt98ABAABAAM"; + #ohttp%3DAQO6SMScPUqSo60A7MY6Ak2hDO0CGAxz7BLYp60syRu0gw"; let uri = Uri::try_from(uri).unwrap().assume_checked().check_pj_supported().unwrap(); assert!(uri.extras.endpoint().ohttp().is_some()); } diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index 77c21ce0..026f77c7 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -364,9 +364,23 @@ impl OhttpKeys { } } +const KEM_ID: &[u8] = b"\x00\x16"; // DHKEM(secp256k1, HKDF-SHA256) +const SYMMETRIC_LEN: &[u8] = b"\x00\x04"; // 4 bytes +const SYMMETRIC_KDF_AEAD: &[u8] = b"\x00\x01\x00\x03"; // KDF(HKDF-SHA256), AEAD(ChaCha20Poly1305) + impl fmt::Display for OhttpKeys { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let encoded = BASE64_URL_SAFE_NO_PAD.encode(self.encode().map_err(|_| fmt::Error)?); + let bytes = self.encode().map_err(|_| fmt::Error)?; + let key_id = bytes[0]; + let pubkey = &bytes[3..68]; + + let compressed_pubkey = + bitcoin::secp256k1::PublicKey::from_slice(pubkey).map_err(|_| fmt::Error)?.serialize(); + + let mut buf = vec![key_id]; + buf.extend_from_slice(&compressed_pubkey); + + let encoded = BASE64_URL_SAFE_NO_PAD.encode(buf); write!(f, "{}", encoded) } } @@ -374,9 +388,24 @@ impl fmt::Display for OhttpKeys { impl std::str::FromStr for OhttpKeys { type Err = ParseOhttpKeysError; + /// Parses a base64URL-encoded string into OhttpKeys. + /// The string format is: key_id || compressed_public_key fn from_str(s: &str) -> Result { let bytes = BASE64_URL_SAFE_NO_PAD.decode(s).map_err(ParseOhttpKeysError::DecodeBase64)?; - OhttpKeys::decode(&bytes).map_err(ParseOhttpKeysError::DecodeKeyConfig) + + let key_id = *bytes.first().ok_or(ParseOhttpKeysError::InvalidFormat)?; + let compressed_pk = bytes.get(1..34).ok_or(ParseOhttpKeysError::InvalidFormat)?; + + let pubkey = bitcoin::secp256k1::PublicKey::from_slice(compressed_pk) + .map_err(|_| ParseOhttpKeysError::InvalidPublicKey)?; + + let mut buf = vec![key_id]; + buf.extend_from_slice(KEM_ID); + buf.extend_from_slice(&pubkey.serialize_uncompressed()); + buf.extend_from_slice(SYMMETRIC_LEN); + buf.extend_from_slice(SYMMETRIC_KDF_AEAD); + + ohttp::KeyConfig::decode(&buf).map(Self).map_err(ParseOhttpKeysError::DecodeKeyConfig) } } @@ -424,6 +453,8 @@ impl serde::Serialize for OhttpKeys { #[derive(Debug)] pub enum ParseOhttpKeysError { + InvalidFormat, + InvalidPublicKey, DecodeBase64(bitcoin::base64::DecodeError), DecodeKeyConfig(ohttp::Error), } @@ -431,6 +462,8 @@ pub enum ParseOhttpKeysError { impl std::fmt::Display for ParseOhttpKeysError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + ParseOhttpKeysError::InvalidFormat => write!(f, "Invalid format"), + ParseOhttpKeysError::InvalidPublicKey => write!(f, "Invalid public key"), ParseOhttpKeysError::DecodeBase64(e) => write!(f, "Failed to decode base64: {}", e), ParseOhttpKeysError::DecodeKeyConfig(e) => write!(f, "Failed to decode KeyConfig: {}", e), @@ -443,6 +476,7 @@ impl std::error::Error for ParseOhttpKeysError { match self { ParseOhttpKeysError::DecodeBase64(e) => Some(e), ParseOhttpKeysError::DecodeKeyConfig(e) => Some(e), + ParseOhttpKeysError::InvalidFormat | ParseOhttpKeysError::InvalidPublicKey => None, } } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 6ed9292e..5bef15a3 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -105,7 +105,7 @@ mod integration { #[tokio::test] async fn test_bad_ohttp_keys() { let bad_ohttp_keys = - OhttpKeys::from_str("AQAWBG3fkg7fQCN-bafc-BEJOSnDfq8k1M9Cy1kgQZX42GVOvI0bWVAciTaJCy2A_wy7R7VxtU88xej692bv0uXgt98ABAABAAM") + OhttpKeys::from_str("AQO6SMScPUqSo60A7MY6Ak2hDO0CGAxz7BLYp60syRu0gw") .expect("Invalid OhttpKeys"); let (cert, key) = local_cert_key(); From 76000be82ad5d0ae74bbccdd9b94784ddb2eecd1 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 10 Sep 2024 13:40:02 -0400 Subject: [PATCH 6/7] Take receiver public key from typestate Before, it was passed as aad, but it has already been transmitted from the URI and can be passed from the typestate machine. --- payjoin-directory/src/lib.rs | 3 ++- payjoin/src/send/mod.rs | 10 +++++--- payjoin/src/v2.rs | 49 ++++++++++++++++++++---------------- 3 files changed, 36 insertions(+), 26 deletions(-) diff --git a/payjoin-directory/src/lib.rs b/payjoin-directory/src/lib.rs index 31408368..875ff9fb 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -15,7 +15,7 @@ use hyper::{Method, Request, Response, StatusCode, Uri}; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; use tokio::sync::Mutex; -use tracing::{debug, error, trace}; +use tracing::{debug, error, info, trace}; pub const DEFAULT_DIR_PORT: u16 = 8080; pub const DEFAULT_DB_HOST: &str = "localhost:6379"; @@ -138,6 +138,7 @@ fn init_ohttp() -> Result { // create or read from file let server_config = ohttp::KeyConfig::new(KEY_ID, KEM, Vec::from(SYMMETRIC))?; + info!("Initialized a new OHTTP Key Configuration. GET /ohttp-keys to fetch it."); Ok(ohttp::Server::new(server_config)?) } diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index b2778c7f..a1f2ee31 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -312,7 +312,7 @@ impl RequestContext { Err(e) => { log::warn!("Failed to extract `rs` pubkey, falling back to v1: {}", e); let (req, context_v1) = self.extract_v1()?; - Ok((req, ContextV2 { context_v1, e: None, ohttp_res: None })) + Ok((req, ContextV2 { context_v1, rs: None, e: None, ohttp_res: None })) } } } @@ -355,6 +355,7 @@ impl RequestContext { sequence: self.sequence, min_fee_rate: self.min_fee_rate, }, + rs: Some(self.extract_rs_pubkey()?), e: Some(self.e.clone()), ohttp_res: Some(ohttp_res), }, @@ -402,6 +403,7 @@ pub struct ContextV1 { #[cfg(feature = "v2")] pub struct ContextV2 { context_v1: ContextV1, + rs: Option, e: Option, ohttp_res: Option, } @@ -437,8 +439,8 @@ impl ContextV2 { self, response: &mut impl std::io::Read, ) -> Result, ResponseError> { - match (self.ohttp_res, self.e) { - (Some(ohttp_res), Some(e)) => { + match (self.ohttp_res, self.rs, self.e) { + (Some(ohttp_res), Some(rs), Some(e)) => { let mut res_buf = Vec::new(); response.read_to_end(&mut res_buf).map_err(InternalValidationError::Io)?; let response = crate::v2::ohttp_decapsulate(ohttp_res, &res_buf) @@ -448,7 +450,7 @@ impl ContextV2 { http::StatusCode::ACCEPTED => return Ok(None), _ => return Err(InternalValidationError::UnexpectedStatusCode)?, }; - let psbt = crate::v2::decrypt_message_b(&body, e) + let psbt = crate::v2::decrypt_message_b(&body, rs, e) .map_err(InternalValidationError::Hpke)?; let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index 026f77c7..c04f580b 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -11,8 +11,9 @@ use hpke::rand_core::OsRng; use hpke::{Deserializable, OpModeR, OpModeS, Serializable}; pub const PADDED_MESSAGE_BYTES: usize = 7168; -pub const PADDED_PLAINTEXT_LENGTH: usize = PADDED_MESSAGE_BYTES - UNCOMPRESSED_PUBLIC_KEY_SIZE * 2; - +pub const PADDED_PLAINTEXT_A_LENGTH: usize = + PADDED_MESSAGE_BYTES - UNCOMPRESSED_PUBLIC_KEY_SIZE * 2; +pub const PADDED_PLAINTEXT_B_LENGTH: usize = PADDED_MESSAGE_BYTES - UNCOMPRESSED_PUBLIC_KEY_SIZE; pub const INFO_A: &[u8] = b"PjV2MsgA"; pub const INFO_B: &[u8] = b"PjV2MsgB"; @@ -132,7 +133,7 @@ pub fn encrypt_message_a( &mut OsRng, )?; let aad = pk.to_bytes().to_vec(); - let plaintext = pad_plaintext(&mut plaintext)?; + let plaintext = pad_plaintext(&mut plaintext, PADDED_PLAINTEXT_A_LENGTH)?; let ciphertext = encryption_context.seal(plaintext, &aad)?; let mut message_a = encapsulated_key.to_bytes().to_vec(); message_a.extend(&aad); @@ -174,36 +175,36 @@ pub fn encrypt_message_b( INFO_B, &mut OsRng, )?; - let aad = pk.to_bytes().to_vec(); - let plaintext = pad_plaintext(&mut plaintext)?; - let ciphertext = encryption_context.seal(plaintext, &aad)?; + let plaintext = pad_plaintext(&mut plaintext, PADDED_PLAINTEXT_B_LENGTH)?; + let ciphertext = encryption_context.seal(plaintext, &[])?; let mut message_b = encapsulated_key.to_bytes().to_vec(); - message_b.extend(&aad); message_b.extend(&ciphertext); Ok(message_b.to_vec()) } #[cfg(feature = "send")] -pub fn decrypt_message_b(message_b: &[u8], sender_sk: HpkeSecretKey) -> Result, HpkeError> { +pub fn decrypt_message_b( + message_b: &[u8], + receiver_pk: HpkePublicKey, + sender_sk: HpkeSecretKey, +) -> Result, HpkeError> { let enc = message_b.get(..65).ok_or(HpkeError::PayloadTooShort)?; - let enc = EncappedKey::from_bytes(enc).unwrap(); - let aad = message_b.get(65..130).ok_or(HpkeError::PayloadTooShort)?; - let pk_s = PublicKey::from_bytes(aad)?; + let enc = EncappedKey::from_bytes(enc)?; let mut decryption_ctx = hpke::setup_receiver::< ChaCha20Poly1305, HkdfSha256, SecpK256HkdfSha256, - >(&OpModeR::Auth(pk_s), &sender_sk.0, &enc, INFO_B)?; + >(&OpModeR::Auth(receiver_pk.0), &sender_sk.0, &enc, INFO_B)?; let plaintext = - decryption_ctx.open(message_b.get(130..).ok_or(HpkeError::PayloadTooShort)?, aad)?; + decryption_ctx.open(message_b.get(65..).ok_or(HpkeError::PayloadTooShort)?, &[])?; Ok(plaintext) } -fn pad_plaintext(msg: &mut Vec) -> Result<&[u8], HpkeError> { - if msg.len() > PADDED_PLAINTEXT_LENGTH { - return Err(HpkeError::PayloadTooLarge); +fn pad_plaintext(msg: &mut Vec, padded_length: usize) -> Result<&[u8], HpkeError> { + if msg.len() > padded_length { + return Err(HpkeError::PayloadTooLarge { actual: msg.len(), max: padded_length }); } - msg.resize(PADDED_PLAINTEXT_LENGTH, 0); + msg.resize(padded_length, 0); Ok(msg) } @@ -213,7 +214,7 @@ pub enum HpkeError { Secp256k1(bitcoin::secp256k1::Error), Hpke(hpke::HpkeError), InvalidKeyLength, - PayloadTooLarge, + PayloadTooLarge { actual: usize, max: usize }, PayloadTooShort, } @@ -232,8 +233,13 @@ impl fmt::Display for HpkeError { match &self { Hpke(e) => e.fmt(f), InvalidKeyLength => write!(f, "Invalid Length"), - PayloadTooLarge => - write!(f, "Plaintext too large, max size is {} bytes", PADDED_PLAINTEXT_LENGTH), + PayloadTooLarge { actual, max } => { + write!( + f, + "Plaintext too large, max size is {} bytes, actual size is {} bytes", + max, actual + ) + } PayloadTooShort => write!(f, "Payload too small"), Secp256k1(e) => e.fmt(f), } @@ -246,7 +252,8 @@ impl error::Error for HpkeError { match &self { Hpke(e) => Some(e), - InvalidKeyLength | PayloadTooLarge | PayloadTooShort => None, + PayloadTooLarge { .. } => None, + InvalidKeyLength | PayloadTooShort => None, Secp256k1(e) => Some(e), } } From 3b9769bd2f97de7e67afd3e08b62f838bf6d7904 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 19 Sep 2024 11:48:20 -0400 Subject: [PATCH 7/7] Add HpkeKeyPair abstraction --- payjoin/src/receive/v2/mod.rs | 19 ++++++++++--------- payjoin/src/send/mod.rs | 2 +- payjoin/src/v2.rs | 26 ++++++++++++++++++++------ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/payjoin/src/receive/v2/mod.rs b/payjoin/src/receive/v2/mod.rs index 8e32ed11..91fdbe69 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -17,7 +17,7 @@ use super::{ }; use crate::psbt::PsbtExt; use crate::receive::optional_parameters::Params; -use crate::v2::{HpkePublicKey, HpkeSecretKey, OhttpEncapsulationError}; +use crate::v2::{HpkeKeyPair, HpkePublicKey, OhttpEncapsulationError}; use crate::{OhttpKeys, PjUriBuilder, Request}; pub(crate) mod error; @@ -33,7 +33,7 @@ struct SessionContext { ohttp_keys: OhttpKeys, expiry: SystemTime, ohttp_relay: url::Url, - s: (HpkeSecretKey, HpkePublicKey), + s: HpkeKeyPair, e: Option, } @@ -85,7 +85,7 @@ impl SessionInitializer { ohttp_relay, expiry: SystemTime::now() + expire_after.unwrap_or(TWENTY_FOUR_HOURS_DEFAULT_EXPIRY), - s: crate::v2::gen_keypair(), + s: HpkeKeyPair::gen_keypair(), e: None, }, } @@ -93,7 +93,7 @@ impl SessionInitializer { pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), Error> { let url = self.context.ohttp_relay.clone(); - let subdirectory = subdir_path_from_pubkey(&self.context.s.1); + let subdirectory = subdir_path_from_pubkey(self.context.s.public_key()); let (body, ctx) = crate::v2::ohttp_encapsulate( &mut self.context.ohttp_keys, "POST", @@ -192,7 +192,8 @@ impl ActiveSession { } fn extract_proposal_from_v2(&mut self, response: Vec) -> Result { - let (payload_bytes, e) = crate::v2::decrypt_message_a(&response, self.context.s.0.clone())?; + let (payload_bytes, e) = + crate::v2::decrypt_message_a(&response, self.context.s.secret_key().clone())?; self.context.e = Some(e); let payload = String::from_utf8(payload_bytes).map_err(InternalRequestError::Utf8)?; Ok(self.unchecked_from_payload(payload)?) @@ -252,7 +253,7 @@ impl ActiveSession { } /// The per-session public key to use as an identifier - pub fn id(&self) -> [u8; 33] { self.context.s.1.to_compressed_bytes() } + pub fn id(&self) -> [u8; 33] { self.context.s.public_key().to_compressed_bytes() } } /// The sender's original PSBT and optional parameters @@ -532,11 +533,11 @@ impl PayjoinProposal { Some(e) => { let payjoin_bytes = self.inner.payjoin_psbt.serialize(); log::debug!("THERE IS AN e: {:?}", e); - crate::v2::encrypt_message_b(payjoin_bytes, self.context.s.clone(), e) + crate::v2::encrypt_message_b(payjoin_bytes, &self.context.s, e) } None => Ok(self.extract_v1_req().as_bytes().to_vec()), }?; - let subdir_path = subdir_path_from_pubkey(&self.context.s.1); + let subdir_path = subdir_path_from_pubkey(self.context.s.public_key()); let post_payjoin_target = self.context.directory.join(&subdir_path).map_err(|e| Error::Server(e.into()))?; log::debug!("Payjoin post target: {}", post_payjoin_target.as_str()); @@ -602,7 +603,7 @@ mod test { ), ohttp_relay: url::Url::parse("https://relay.com").unwrap(), expiry: SystemTime::now() + Duration::from_secs(60), - s: crate::v2::gen_keypair(), + s: HpkeKeyPair::gen_keypair(), e: None, }, }; diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index a1f2ee31..2e4b0abf 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -242,7 +242,7 @@ impl<'a> RequestBuilder<'a> { sequence, min_fee_rate: self.min_fee_rate, #[cfg(feature = "v2")] - e: crate::v2::gen_keypair().0, + e: crate::v2::HpkeKeyPair::gen_keypair().secret_key().clone(), }) } } diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index c04f580b..6c62f3ef 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -9,6 +9,7 @@ use hpke::kdf::HkdfSha256; use hpke::kem::SecpK256HkdfSha256; use hpke::rand_core::OsRng; use hpke::{Deserializable, OpModeR, OpModeS, Serializable}; +use serde::{Deserialize, Serialize}; pub const PADDED_MESSAGE_BYTES: usize = 7168; pub const PADDED_PLAINTEXT_A_LENGTH: usize = @@ -23,9 +24,20 @@ pub type EncappedKey = ::EncappedKey; fn sk_to_pk(sk: &SecretKey) -> PublicKey { ::sk_to_pk(sk) } -pub(crate) fn gen_keypair() -> (HpkeSecretKey, HpkePublicKey) { - let (sk, pk) = ::gen_keypair(&mut OsRng); - (HpkeSecretKey(sk), HpkePublicKey(pk)) +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HpkeKeyPair(pub HpkeSecretKey, pub HpkePublicKey); + +impl From for (HpkeSecretKey, HpkePublicKey) { + fn from(value: HpkeKeyPair) -> Self { (value.0, value.1) } +} + +impl HpkeKeyPair { + pub fn gen_keypair() -> Self { + let (sk, pk) = ::gen_keypair(&mut OsRng); + Self(HpkeSecretKey(sk), HpkePublicKey(pk)) + } + pub fn secret_key(&self) -> &HpkeSecretKey { &self.0 } + pub fn public_key(&self) -> &HpkePublicKey { &self.1 } } #[derive(Clone, PartialEq, Eq)] @@ -164,13 +176,15 @@ pub fn decrypt_message_a( #[cfg(feature = "receive")] pub fn encrypt_message_b( mut plaintext: Vec, - receiver_keypair: (HpkeSecretKey, HpkePublicKey), + receiver_keypair: &HpkeKeyPair, sender_pk: &HpkePublicKey, ) -> Result, HpkeError> { - let pk = sk_to_pk(&receiver_keypair.0 .0); let (encapsulated_key, mut encryption_context) = hpke::setup_sender::( - &OpModeS::Auth((receiver_keypair.0 .0, pk.clone())), + &OpModeS::Auth(( + receiver_keypair.secret_key().0.clone(), + receiver_keypair.public_key().0.clone(), + )), &sender_pk.0, INFO_B, &mut OsRng,