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/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/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-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..875ff9fb 100644 --- a/payjoin-directory/src/lib.rs +++ b/payjoin-directory/src/lib.rs @@ -138,9 +138,7 @@ 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); + info!("Initialized a new OHTTP Key Configuration. GET /ohttp-keys to fetch it."); Ok(ohttp::Server::new(server_config)?) } 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..91fdbe69 100644 --- a/payjoin/src/receive/v2/mod.rs +++ b/payjoin/src/receive/v2/mod.rs @@ -1,17 +1,13 @@ 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::secp256k1::{rand, PublicKey}; use bitcoin::{Address, Amount, FeeRate, OutPoint, Script, TxOut}; -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}; @@ -21,23 +17,33 @@ use super::{ }; use crate::psbt::PsbtExt; use crate::receive::optional_parameters::Params; -use crate::v2::OhttpEncapsulationError; +use crate::v2::{HpkeKeyPair, HpkePublicKey, OhttpEncapsulationError}; use crate::{OhttpKeys, PjUriBuilder, Request}; 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, ohttp_keys: OhttpKeys, expiry: SystemTime, ohttp_relay: url::Url, - s: bitcoin::secp256k1::Keypair, - e: Option, + s: HpkeKeyPair, + 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 @@ -70,8 +76,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 +85,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: HpkeKeyPair::gen_keypair(), e: None, }, } @@ -89,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.public_key()); + let subdirectory = subdir_path_from_pubkey(self.context.s.public_key()); let (body, ctx) = crate::v2::ohttp_encapsulate( &mut self.context.ohttp_keys, "POST", @@ -125,8 +129,8 @@ 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: &HpkePublicKey) -> String { + BASE64_URL_SAFE_NO_PAD.encode(pubkey.to_compressed_bytes()) } /// An active payjoin V2 session, allowing for polled requests to the @@ -189,7 +193,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())?; + 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)?) @@ -237,7 +241,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.id(); let pubkey_base64 = BASE64_URL_SAFE_NO_PAD.encode(pubkey); let mut url = self.context.directory.clone(); { @@ -249,7 +253,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) -> [u8; 33] { self.context.s.public_key().to_compressed_bytes() } } /// The sender's original PSBT and optional parameters @@ -525,15 +529,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, 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.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()); @@ -572,146 +576,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 { @@ -739,10 +603,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: HpkeKeyPair::gen_keypair(), e: None, }, }; diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 681cad51..0c955199 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(crate::v2::HpkeError), } #[cfg(feature = "v2")] diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 69275f6b..2e4b0abf 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -27,24 +27,18 @@ 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}; #[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; 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 +232,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,12 +242,13 @@ impl<'a> RequestBuilder<'a> { sequence, min_fee_rate: self.min_fee_rate, #[cfg(feature = "v2")] - e, + e: crate::v2::HpkeKeyPair::gen_keypair().secret_key().clone(), }) } } -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "v2", derive(Serialize, Deserialize))] pub struct RequestContext { psbt: Psbt, endpoint: Url, @@ -271,12 +259,9 @@ pub struct RequestContext { sequence: Sequence, payee: ScriptBuf, #[cfg(feature = "v2")] - e: bitcoin::secp256k1::SecretKey, + 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> { @@ -327,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 })) } } } @@ -340,7 +325,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 +335,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 +355,15 @@ impl RequestContext { sequence: self.sequence, min_fee_rate: self.min_fee_rate, }, - e: Some(self.e), + rs: Some(self.extract_rs_pubkey()?), + 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,131 +378,13 @@ impl RequestContext { .decode(subdirectory) .map_err(ParseSubdirectoryError::SubdirectoryNotBase64)?; - bitcoin::secp256k1::PublicKey::from_slice(&pubkey_bytes) + HpkePublicKey::from_compressed_bytes(&pubkey_bytes) .map_err(ParseSubdirectoryError::SubdirectoryInvalidPubkey) } 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.secret_bytes())?; - 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" => { - let secret_bytes: Vec = map.next_value()?; - e = Some( - bitcoin::secp256k1::SecretKey::from_slice(&secret_bytes) - .map_err(de::Error::custom)?, - ); - } - _ => 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. @@ -535,7 +403,8 @@ pub struct ContextV1 { #[cfg(feature = "v2")] pub struct ContextV2 { context_v1: ContextV1, - e: Option, + rs: Option, + e: Option, ohttp_res: Option, } @@ -570,19 +439,19 @@ 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) .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, rs, 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 +978,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 +993,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/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 5df401e4..6c62f3ef 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -3,100 +3,222 @@ 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}; - -pub const PADDED_MESSAGE_BYTES: usize = 7168; // 7KB - -/// 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 +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}; +use serde::{Deserialize, Serialize}; + +pub const PADDED_MESSAGE_BYTES: usize = 7168; +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"; + +pub type SecretKey = ::PrivateKey; +pub type PublicKey = ::PublicKey; +pub type EncappedKey = ::EncappedKey; + +fn sk_to_pk(sk: &SecretKey) -> PublicKey { ::sk_to_pk(sk) } + +#[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)] +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 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(), + )?)) + } +} + +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, + mut 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 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); + message_a.extend(&ciphertext); + 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( + mut plaintext: Vec, + receiver_keypair: &HpkeKeyPair, + sender_pk: &HpkePublicKey, +) -> Result, HpkeError> { + let (encapsulated_key, mut encryption_context) = + hpke::setup_sender::( + &OpModeS::Auth(( + receiver_keypair.secret_key().0.clone(), + receiver_keypair.public_key().0.clone(), + )), + &sender_pk.0, + INFO_B, + &mut OsRng, + )?; + 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(&ciphertext); + 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) -} - -fn pad(msg: &mut Vec) -> Result<&[u8], HpkeError> { - if msg.len() > PADDED_MESSAGE_BYTES { - return Err(HpkeError::PayloadTooLarge); - } - while msg.len() < PADDED_MESSAGE_BYTES { - msg.push(0); +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)?; + let mut decryption_ctx = hpke::setup_receiver::< + ChaCha20Poly1305, + HkdfSha256, + SecpK256HkdfSha256, + >(&OpModeR::Auth(receiver_pk.0), &sender_sk.0, &enc, INFO_B)?; + let plaintext = + decryption_ctx.open(message_b.get(65..).ok_or(HpkeError::PayloadTooShort)?, &[])?; + Ok(plaintext) +} + +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_length, 0); Ok(msg) } @@ -104,18 +226,18 @@ fn pad(msg: &mut Vec) -> Result<&[u8], HpkeError> { #[derive(Debug)] pub enum HpkeError { Secp256k1(bitcoin::secp256k1::Error), - ChaCha20Poly1305(chacha20poly1305::aead::Error), + Hpke(hpke::HpkeError), InvalidKeyLength, - PayloadTooLarge, + PayloadTooLarge { actual: usize, max: usize }, PayloadTooShort, } -impl From for HpkeError { - fn from(value: bitcoin::secp256k1::Error) -> Self { Self::Secp256k1(value) } +impl From for HpkeError { + fn from(value: hpke::HpkeError) -> Self { Self::Hpke(value) } } -impl From for HpkeError { - fn from(value: chacha20poly1305::aead::Error) -> Self { Self::ChaCha20Poly1305(value) } +impl From for HpkeError { + fn from(value: bitcoin::secp256k1::Error) -> Self { Self::Secp256k1(value) } } impl fmt::Display for HpkeError { @@ -123,12 +245,17 @@ 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), + 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), } } } @@ -138,8 +265,10 @@ impl error::Error for HpkeError { use HpkeError::*; match &self { + Hpke(e) => Some(e), + PayloadTooLarge { .. } => None, + InvalidKeyLength | PayloadTooShort => None, Secp256k1(e) => Some(e), - ChaCha20Poly1305(_) | InvalidKeyLength | PayloadTooLarge | PayloadTooShort => None, } } } @@ -256,9 +385,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) } } @@ -266,9 +409,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) } } @@ -316,6 +474,8 @@ impl serde::Serialize for OhttpKeys { #[derive(Debug)] pub enum ParseOhttpKeysError { + InvalidFormat, + InvalidPublicKey, DecodeBase64(bitcoin::base64::DecodeError), DecodeKeyConfig(ohttp::Error), } @@ -323,6 +483,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), @@ -335,6 +497,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();