From a1d090c4a1354e94fb4c557cf9d9b71953b38228 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 31 Aug 2023 16:21:25 -0400 Subject: [PATCH] Secure v2 payloads with authenticated encryption --- Cargo.lock | 227 ++++++++++++++++++++++++----------- payjoin-relay/src/main.rs | 4 +- payjoin/Cargo.toml | 3 +- payjoin/src/lib.rs | 3 + payjoin/src/receive/error.rs | 16 +++ payjoin/src/receive/mod.rs | 123 +++++++++++++++---- payjoin/src/send/error.rs | 44 +++++-- payjoin/src/send/mod.rs | 92 +++++++++++--- payjoin/src/v2.rs | 172 ++++++++++++++++++++++++++ payjoin/tests/integration.rs | 162 +++++++++++++++---------- 10 files changed, 662 insertions(+), 184 deletions(-) create mode 100644 payjoin/src/v2.rs diff --git a/Cargo.lock b/Cargo.lock index a0d8cb44..e5805e9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "ahash" version = "0.7.7" @@ -321,6 +331,41 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "3.2.25" @@ -486,6 +531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] @@ -535,6 +581,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb30d70a07a3b04884d2677f06bec33509dc67ca60d92949e5535352d3191dc" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -622,14 +677,14 @@ checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "filetime" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.3.5", - "windows-sys 0.48.0", + "redox_syscall", + "windows-sys 0.52.0", ] [[package]] @@ -924,9 +979,9 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", "http", @@ -1027,20 +1082,29 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "itertools" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "js-sys" @@ -1199,9 +1263,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", @@ -1308,9 +1372,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl-probe" @@ -1358,7 +1428,7 @@ checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.4.1", + "redox_syscall", "smallvec", "windows-targets 0.48.5", ] @@ -1382,6 +1452,7 @@ dependencies = [ "bip21", "bitcoin", "bitcoind", + "chacha20poly1305", "env_logger", "log", "rand", @@ -1547,6 +1618,23 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -1633,19 +1721,10 @@ checksum = "52c4f3084aa3bc7dfbba4eff4fab2a54db4324965d8872ab933565e6fbd83bc6" dependencies = [ "pem", "ring 0.16.20", - "time 0.3.20", + "time 0.3.30", "yasna", ] -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -1657,14 +1736,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.6" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.3.9", - "regex-syntax 0.7.5", + "regex-automata 0.4.3", + "regex-syntax 0.8.2", ] [[package]] @@ -1678,13 +1757,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.9" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.5", + "regex-syntax 0.8.2", ] [[package]] @@ -1695,9 +1774,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.5" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "ring" @@ -1716,9 +1795,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.6" +version = "0.17.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684d5e6e18f669ccebf64a92236bb7db9a34f07be010e3627368182027180866" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" dependencies = [ "cc", "getrandom", @@ -1741,9 +1820,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.5" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af6c4b23d99685a1408194da11270ef8e9809aff951cc70ec9b17350b087e474" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ "const-oid", "digest", @@ -1777,25 +1856,25 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" [[package]] name = "rustix" -version = "0.38.25" +version = "0.38.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc99bc2d4f1fed22595588a013687477aedf3cdcfb26558c559edb67b4d9b22e" +checksum = "72e572a5e8ca657d7366229cdde4bd14c4eb5499a9573d4d366fe1b599daa316" dependencies = [ "bitflags 2.4.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.21.9" +version = "0.21.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629648aced5775d558af50b2b4c7b02983a04b312126d45eeead26e7caa498b9" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" dependencies = [ "log", - "ring 0.17.6", + "ring 0.17.7", "rustls-webpki", "sct", ] @@ -1827,15 +1906,15 @@ version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.6", + "ring 0.17.7", "untrusted 0.9.0", ] [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" [[package]] name = "schannel" @@ -1858,7 +1937,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.6", + "ring 0.17.7", "untrusted 0.9.0", ] @@ -2071,9 +2150,9 @@ dependencies = [ [[package]] name = "sqlformat" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" dependencies = [ "itertools", "nom", @@ -2341,7 +2420,7 @@ checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" dependencies = [ "cfg-if", "fastrand", - "redox_syscall 0.4.1", + "redox_syscall", "rustix", "windows-sys 0.48.0", ] @@ -2430,19 +2509,21 @@ dependencies = [ [[package]] name = "time" -version = "0.3.20" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0cbfecb4d19b5ea75bb31ad904eb5b9fa13f21079c3b92017ebdf4999a5890" +checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" dependencies = [ + "deranged", + "powerfmt", "serde", "time-core", ] [[package]] name = "time-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tinyvec" @@ -2461,9 +2542,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.34.0" +version = "1.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c014766411e834f7af5b8f4cf46257aab4036ca95e9d2c144a10f59ad6f5b9" +checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" dependencies = [ "backtrace", "bytes", @@ -2603,9 +2684,9 @@ dependencies = [ [[package]] name = "try-lock" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" @@ -2621,9 +2702,9 @@ checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "unicode-bidi" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" [[package]] name = "unicode-ident" @@ -2652,6 +2733,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -2990,9 +3081,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" [[package]] name = "xattr" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4686009f71ff3e5c4dbcf1a282d0a44db3f021ba69350cd42086b3e5f1c6985" +checksum = "fbc6ab6ec1907d1a901cdbcd2bd4cb9e7d64ce5c9739cbb97d3c391acd8c7fae" dependencies = [ "libc", ] @@ -3012,23 +3103,23 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" dependencies = [ - "time 0.3.20", + "time 0.3.30", ] [[package]] name = "zerocopy" -version = "0.7.27" +version = "0.7.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43de342578a3a14a9314a2dab1942cbfcbe5686e1f91acdc513058063eafe18" +checksum = "306dca4455518f1f31635ec308b6b3e4eb1b11758cefafc782827d0aa7acb5c7" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.27" +version = "0.7.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1012d89e3acb79fad7a799ce96866cfb8098b74638465ea1b1533d35900ca90" +checksum = "be912bf68235a88fbefd1b73415cb218405958d1655b2ece9035a19920bdf6ba" dependencies = [ "proc-macro2", "quote", diff --git a/payjoin-relay/src/main.rs b/payjoin-relay/src/main.rs index dc663053..44dd99a3 100644 --- a/payjoin-relay/src/main.rs +++ b/payjoin-relay/src/main.rs @@ -153,9 +153,7 @@ async fn post_enroll(body: Body) -> Result, HandlerError> { String::from_utf8(bytes.to_vec()).map_err(|e| HandlerError::BadRequest(e.into()))?; let pubkey_bytes: Vec = base64::decode_config(base64_id, b64_config) .map_err(|e| HandlerError::BadRequest(e.into()))?; - let pubkey_string = - String::from_utf8(pubkey_bytes).map_err(|e| HandlerError::BadRequest(e.into()))?; - let pubkey = bitcoin::secp256k1::PublicKey::from_str(&pubkey_string) + let pubkey = bitcoin::secp256k1::PublicKey::from_slice(&pubkey_bytes) .map_err(|e| HandlerError::BadRequest(e.into()))?; tracing::info!("Enrolled valid pubkey: {:?}", pubkey); Ok(Response::builder().status(StatusCode::NO_CONTENT).body(Body::empty())?) diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index ee2e4f1f..77e2194d 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,11 +18,12 @@ exclude = ["tests"] send = [] receive = ["rand"] base64 = ["bitcoin/base64"] -v2 = [] +v2 = ["bitcoin/rand-std", "chacha20poly1305"] [dependencies] bitcoin = { version = "0.30.0", features = ["base64"] } bip21 = "0.3.1" +chacha20poly1305 = { version = "0.10.1", optional = true } log = { version = "0.4.14"} rand = { version = "0.8.4", optional = true } url = "2.2.2" diff --git a/payjoin/src/lib.rs b/payjoin/src/lib.rs index 284d3ff3..969390c3 100644 --- a/payjoin/src/lib.rs +++ b/payjoin/src/lib.rs @@ -27,6 +27,9 @@ pub use crate::receive::Error; #[cfg(feature = "send")] pub mod send; +#[cfg(feature = "v2")] +pub mod v2; + #[cfg(any(feature = "send", feature = "receive"))] pub(crate) mod input_type; #[cfg(any(feature = "send", feature = "receive"))] diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index 9bfdfcf1..5de611bd 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -7,6 +7,9 @@ pub enum Error { BadRequest(RequestError), // To be returned as HTTP 500 Server(Box), + // V2 d/encapsulation failed + #[cfg(feature = "v2")] + V2(crate::v2::Error), } impl fmt::Display for Error { @@ -14,6 +17,8 @@ impl fmt::Display for Error { match &self { Self::BadRequest(e) => e.fmt(f), Self::Server(e) => write!(f, "Internal Server Error: {}", e), + #[cfg(feature = "v2")] + Self::V2(e) => e.fmt(f), } } } @@ -23,6 +28,8 @@ impl error::Error for Error { match &self { Self::BadRequest(_) => None, Self::Server(e) => Some(e.as_ref()), + #[cfg(feature = "v2")] + Self::V2(e) => Some(e), } } } @@ -31,6 +38,15 @@ impl From for Error { fn from(e: RequestError) -> Self { Error::BadRequest(e) } } +impl From for Error { + fn from(e: InternalRequestError) -> Self { Error::BadRequest(e.into()) } +} + +#[cfg(feature = "v2")] +impl From for Error { + fn from(e: crate::v2::Error) -> Self { Error::V2(e) } +} + /// Error that may occur when the request from sender is malformed. /// /// This is currently opaque type because we aren't sure which variants will stay. diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 88bd36c7..52ff0fff 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -288,6 +288,45 @@ pub trait Headers { fn get_header(&self, key: &str) -> Option<&str>; } +#[cfg(feature = "v2")] +pub struct ProposalContext { + s: bitcoin::secp256k1::KeyPair, +} + +#[cfg(feature = "v2")] +impl ProposalContext { + pub fn new() -> Self { + let secp = bitcoin::secp256k1::Secp256k1::new(); + let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng); + ProposalContext { s: bitcoin::secp256k1::KeyPair::from_secret_key(&secp, &sk) } + } + + pub fn subdirectory(&self) -> String { + let pubkey = &self.s.public_key().serialize(); + let b64_config = + bitcoin::base64::Config::new(bitcoin::base64::CharacterSet::UrlSafe, false); + let pubkey_base64 = bitcoin::base64::encode_config(pubkey, b64_config); + pubkey_base64 + } + + pub fn receive_subdir(&self) -> String { + format!("{}/{}", self.subdirectory(), crate::v2::RECEIVE) + } + + pub fn parse_relay_response( + self, + mut body: impl std::io::Read, + ) -> Result { + let mut buf = Vec::new(); + let _ = body.read_to_end(&mut buf); + let (proposal, e) = + crate::v2::decrypt_message_a(&mut buf, self.s.secret_key()).map_err(Error::V2)?; + let proposal = UncheckedProposal::from_v2_payload(proposal, e)?; + + Ok(proposal) + } +} + /// The sender's original PSBT and optional parameters /// /// This type is used to proces the request. It is returned by @@ -300,27 +339,10 @@ pub trait Headers { pub struct UncheckedProposal { psbt: Psbt, params: Params, + v2_context: Option, } impl UncheckedProposal { - #[cfg(feature = "v2")] - pub fn from_relay_response(mut body: impl std::io::Read) -> Result { - use std::str::FromStr; - - let mut buf = Vec::new(); - let _ = body.read_to_end(&mut buf); - let buf_as_string = String::from_utf8(buf.to_vec()).map_err(InternalRequestError::Utf8)?; - log::debug!("{}", &buf_as_string); - let (query, base64) = buf_as_string.split_once('\n').unwrap_or_default(); - let unchecked_psbt = Psbt::from_str(base64).map_err(InternalRequestError::ParsePsbt)?; - let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; - log::debug!("Received original psbt: {:?}", psbt); - let params = Params::from_query_pairs(url::form_urlencoded::parse(query.as_bytes())) - .map_err(InternalRequestError::SenderParams)?; - log::debug!("Received request with params: {:?}", params); - Ok(Self { psbt, params }) - } - pub fn from_request( mut body: impl std::io::Read, query: &str, @@ -357,7 +379,28 @@ impl UncheckedProposal { // TODO check that params are valid for the request's Original PSBT - Ok(UncheckedProposal { psbt, params }) + Ok(UncheckedProposal { psbt, params, v2_context: None }) + } + + #[cfg(feature = "v2")] + fn from_v2_payload( + body: Vec, + e: bitcoin::secp256k1::PublicKey, + ) -> Result { + use std::str::FromStr; + + let buf_as_string = String::from_utf8(body).map_err(InternalRequestError::Utf8)?; + log::debug!("{}", &buf_as_string); + let (padded_base64, query) = buf_as_string.split_once('\n').unwrap_or_default(); + let base64 = padded_base64.trim_start_matches('\0'); + let unchecked_psbt = Psbt::from_str(base64).map_err(InternalRequestError::ParsePsbt)?; + let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", psbt); + let params = Params::from_query_pairs(url::form_urlencoded::parse(query.as_bytes())) + .map_err(InternalRequestError::SenderParams)?; + log::debug!("Received request with params: {:?}", params); + let v2_context = Some(e); + Ok(Self { psbt, params, v2_context }) } /// The Sender's Original PSBT @@ -382,7 +425,11 @@ impl UncheckedProposal { can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, ) -> Result { if can_broadcast(&self.psbt.clone().extract_tx())? { - Ok(MaybeInputsOwned { psbt: self.psbt, params: self.params }) + Ok(MaybeInputsOwned { + psbt: self.psbt, + params: self.params, + v2_context: self.v2_context, + }) } else { Err(Error::BadRequest(InternalRequestError::OriginalPsbtNotBroadcastable.into())) } @@ -394,7 +441,7 @@ impl UncheckedProposal { /// So-called "non-interactive" receivers, like payment processors, that allow arbitrary requests are otherwise vulnerable to probing attacks. /// Those receivers call `extract_tx_to_check_broadcast()` and `attest_tested_and_scheduled_broadcast()` after making those checks downstream. pub fn assume_interactive_receiver(self) -> MaybeInputsOwned { - MaybeInputsOwned { psbt: self.psbt, params: self.params } + MaybeInputsOwned { psbt: self.psbt, params: self.params, v2_context: self.v2_context } } } @@ -404,6 +451,7 @@ impl UncheckedProposal { pub struct MaybeInputsOwned { psbt: Psbt, params: Params, + v2_context: Option, } impl MaybeInputsOwned { @@ -437,7 +485,11 @@ impl MaybeInputsOwned { } err?; - Ok(MaybeMixedInputScripts { psbt: self.psbt, params: self.params }) + Ok(MaybeMixedInputScripts { + psbt: self.psbt, + params: self.params, + v2_context: self.v2_context, + }) } } @@ -447,6 +499,7 @@ impl MaybeInputsOwned { pub struct MaybeMixedInputScripts { psbt: Psbt, params: Params, + v2_context: Option, } impl MaybeMixedInputScripts { @@ -489,7 +542,7 @@ impl MaybeMixedInputScripts { })?; } - Ok(MaybeInputsSeen { psbt: self.psbt, params: self.params }) + Ok(MaybeInputsSeen { psbt: self.psbt, params: self.params, v2_context: self.v2_context }) } } @@ -499,6 +552,7 @@ impl MaybeMixedInputScripts { pub struct MaybeInputsSeen { psbt: Psbt, params: Params, + v2_context: Option, } impl MaybeInputsSeen { /// Make sure that the original transaction inputs have never been seen before. @@ -521,7 +575,7 @@ impl MaybeInputsSeen { } })?; - Ok(OutputsUnknown { psbt: self.psbt, params: self.params }) + Ok(OutputsUnknown { psbt: self.psbt, params: self.params, v2_context: self.v2_context }) } } @@ -532,6 +586,7 @@ impl MaybeInputsSeen { pub struct OutputsUnknown { psbt: Psbt, params: Params, + v2_context: Option, } impl OutputsUnknown { @@ -562,6 +617,7 @@ impl OutputsUnknown { payjoin_psbt: self.psbt, params: self.params, owned_vouts, + v2_context: self.v2_context, }) } } @@ -572,6 +628,7 @@ pub struct ProvisionalProposal { payjoin_psbt: Psbt, params: Params, owned_vouts: Vec, + v2_context: Option, } impl ProvisionalProposal { @@ -800,6 +857,7 @@ impl ProvisionalProposal { payjoin_psbt: self.payjoin_psbt, owned_vouts: self.owned_vouts, params: self.params, + v2_context: self.v2_context, }) } @@ -820,6 +878,7 @@ pub struct PayjoinProposal { payjoin_psbt: Psbt, params: Params, owned_vouts: Vec, + v2_context: Option, } impl PayjoinProposal { @@ -834,6 +893,22 @@ impl PayjoinProposal { pub fn owned_vouts(&self) -> &Vec { &self.owned_vouts } pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } + + #[cfg(feature = "v2")] + pub fn serialize_body(&self) -> Result, Error> { + match self.v2_context { + Some(e) => { + let mut payjoin_bytes = self.payjoin_psbt.serialize(); + crate::v2::encrypt_message_b(&mut payjoin_bytes, e).map_err(Error::V2) + } + None => Ok(bitcoin::base64::encode(self.payjoin_psbt.serialize()).as_bytes().to_vec()), + } + } + + #[cfg(not(feature = "v2"))] + pub fn serialize_body(&self) -> Vec { + bitcoin::base64::encode(self.payjoin_psbt.serialize()).as_bytes().to_vec() + } } #[cfg(test)] diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 8aeebfc5..058dca83 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -16,13 +16,22 @@ pub struct ValidationError { #[derive(Debug)] pub(crate) enum InternalValidationError { - Psbt(bitcoin::psbt::PsbtParseError), + PsbtParse(bitcoin::psbt::PsbtParseError), Io(std::io::Error), InvalidInputType(InputTypeError), InvalidProposedInput(crate::psbt::PrevTxOutError), - VersionsDontMatch { proposed: i32, original: i32 }, - LockTimesDontMatch { proposed: LockTime, original: LockTime }, - SenderTxinSequenceChanged { proposed: Sequence, original: Sequence }, + VersionsDontMatch { + proposed: i32, + original: i32, + }, + LockTimesDontMatch { + proposed: LockTime, + original: LockTime, + }, + SenderTxinSequenceChanged { + proposed: Sequence, + original: Sequence, + }, SenderTxinContainsNonWitnessUtxo, SenderTxinContainsWitnessUtxo, SenderTxinContainsFinalScriptSig, @@ -32,7 +41,10 @@ pub(crate) enum InternalValidationError { ReceiverTxinNotFinalized, ReceiverTxinMissingUtxoInfo, MixedSequence, - MixedInputTypes { proposed: InputType, original: InputType }, + MixedInputTypes { + proposed: InputType, + original: InputType, + }, MissingOrShuffledInputs, TxOutContainsKeyPaths, FeeContributionExceedsMaximum, @@ -44,6 +56,10 @@ pub(crate) enum InternalValidationError { PayeeTookContributedFee, FeeContributionPaysOutputSizeIncrease, FeeRateBelowMinimum, + #[cfg(feature = "v2")] + V2(crate::v2::Error), + #[cfg(feature = "v2")] + Psbt(bitcoin::psbt::Error), } impl From for ValidationError { @@ -58,7 +74,7 @@ impl fmt::Display for ValidationError { use InternalValidationError::*; match &self.internal { - Psbt(e) => write!(f, "couldn't decode PSBT: {}", e), + PsbtParse(e) => write!(f, "couldn't decode PSBT: {}", e), Io(e) => write!(f, "couldn't read PSBT: {}", e), InvalidInputType(e) => write!(f, "invalid transaction input type: {}", e), InvalidProposedInput(e) => write!(f, "invalid proposed transaction input: {}", e), @@ -86,6 +102,10 @@ impl fmt::Display for ValidationError { PayeeTookContributedFee => write!(f, "payee tried to take fee contribution for himself"), FeeContributionPaysOutputSizeIncrease => write!(f, "fee contribution pays for additional outputs"), FeeRateBelowMinimum => write!(f, "the fee rate of proposed transaction is below minimum"), + #[cfg(feature = "v2")] + V2(e) => write!(f, "v2 error: {}", e), + #[cfg(feature = "v2")] + Psbt(e) => write!(f, "psbt error: {}", e), } } } @@ -95,7 +115,7 @@ impl std::error::Error for ValidationError { use InternalValidationError::*; match &self.internal { - Psbt(error) => Some(error), + PsbtParse(error) => Some(error), Io(error) => Some(error), InvalidInputType(error) => Some(error), InvalidProposedInput(error) => Some(error), @@ -123,6 +143,10 @@ impl std::error::Error for ValidationError { PayeeTookContributedFee => None, FeeContributionPaysOutputSizeIncrease => None, FeeRateBelowMinimum => None, + #[cfg(feature = "v2")] + V2(error) => Some(error), + #[cfg(feature = "v2")] + Psbt(error) => Some(error), } } } @@ -152,6 +176,8 @@ pub(crate) enum InternalCreateRequestError { UriDoesNotSupportPayjoin, PrevTxOut(crate::psbt::PrevTxOutError), InputType(crate::input_type::InputTypeError), + #[cfg(feature = "v2")] + V2(crate::v2::Error), } impl fmt::Display for CreateRequestError { @@ -174,6 +200,8 @@ impl fmt::Display for CreateRequestError { UriDoesNotSupportPayjoin => write!(f, "the URI does not support payjoin"), PrevTxOut(e) => write!(f, "invalid previous transaction output: {}", e), InputType(e) => write!(f, "invalid input type: {}", e), + #[cfg(feature = "v2")] + V2(e) => write!(f, "v2 error: {}", e), } } } @@ -198,6 +226,8 @@ impl std::error::Error for CreateRequestError { UriDoesNotSupportPayjoin => None, PrevTxOut(error) => Some(error), InputType(error) => Some(error), + #[cfg(feature = "v2")] + V2(error) => Some(error), } } } diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 161a9804..49aa3f95 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -328,7 +328,7 @@ impl<'a> RequestBuilder<'a> { let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin).unwrap(); #[cfg(not(feature = "v2"))] - let request = { + let (req, ctx) = { let url = serialize_url( self.uri.extras._endpoint.into(), disable_output_substitution, @@ -337,11 +337,31 @@ impl<'a> RequestBuilder<'a> { ) .map_err(InternalCreateRequestError::Url)?; let body = psbt.to_string().as_bytes().to_vec(); - Request { url, body } + ( + Request { url, body }, + Context { + original_psbt: psbt, + disable_output_substitution, + fee_contribution, + payee, + input_type, + sequence, + min_fee_rate: self.min_fee_rate, + v2_context: None, + }, + ) }; #[cfg(feature = "v2")] - let request = { + let (req, ctx) = { + let rs_base64 = crate::v2::subdir(self.uri.extras._endpoint.as_str()).to_string(); + log::debug!("rs_base64: {:?}", rs_base64); + let b64_config = + bitcoin::base64::Config::new(bitcoin::base64::CharacterSet::UrlSafe, false); + let rs = bitcoin::base64::decode_config(rs_base64, b64_config).unwrap(); + log::debug!("rs: {:?}", rs.len()); + let rs = bitcoin::secp256k1::PublicKey::from_slice(&rs).unwrap(); + let url = self.uri.extras._endpoint; let body = serialize_v2_body( &psbt, @@ -349,21 +369,24 @@ impl<'a> RequestBuilder<'a> { fee_contribution, self.min_fee_rate, )?; - Request { url, body } + let (body, e) = + crate::v2::encrypt_message_a(body, rs).map_err(InternalCreateRequestError::V2)?; + ( + Request { url, body }, + Context { + original_psbt: psbt, + disable_output_substitution, + fee_contribution, + payee, + input_type, + sequence, + min_fee_rate: self.min_fee_rate, + v2_context: Some(e), + }, + ) }; - Ok(( - request, - Context { - original_psbt: psbt, - disable_output_substitution, - fee_contribution, - payee, - input_type, - sequence, - min_fee_rate: self.min_fee_rate, - }, - )) + Ok((req, ctx)) } } @@ -399,6 +422,7 @@ pub struct Context { input_type: InputType, sequence: Sequence, payee: ScriptBuf, + v2_context: Option, } macro_rules! check_eq { @@ -428,11 +452,40 @@ impl Context { pub fn process_response( self, response: &mut impl std::io::Read, + ) -> Result { + #[cfg(feature = "v2")] + match self.v2_context { + Some(e) => self.process_response_v2(response, e), + None => self.process_response_v1(response), + } + + #[cfg(not(feature = "v2"))] + self.process_response_v1(response) + } + + pub fn process_response_v1( + self, + response: &mut impl std::io::Read, ) -> Result { let mut res_str = String::new(); response.read_to_string(&mut res_str).map_err(InternalValidationError::Io)?; - let proposal = Psbt::from_str(&res_str).map_err(InternalValidationError::Psbt)?; + let proposal = Psbt::from_str(&res_str).map_err(InternalValidationError::PsbtParse)?; + + // process in non-generic function + self.process_proposal(proposal).map(Into::into).map_err(Into::into) + } + #[cfg(feature = "v2")] + pub fn process_response_v2( + self, + response: &mut impl std::io::Read, + e: bitcoin::secp256k1::SecretKey, + ) -> Result { + let mut res_buf = Vec::new(); + response.read_to_end(&mut res_buf).map_err(InternalValidationError::Io)?; + let psbt = + crate::v2::decrypt_message_b(&mut res_buf, e).map_err(InternalValidationError::V2)?; + let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; // process in non-generic function self.process_proposal(proposal).map(Into::into).map_err(Into::into) } @@ -804,8 +857,8 @@ fn serialize_v2_body( ) .map_err(InternalCreateRequestError::Url)?; let query_params = placeholder_url.query().unwrap_or_default(); - let body = psbt.to_string(); - Ok(format!("{}\n{}", query_params, body).into_bytes()) + let base64 = psbt.to_string(); + Ok(format!("{}\n{}", base64, query_params).into_bytes()) } fn serialize_url( @@ -835,6 +888,7 @@ fn serialize_url( #[cfg(test)] mod tests { #[test] + #[cfg(not(feature = "v2"))] fn official_vectors() { use std::str::FromStr; diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs new file mode 100644 index 00000000..0b12eab0 --- /dev/null +++ b/payjoin/src/v2.rs @@ -0,0 +1,172 @@ +use std::{error, fmt}; + +pub const MAX_BUFFER_SIZE: usize = 65536; +pub const RECEIVE: &str = "receive"; +pub const PADDED_MESSAGE_BYTES: usize = 7168; // 7KB + +pub fn subdir(path: &str) -> String { + let subdirectory: String; + + if let Some(pos) = path.rfind('/') { + subdirectory = path[pos + 1..].to_string(); + } else { + subdirectory = path.to_string(); + } + + let pubkey_id: String; + + if let Some(pos) = subdirectory.find('?') { + pubkey_id = subdirectory[..pos].to_string(); + } else { + pubkey_id = subdirectory; + } + pubkey_id +} + +use bitcoin::secp256k1::ecdh::SharedSecret; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; +use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Nonce}; + +/// 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 fn encrypt_message_a( + mut raw_msg: Vec, + s: PublicKey, +) -> Result<(Vec, SecretKey), Error> { + let secp = Secp256k1::new(); + let (e_sec, e_pub) = secp.generate_keypair(&mut OsRng); + let es = SharedSecret::new(&s, &e_sec); + let cipher = ChaCha20Poly1305::new_from_slice(&es.secret_bytes()) + .map_err(|_| InternalError::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, e_sec)) +} + +pub fn decrypt_message_a( + message_a: &mut [u8], + s: SecretKey, +) -> Result<(Vec, PublicKey), Error> { + // let message a = [pubkey/AD][nonce][authentication tag][ciphertext] + let e = PublicKey::from_slice(&message_a[..33])?; + let nonce = Nonce::from_slice(&message_a[33..45]); + let es = SharedSecret::new(&e, &s); + let cipher = ChaCha20Poly1305::new_from_slice(&es.secret_bytes()) + .map_err(|_| InternalError::InvalidKeyLength)?; + let c_t = &message_a[45..]; + let aad = &e.serialize(); + let payload = Payload { msg: c_t, aad }; + let buffer = cipher.decrypt(nonce, payload)?; + Ok((buffer, e)) +} + +pub fn encrypt_message_b(raw_msg: &mut Vec, re_pub: PublicKey) -> Result, Error> { + // 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(|_| InternalError::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 decrypt_message_b(message_b: &mut [u8], e: SecretKey) -> Result, Error> { + // let message b = [pubkey/AD][nonce][authentication tag][ciphertext] + let re = PublicKey::from_slice(&message_b[..33])?; + let nonce = Nonce::from_slice(&message_b[33..45]); + let ee = SharedSecret::new(&re, &e); + let cipher = ChaCha20Poly1305::new_from_slice(&ee.secret_bytes()) + .map_err(|_| InternalError::InvalidKeyLength)?; + let payload = Payload { msg: &message_b[45..], aad: &re.serialize() }; + let buffer = cipher.decrypt(nonce, payload)?; + Ok(buffer) +} + +fn pad(msg: &mut Vec) -> Result<&[u8], Error> { + if msg.len() > PADDED_MESSAGE_BYTES { + return Err(Error(InternalError::PayloadTooLarge)); + } + while msg.len() < PADDED_MESSAGE_BYTES { + msg.push(0); + } + Ok(msg) +} + +/// Error that may occur when de/encrypting or de/capsulating a v2 message. +/// +/// This is currently opaque type because we aren't sure which variants will stay. +/// You can only display it. +#[derive(Debug)] +pub struct Error(InternalError); + +#[derive(Debug)] +pub(crate) enum InternalError { + ParseUrl(url::ParseError), + Secp256k1(bitcoin::secp256k1::Error), + ChaCha20Poly1305(chacha20poly1305::aead::Error), + InvalidKeyLength, + PayloadTooLarge, +} + +impl From for Error { + fn from(value: url::ParseError) -> Self { Self(InternalError::ParseUrl(value)) } +} + +impl From for Error { + fn from(value: bitcoin::secp256k1::Error) -> Self { Self(InternalError::Secp256k1(value)) } +} + +impl From for Error { + fn from(value: chacha20poly1305::aead::Error) -> Self { + Self(InternalError::ChaCha20Poly1305(value)) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use InternalError::*; + + match &self.0 { + ParseUrl(e) => e.fmt(f), + Secp256k1(e) => e.fmt(f), + ChaCha20Poly1305(e) => e.fmt(f), + InvalidKeyLength => write!(f, "Invalid Length"), + PayloadTooLarge => + write!(f, "Payload too large, max size is {} bytes", PADDED_MESSAGE_BYTES), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use InternalError::*; + + match &self.0 { + ParseUrl(e) => Some(e), + Secp256k1(e) => Some(e), + ChaCha20Poly1305(_) | InvalidKeyLength | PayloadTooLarge => None, + } + } +} + +impl From for Error { + fn from(value: InternalError) -> Self { Self(value) } +} diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 5b57d91d..bd94f4a2 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -12,7 +12,7 @@ mod integration { use bitcoind::bitcoincore_rpc::RpcApi; use log::{debug, log_enabled, Level}; use payjoin::bitcoin::base64; - use payjoin::receive::UncheckedProposal; + use payjoin::receive::{PayjoinProposal, UncheckedProposal}; use payjoin::send::RequestBuilder; use payjoin::Uri; @@ -86,7 +86,9 @@ mod integration { headers, ) .unwrap(); - handle_proposal(proposal, receiver) + let psbt = handle_proposal(proposal, receiver); + debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); + base64::encode(&psbt.serialize()) } } @@ -95,6 +97,8 @@ mod integration { use std::process::Stdio; use std::sync::Arc; + use payjoin::receive::{PayjoinProposal, ProposalContext}; + use payjoin::send::Request; use testcontainers::Container; use testcontainers_modules::postgres::Postgres; use testcontainers_modules::testcontainers::clients::Cli; @@ -117,18 +121,17 @@ mod integration { // ********************** // Inside the Receiver: // Enroll with relay - let secp = bitcoin::secp256k1::Secp256k1::new(); - let mut rng = bitcoin::secp256k1::rand::thread_rng(); - let key = bitcoin::secp256k1::KeyPair::new(&secp, &mut rng); - let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); - let pubkey_base64 = base64::encode_config(key.public_key().to_string(), b64_config); - let pk64 = pubkey_base64.clone(); - let enroll = - spawn_blocking(move || http_agent().post(RELAY_URL).send_string(&pk64)).await??; + let receive_context = ProposalContext::new(); + let subdirectory = Arc::new(receive_context.subdirectory()); + let enroll = { + let subdir_clone = subdirectory.clone(); + spawn_blocking(move || http_agent().post(RELAY_URL).send_string(&subdir_clone)) + .await?? + }; assert!(enroll.status() == 204); // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let relay_endpoint = format!("{}/{}", RELAY_URL, &pubkey_base64); + let relay_endpoint = format!("{}/{}", RELAY_URL, &subdirectory); let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); // ********************** @@ -140,14 +143,17 @@ mod integration { .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; log::info!("send fallback v2"); log::debug!("Request: {:#?}", &req.body); - let response = spawn_blocking(move || { - http_agent() - .post(req.url.as_str()) - .set("Content-Type", "text/plain") - .set("Async", "true") - .send_string(String::from_utf8(req.body).unwrap().as_ref()) - }) - .await??; + let response = { + let Request { url, body, .. } = req.clone(); + spawn_blocking(move || { + http_agent() + .post(url.as_str()) + .set("Content-Type", "text/plain") + .set("Async", "true") + .send_bytes(&body) + }) + .await?? + }; log::info!("Response: {:#?}", &response); assert!(response.status() == 202); // no response body yet since we are async and pushed fallback_psbt to the buffer @@ -155,16 +161,31 @@ mod integration { // ********************** // Inside the Receiver: // this data would transit from one party to another over the network in production - let receive_endpoint = format!("{}/{}", RELAY_URL, &pubkey_base64); + let receive_endpoint = format!("{}/{}", RELAY_URL, &subdirectory.clone()); let response = spawn_blocking(move || http_agent().get(&receive_endpoint).call()).await??; - let response = handle_relay_response(response.into_reader(), receiver); + let payjoin_proposal = + handle_relay_response(response.into_reader(), receiver, receive_context); // this response would be returned as http response to the sender + let subdir_clone = subdirectory.clone(); + spawn_blocking(move || { + let payjoin_endpoint = format!("{}/{}/payjoin", RELAY_URL, &subdir_clone); + http_agent() + .post(&payjoin_endpoint) + .send_bytes(&payjoin_proposal.serialize_body().unwrap()) + }) + .await??; // ********************** // Inside the Sender: // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + + // Replay post fallback to get the response + let response = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + let checked_payjoin_proposal_psbt = + ctx.process_response(&mut response.into_reader())?; let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; sender.send_raw_transaction(&payjoin_tx)?; log::info!("sent"); @@ -186,22 +207,19 @@ mod integration { // ********************** // Inside the Receiver: // Enroll with relay - let secp = bitcoin::secp256k1::Secp256k1::new(); - let mut rng = bitcoin::secp256k1::rand::thread_rng(); - let key = bitcoin::secp256k1::KeyPair::new(&secp, &mut rng); - let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); - let pubkey_base64 = base64::encode_config(key.public_key().to_string(), b64_config); - let pk64 = pubkey_base64.clone(); - let enroll = - spawn_blocking(move || http_agent().post(RELAY_URL).send_string(&pk64.clone())) - .await? - .unwrap(); + let receive_context = ProposalContext::new(); + let subdirectory = Arc::new(receive_context.subdirectory()); + let enroll = { + let subdir_clone = subdirectory.clone(); + spawn_blocking(move || http_agent().post(RELAY_URL).send_string(&subdir_clone)) + .await?? + }; assert!(enroll.status() == 204); // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None).unwrap().assume_checked(); - let relay_endpoint = format!("{}/{}", RELAY_URL, &pubkey_base64); + let relay_endpoint = format!("{}/{}", RELAY_URL, &subdirectory); let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); // ********************** @@ -212,14 +230,16 @@ mod integration { let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; log::info!("send fallback v1 to offline receiver fail"); - let req_clone = req.clone(); - let res = spawn_blocking(move || { - http_agent() - .post(req_clone.url.as_str()) - .set("Content-Type", "text/plain") - .send_bytes(&req_clone.body) - }) - .await?; + let res = { + let Request { url, body, .. } = req.clone(); + spawn_blocking(move || { + http_agent() + .post(url.as_str()) + .set("Content-Type", "text/plain") + .send_bytes(&body) + }) + .await? + }; match res { Err(ureq::Error::Status(code, _)) => assert_eq!(code, 503), _ => panic!("Expected response status code 503, found {:?}", res), @@ -228,10 +248,11 @@ mod integration { // ********************** // Inside the Receiver: let receiver_loop = tokio::task::spawn(async move { + let subdirectory = receive_context.subdirectory(); let fallback_psbt_body = loop { - let pk64 = pubkey_base64.clone(); + let subdir_clone = subdirectory.clone(); let response = spawn_blocking(move || { - let receive_endpoint = format!("{}/{}", RELAY_URL, &pk64); + let receive_endpoint = format!("{}/{}", RELAY_URL, &subdir_clone); http_agent().get(&receive_endpoint).call() }) .await??; @@ -250,12 +271,23 @@ mod integration { } }; debug!("handle relay response"); - let response = handle_relay_response(fallback_psbt_body, receiver); + let payjoin_proposal = + handle_relay_response(fallback_psbt_body, receiver, receive_context); + // this response would be returned as http response to the sender + let subdir_clone = subdirectory.clone(); + let body = payjoin_proposal.serialize_body().unwrap(); + spawn_blocking(move || { + let payjoin_endpoint = format!("{}/{}/payjoin", RELAY_URL, &subdir_clone); + http_agent().post(&payjoin_endpoint).send_bytes(&body) + }) + .await??; debug!("Post payjoin_psbt to relay"); // Respond with payjoin psbt within the time window the sender is willing to wait - let payjoin_endpoint = format!("{}/{}/payjoin", RELAY_URL, &pubkey_base64); + let payjoin_endpoint = format!("{}/{}/payjoin", RELAY_URL, &subdirectory); let response = spawn_blocking(move || { - http_agent().post(&payjoin_endpoint).send_string(&response) + http_agent() + .post(&payjoin_endpoint) + .send_bytes(&payjoin_proposal.serialize_body().unwrap()) }) .await??; debug!("POSTed with payjoin_psbt response status {}", response.status()); @@ -266,15 +298,17 @@ mod integration { // ********************** // send fallback v1 to online receiver log::info!("send fallback v1 to online receiver should succeed"); - let req_clone = req.clone(); - let response = spawn_blocking(move || { - http_agent() - .post(req_clone.url.as_str()) - .set("Content-Type", "text/plain") - .send_bytes(&req_clone.body) - .expect("Failed to send request") - }) - .await?; + let response = { + let Request { url, body, .. } = req.clone(); + spawn_blocking(move || { + http_agent() + .post(url.as_str()) + .set("Content-Type", "text/plain") + .send_bytes(&body) + .expect("Failed to send request") + }) + .await? + }; log::info!("Response: {:#?}", &response); assert!(response.status() == 200); @@ -322,9 +356,12 @@ mod integration { fn handle_relay_response( res: impl std::io::Read, receiver: bitcoincore_rpc::Client, - ) -> String { - let proposal = payjoin::receive::UncheckedProposal::from_relay_response(res).unwrap(); - handle_proposal(proposal, receiver) + ctx: ProposalContext, + ) -> PayjoinProposal { + let proposal = ctx.parse_relay_response(res).unwrap(); + let proposal = handle_proposal(proposal, receiver); + debug!("Receiver's Payjoin proposal PSBT: {:#?}", &proposal.psbt()); + proposal } fn http_agent() -> ureq::Agent { @@ -414,7 +451,10 @@ mod integration { Ok(Psbt::from_str(&psbt)?) } - fn handle_proposal(proposal: UncheckedProposal, receiver: bitcoincore_rpc::Client) -> String { + fn handle_proposal( + proposal: UncheckedProposal, + receiver: bitcoincore_rpc::Client, + ) -> PayjoinProposal { // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); @@ -498,9 +538,7 @@ mod integration { Some(bitcoin::FeeRate::MIN), ) .unwrap(); - let psbt = payjoin_proposal.psbt(); - debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); - base64::encode(&psbt.serialize()) + payjoin_proposal } fn extract_pj_tx(