From 933fd5bcdbd38d365c125165ed8f3bca56161d8b Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 18 Aug 2023 13:54:52 -0400 Subject: [PATCH 1/6] Store and forward v2 messages with payjoin-relay Use postgres and hyper to store and notify clients' updates. --- Cargo.lock | 988 ++++++++++++++++++++++++++++++++++- Cargo.toml | 2 +- payjoin-relay/Cargo.toml | 31 ++ payjoin-relay/README.md | 15 + payjoin-relay/src/db.rs | 189 +++++++ payjoin-relay/src/main.rs | 232 ++++++++ payjoin/Cargo.toml | 7 +- payjoin/src/receive/mod.rs | 19 + payjoin/src/send/mod.rs | 1 + payjoin/src/uri.rs | 9 +- payjoin/tests/integration.rs | 420 ++++++++++++--- 11 files changed, 1814 insertions(+), 99 deletions(-) create mode 100644 payjoin-relay/Cargo.toml create mode 100644 payjoin-relay/README.md create mode 100644 payjoin-relay/src/db.rs create mode 100644 payjoin-relay/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index fd83b51c..a0d8cb44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -37,6 +50,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "anyhow" version = "1.0.75" @@ -54,6 +73,25 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-write-file" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcdbedc2236483ab103a53415653d6b4442ea6141baf1ffa85df29635e88436" +dependencies = [ + "nix", + "rand", +] + [[package]] name = "atty" version = "0.2.14" @@ -98,6 +136,12 @@ version = "0.21.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bech32" version = "0.9.1" @@ -200,6 +244,9 @@ name = "bitflags" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +dependencies = [ + "serde", +] [[package]] name = "block-buffer" @@ -210,6 +257,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard-stubs" +version = "1.42.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed59b5c00048f48d7af971b71f800fdf23e858844a6f9e4d32ca72e9399e7864" +dependencies = [ + "serde", + "serde_with", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -322,11 +379,17 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + [[package]] name = "core-foundation" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", @@ -334,9 +397,9 @@ dependencies = [ [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "core-rpc" @@ -373,6 +436,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.3.2" @@ -382,6 +460,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -392,6 +489,52 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -399,7 +542,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -408,11 +553,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "either" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +dependencies = [ + "serde", +] [[package]] name = "env_logger" @@ -443,6 +597,23 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "fastrand" version = "2.0.1" @@ -461,6 +632,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + [[package]] name = "flate2" version = "1.0.28" @@ -471,6 +648,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -486,6 +674,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.29" @@ -493,6 +696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -501,6 +705,45 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +[[package]] +name = "futures-executor" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4fb8693db0cf099eadcca0efe2a5a22e4550f98ed16aba6c48700da29597bc" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" + +[[package]] +name = "futures-macro" +version = "0.3.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "futures-sink" version = "0.3.29" @@ -519,10 +762,16 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -577,7 +826,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.7", ] [[package]] @@ -585,12 +834,28 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash 0.8.6", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.3", +] [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "hermit-abi" @@ -607,12 +872,36 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex_lit" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hkdf" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.5" @@ -702,6 +991,12 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -732,6 +1027,15 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -785,6 +1089,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] [[package]] name = "libc" @@ -792,6 +1099,23 @@ version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -800,9 +1124,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" [[package]] name = "lock_api" @@ -820,6 +1144,25 @@ version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.6.4" @@ -865,6 +1208,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -876,20 +1230,78 @@ dependencies = [ ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "nu-ansi-term" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ - "hermit-abi 0.3.3", - "libc", + "overload", + "winapi", ] [[package]] -name = "object" -version = "0.32.1" +name = "num-bigint-dig" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.3", + "libc", +] + +[[package]] +name = "object" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" dependencies = [ "memchr", ] @@ -922,6 +1334,12 @@ version = "6.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.1" @@ -945,6 +1363,12 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "paste" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" + [[package]] name = "pathdiff" version = "0.2.1" @@ -961,6 +1385,11 @@ dependencies = [ "env_logger", "log", "rand", + "rustls", + "testcontainers", + "testcontainers-modules", + "tokio", + "ureq", "url", ] @@ -987,6 +1416,22 @@ dependencies = [ "url", ] +[[package]] +name = "payjoin-relay" +version = "0.0.1" +dependencies = [ + "anyhow", + "hyper", + "hyper-rustls", + "payjoin", + "rcgen", + "rustls", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "pem" version = "3.0.2" @@ -997,6 +1442,15 @@ dependencies = [ "serde", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1066,6 +1520,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.27" @@ -1188,8 +1663,17 @@ checksum = "ebee201405406dbf528b8b672104ae6d6d63e6d118cb10e4d51abbc7b58044ff" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.3.9", + "regex-syntax 0.7.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1200,9 +1684,15 @@ checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.7.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.7.5" @@ -1249,6 +1739,26 @@ dependencies = [ "serde", ] +[[package]] +name = "rsa" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af6c4b23d99685a1408194da11270ef8e9809aff951cc70ec9b17350b087e474" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -1427,6 +1937,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -1438,6 +1981,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -1447,6 +1999,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.9" @@ -1493,6 +2055,238 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7b278788e7be4d0d29c0f39497a0eef3fba6bbc8e70d8bf7fde46edeaa9e85" +dependencies = [ + "itertools", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba03c279da73694ef99763320dea58b51095dfe87d001b1d4b5fe78ba8763cf" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d84b0a3c3739e220d94b3239fd69fb1f74bc36e16643423bd99de3b43c21bfbd" +dependencies = [ + "ahash 0.8.6", + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "dotenvy", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.1.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89961c00dc4d7dffb7aee214964b065072bff69e36ddb9e2c107541f75e4f2a5" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0bd4519486723648186a08785143599760f7cc81c52334a55d6a83ea1e20841" +dependencies = [ + "atomic-write-file", + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e37195395df71fd068f6e2082247891bc11e3289624bbc776a0cdfa1ca7f1ea4" +dependencies = [ + "atoi", + "base64 0.21.5", + "bitflags 2.4.1", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ac0ac3b7ccd10cc96c7ab29791a7dd236bd94021f31eec7ba3d46a74aa1c24" +dependencies = [ + "atoi", + "base64 0.21.5", + "bitflags 2.4.1", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "210976b7d948c7ba9fced8ca835b11cbb2d677c59c79de41ac0d397e14547490" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] [[package]] name = "strsim" @@ -1500,6 +2294,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -1555,6 +2355,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "testcontainers" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d2931d7f521af5bae989f716c3fa43a6af9af7ec7a5e21b59ae40878cec00" +dependencies = [ + "bollard-stubs", + "futures", + "hex", + "hmac", + "log", + "rand", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "testcontainers-modules" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c405c3757803e54818eaaf6b5b2af485dff4ab89a7130b72f62fd19b8bb6cd" +dependencies = [ + "testcontainers", +] + [[package]] name = "textwrap" version = "0.16.0" @@ -1581,6 +2407,16 @@ dependencies = [ "syn 2.0.39", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.1.45" @@ -1663,6 +2499,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -1698,10 +2545,23 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + [[package]] name = "tracing-core" version = "0.1.32" @@ -1709,6 +2569,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1750,6 +2640,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.7.1" @@ -1790,6 +2692,24 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -1899,6 +2819,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "whoami" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" + [[package]] name = "winapi" version = "0.3.9" @@ -2089,6 +3015,32 @@ dependencies = [ "time 0.3.20", ] +[[package]] +name = "zerocopy" +version = "0.7.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43de342578a3a14a9314a2dab1942cbfcbe5686e1f91acdc513058063eafe18" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1012d89e3acb79fad7a799ce96866cfb8098b74638465ea1b1533d35900ca90" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" + [[package]] name = "zip" version = "0.5.13" diff --git a/Cargo.toml b/Cargo.toml index d1910cba..4a377612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["payjoin", "payjoin-cli"] +members = ["payjoin", "payjoin-cli", "payjoin-relay"] resolver = "2" [patch.crates-io.payjoin] diff --git a/payjoin-relay/Cargo.toml b/payjoin-relay/Cargo.toml new file mode 100644 index 00000000..f04b5f02 --- /dev/null +++ b/payjoin-relay/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "payjoin-relay" +version = "0.0.1" +authors = ["Dan Gould "] +description = "A relay server for Payjoin V2 coordination" +repository = "https://github.com/payjoin/rust-payjoin" +readme = "README.md" +keywords = ["bip78", "payjoin", "bitcoin", "relay"] +categories = ["cryptography::cryptocurrencies", "network-programming"] +license = "MITNFA" +edition = "2021" +resolver = "2" +exclude = ["tests"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +danger-local-https = ["hyper-rustls", "rcgen", "rustls"] + +[dependencies] +hyper = { version = "0.14", features = ["full"] } +hyper-rustls = { version = "0.24", optional = true } +anyhow = "1.0.71" +payjoin = { path = "../payjoin", features = ["base64"] } +rcgen = { version = "0.11", optional = true } +rustls = { version = "0.21", optional = true } +sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] } +tokio = { version = "1.12.0", features = ["full"] } +tracing = "0.1.37" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } + diff --git a/payjoin-relay/README.md b/payjoin-relay/README.md new file mode 100644 index 00000000..d7f92f51 --- /dev/null +++ b/payjoin-relay/README.md @@ -0,0 +1,15 @@ +# payjoin-relay + +## Payjoin v2 Relay + +Payjoin v2 peers relay HTTP client messages in order to cordinate an asynchronous Payjoin transaction. Version 1 Requires the receiver to host a public HTTP server and to set up security using either HTTPS or Onion Services above and beyond typical HTTP client operation. + +V2 clients use Hybrid Pubkey Encryption established in the bitcoin URI payment request for security instead, allowing lightweight clients secure communication without the burden of setup, which is done by the operator of this third-party relay server. This relay only sees OHTTP encapsulated, encrypted requests to prevent it from collecting metadata to break the privacy benefits of payjoin for messages who follow the spec. + +This relay *only* accepts v2 payloads via Oblivious HTTP (OHTTP), preventing it from identifying IP addresses of clients. + +## Architecture + +The relay is a simple mailbox. Receivers may enroll by making a request to a pubkey identified subdirectory. After success response, they may share this subdirectory as payjoin endpoint to the sender in a bitcoin URI. The sender may poll the subdirectory with a request posting their encrypted Fallback PSBT expecting a Payjoin Proposal PSBT response. The receiver may poll the enroll endpoint to await a request, later posting their Payjoin Proposal PSBT for the sender to receive, sign, and broadcast. + +The relay does depend on a second independent Oblivious HTTP Relay to help secure request/response metadata from the Payjoin Relay. diff --git a/payjoin-relay/src/db.rs b/payjoin-relay/src/db.rs new file mode 100644 index 00000000..7565664e --- /dev/null +++ b/payjoin-relay/src/db.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use anyhow::Result; +use sqlx::postgres::{PgListener, PgPoolOptions}; +use sqlx::{PgPool, Pool, Postgres}; +use tracing::debug; + +const RES_COLUMN: &str = "res"; +const REQ_COLUMN: &str = "req"; + +pub(crate) struct DbPool { + pool: Pool, + timeout: Duration, +} + +impl DbPool { + /// Initialize a database connection pool with specified peek timeout + pub async fn new(timeout: Duration, db_host: String) -> Result { + let pool = init_postgres(db_host).await?; + Ok(Self { pool, timeout }) + } + + pub async fn peek_req(&self, pubkey_id: &str) -> Option, sqlx::Error>> { + peek_with_timeout(&self.pool, pubkey_id, REQ_COLUMN, self.timeout).await + } + pub async fn peek_res(&self, pubkey_id: &str) -> Option, sqlx::Error>> { + debug!("peek res"); + peek_with_timeout(&self.pool, pubkey_id, RES_COLUMN, self.timeout).await + } + + pub async fn push_req(&self, pubkey_id: &str, data: Vec) -> Result<(), sqlx::Error> { + push(&self.pool, pubkey_id, REQ_COLUMN, data).await + } + + pub async fn push_res(&self, pubkey_id: &str, data: Vec) -> Result<(), sqlx::Error> { + debug!("push res"); + push(&self.pool, pubkey_id, RES_COLUMN, data).await + } +} + +impl Clone for DbPool { + fn clone(&self) -> Self { Self { pool: self.pool.clone(), timeout: self.timeout } } +} + +async fn init_postgres(db_host: String) -> Result { + let pool = PgPoolOptions::new() + .connect(&format!("postgres://postgres:welcome@{}/postgres", db_host)) + .await?; + // Create table if not exist yet + let (table_exists,): (bool,) = + sqlx::query_as("SELECT EXISTS (SELECT FROM pg_tables WHERE tablename = 'relay')") + .fetch_one(&pool) + .await?; + + if !table_exists { + // Create the table + sqlx::query( + r#" + CREATE TABLE relay ( + pubkey_id VARCHAR PRIMARY KEY, + req BYTEA, + res BYTEA + ); + "#, + ) + .execute(&pool) + .await?; + + // Create the function for notification + sqlx::query( + r#" + CREATE OR REPLACE FUNCTION notify_change() + RETURNS TRIGGER AS $$ + DECLARE + channel_name text; + BEGIN + channel_name := NEW.pubkey_id || '_' || TG_ARGV[0]; + PERFORM pg_notify(channel_name, TG_TABLE_NAME || ', ' || NEW.pubkey_id); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + "#, + ) + .execute(&pool) + .await?; + + // Create triggers + sqlx::query( + r#" + CREATE TRIGGER relay_req_trigger + AFTER INSERT OR UPDATE OF req ON relay + FOR EACH ROW + EXECUTE FUNCTION notify_change('req'); + "#, + ) + .execute(&pool) + .await?; + + sqlx::query( + r#" + CREATE TRIGGER relay_res_trigger + AFTER INSERT OR UPDATE OF res ON relay + FOR EACH ROW + EXECUTE FUNCTION notify_change('res'); + "#, + ) + .execute(&pool) + .await?; + } + Ok(pool) +} + +async fn push( + pool: &Pool, + pubkey_id: &str, + channel_type: &str, + data: Vec, +) -> Result<(), sqlx::Error> { + // Use an UPSERT operation to insert or update the record + let query = format!( + "INSERT INTO relay (pubkey_id, {}) VALUES ($1, $2) \ + ON CONFLICT (pubkey_id) DO UPDATE SET {} = EXCLUDED.{}", + channel_type, channel_type, channel_type + ); + + sqlx::query(&query).bind(pubkey_id).bind(data).execute(pool).await?; + + Ok(()) +} + +async fn peek_with_timeout( + pool: &Pool, + pubkey_id: &str, + channel_type: &str, + timeout: Duration, +) -> Option, sqlx::Error>> { + tokio::time::timeout(timeout, peek(pool, pubkey_id, channel_type)).await.ok() +} + +async fn peek( + pool: &Pool, + pubkey_id: &str, + channel_type: &str, +) -> Result, sqlx::Error> { + // Step 1: Attempt to fetch existing content for the given pubkey_id and channel_type + match sqlx::query_as::>,)>(&format!( + "SELECT {} FROM relay WHERE pubkey_id = $1", + channel_type + )) + .bind(pubkey_id) + .fetch_one(pool) + .await + { + Ok(row) => + if let Some(content) = row.0 { + if !content.is_empty() { + return Ok(content); + } + }, + Err(e) => { + debug!("Failed to fetch content initially: {}", e); + // We'll continue to the next step even if the query failed + } + } + + // Step 2: If no content was found, set up a listener + let mut listener = PgListener::connect_with(pool).await?; + let channel_name = format!("{}_{}", pubkey_id, channel_type); + listener.listen(&channel_name).await?; + debug!("Listening on channel: {}", channel_name); + + // Step 3: Wait for a notification and then fetch the new content + loop { + let notification = listener.recv().await?; + debug!("Received notification: {:?}", notification); + if notification.channel() == channel_name { + let row: (Vec,) = + sqlx::query_as(&format!("SELECT {} FROM relay WHERE pubkey_id = $1", channel_type)) + .bind(pubkey_id) + .fetch_one(pool) + .await?; + + let updated_content = row.0; + if !updated_content.is_empty() { + return Ok(updated_content); + } + } + } +} diff --git a/payjoin-relay/src/main.rs b/payjoin-relay/src/main.rs new file mode 100644 index 00000000..dc663053 --- /dev/null +++ b/payjoin-relay/src/main.rs @@ -0,0 +1,232 @@ +use std::env; +use std::net::SocketAddr; +use std::str::FromStr; + +use anyhow::Result; +use hyper::server::conn::AddrIncoming; +use hyper::server::Builder; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, HeaderMap, Method, Request, Response, Server, StatusCode}; +use tracing::{debug, error, info}; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::EnvFilter; + +const DEFAULT_RELAY_PORT: &str = "8080"; +const DEFAULT_DB_HOST: &str = "localhost:5432"; +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const MAX_BUFFER_SIZE: usize = 65536; +const V1_UNAVAILABLE_RES_JSON: &str = r#"{{"errorCode": "unavailable", "message": "V2 receiver offline. V1 sends require synchronous communications."}}"#; + +mod db; +use crate::db::DbPool; + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_logging(); + + let relay_port = env::var("PJ_RELAY_PORT").unwrap_or_else(|_| DEFAULT_RELAY_PORT.to_string()); + let timeout_secs = env::var("PJ_RELAY_TIMEOUT_SECS") + .map(|s| s.parse().expect("Invalid timeout")) + .unwrap_or(DEFAULT_TIMEOUT_SECS); + let timeout = std::time::Duration::from_secs(timeout_secs); + let db_host = env::var("PJ_DB_HOST").unwrap_or_else(|_| DEFAULT_DB_HOST.to_string()); + + let pool = DbPool::new(timeout, db_host).await?; + let make_svc = make_service_fn(|_| { + let pool = pool.clone(); + async move { + let handler = move |req| handle_web_req(pool.clone(), req); + Ok::<_, hyper::Error>(service_fn(handler)) + } + }); + + // Parse the bind address using the provided port + let bind_addr_str = format!("0.0.0.0:{}", relay_port); + let bind_addr: SocketAddr = bind_addr_str.parse()?; + let server = init_server(&bind_addr)?.serve(make_svc); + info!("Serverless payjoin relay awaiting HTTP connection at {}", bind_addr_str); + Ok(server.await?) +} + +fn init_logging() { + let env_filter = + EnvFilter::builder().with_default_directive(LevelFilter::INFO.into()).from_env_lossy(); + + tracing_subscriber::fmt().with_target(true).with_level(true).with_env_filter(env_filter).init(); + + println!("Logging initialized"); +} + +#[cfg(not(feature = "danger-local-https"))] +fn init_server(bind_addr: &SocketAddr) -> Result> { + Ok(Server::bind(bind_addr)) +} + +#[cfg(feature = "danger-local-https")] +fn init_server(bind_addr: &SocketAddr) -> Result> { + const LOCAL_CERT_FILE: &str = "localhost.der"; + + use std::io::Write; + + use rustls::{Certificate, PrivateKey}; + + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])?; + let cert_der = cert.serialize_der()?; + let mut local_cert_path = std::env::temp_dir(); + local_cert_path.push(LOCAL_CERT_FILE); + println!("RELAY CERT PATH {:?}", &local_cert_path); + let mut file = std::fs::File::create(local_cert_path)?; + file.write_all(&cert_der)?; + let key = PrivateKey(cert.serialize_private_key_der()); + let certs = vec![Certificate(cert.serialize_der()?)]; + let incoming = AddrIncoming::bind(bind_addr)?; + let acceptor = hyper_rustls::TlsAcceptor::builder() + .with_single_cert(certs, key) + .map_err(|e| anyhow::anyhow!("TLS error: {}", e))? + .with_all_versions_alpn() + .with_incoming(incoming); + Ok(Server::builder(acceptor)) +} + +async fn handle_web_req(pool: DbPool, req: Request) -> Result> { + let path = req.uri().path().to_string(); + let (parts, body) = req.into_parts(); + + let path_segments: Vec<&str> = path.split('/').collect(); + debug!("{:?}", &path_segments); + let mut response = match (parts.method, path_segments.as_slice()) { + (Method::POST, &["", ""]) => post_enroll(body).await, + (Method::POST, &["", id]) => post_fallback(id, body, parts.headers, pool).await, + (Method::GET, &["", id]) => get_fallback(id, pool).await, + (Method::POST, &["", id, "payjoin"]) => post_payjoin(id, body, pool).await, + _ => Ok(not_found()), + } + .unwrap_or_else(|e| e.to_response()); + + // Allow CORS for third-party access + response + .headers_mut() + .insert("Access-Control-Allow-Origin", hyper::header::HeaderValue::from_static("*")); + + Ok(response) +} + +enum HandlerError { + PayloadTooLarge, + ReceiverOffline, + InternalServerError(anyhow::Error), + BadRequest(anyhow::Error), +} + +impl HandlerError { + fn to_response(&self) -> Response { + let (status, body) = match self { + HandlerError::PayloadTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, Body::empty()), + HandlerError::ReceiverOffline => + (StatusCode::SERVICE_UNAVAILABLE, Body::from(V1_UNAVAILABLE_RES_JSON)), + HandlerError::BadRequest(e) => { + error!("Bad request: {}", e); + (StatusCode::BAD_REQUEST, Body::empty()) + } + HandlerError::InternalServerError(e) => { + error!("Internal server error: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()) + } + }; + + let mut res = Response::new(body); + *res.status_mut() = status; + res + } +} + +impl From for HandlerError { + fn from(e: hyper::http::Error) -> Self { HandlerError::InternalServerError(e.into()) } +} + +async fn post_enroll(body: Body) -> Result, HandlerError> { + use payjoin::{base64, bitcoin}; + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let bytes = + hyper::body::to_bytes(body).await.map_err(|e| HandlerError::BadRequest(e.into()))?; + let base64_id = + 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) + .map_err(|e| HandlerError::BadRequest(e.into()))?; + tracing::info!("Enrolled valid pubkey: {:?}", pubkey); + Ok(Response::builder().status(StatusCode::NO_CONTENT).body(Body::empty())?) +} + +async fn post_fallback( + id: &str, + body: Body, + headers: HeaderMap, + pool: DbPool, +) -> Result, HandlerError> { + use hyper::header::HeaderValue; + + let id = shorten_string(id); + let is_async = headers.get("Async") == Some(&HeaderValue::from_static("true")); + let req = hyper::body::to_bytes(body) + .await + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + if req.len() > MAX_BUFFER_SIZE { + return Err(HandlerError::PayloadTooLarge); + } + + match pool.push_req(&id, req.into()).await { + Ok(_) => (), + Err(e) => return Err(HandlerError::BadRequest(e.into())), + }; + + match pool.peek_res(&id).await { + Some(result) => match result { + Ok(buffered_res) => Ok(Response::new(Body::from(buffered_res))), + Err(e) => Err(HandlerError::BadRequest(e.into())), + }, + None => fallback_timeout_response(is_async), + } +} + +fn fallback_timeout_response(is_req_async: bool) -> Result, HandlerError> { + if is_req_async { + Ok(Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?) + } else { + Err(HandlerError::ReceiverOffline) + } +} + +async fn get_fallback(id: &str, pool: DbPool) -> Result, HandlerError> { + let id = shorten_string(id); + match pool.peek_req(&id).await { + Some(result) => match result { + Ok(buffered_req) => Ok(Response::new(Body::from(buffered_req))), + Err(e) => Err(HandlerError::BadRequest(e.into())), + }, + None => Ok(Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?), + } +} + +async fn post_payjoin(id: &str, body: Body, pool: DbPool) -> Result, HandlerError> { + let id = shorten_string(id); + let res = hyper::body::to_bytes(body) + .await + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + + match pool.push_res(&id, res.into()).await { + Ok(_) => Ok(Response::builder().status(StatusCode::NO_CONTENT).body(Body::empty())?), + Err(e) => Err(HandlerError::BadRequest(e.into())), + } +} + +fn not_found() -> Response { + let mut res = Response::default(); + *res.status_mut() = StatusCode::NOT_FOUND; + res +} + +fn shorten_string(input: &str) -> String { input.chars().take(8).collect() } diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 645bfaf8..63ea7384 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -27,8 +27,13 @@ rand = { version = "0.8.4", optional = true } url = "2.2.2" [dev-dependencies] -env_logger = "0.9.0" bitcoind = { version = "0.31.1", features = ["0_21_2"] } +env_logger = "0.9.0" +rustls = "0.21.9" +testcontainers = "0.15.0" +testcontainers-modules = { version = "0.1.3", features = ["postgres"] } +tokio = { version = "1.12.0", features = ["full"] } +ureq = "2.8.0" [package.metadata.docs.rs] features = ["send", "receive", "base64"] \ No newline at end of file diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 74a72504..72e257ae 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -303,6 +303,25 @@ pub struct UncheckedProposal { } impl UncheckedProposal { + pub fn from_relay_response(mut body: impl std::io::Read) -> Result { + let mut buf = Vec::new(); + let _ = body.read_to_end(&mut buf); + let base64 = bitcoin::base64::decode(buf).map_err(InternalRequestError::Base64)?; + let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; + + let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; + log::debug!("Received original psbt: {:?}", psbt); + + // TODO accept parameters + // let pairs = url::form_urlencoded::parse(query.as_bytes()); + // let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; + // log::debug!("Received request with params: {:?}", params); + + // TODO handle v1 and v2 + + Ok(UncheckedProposal { psbt, params: Params::default() }) + } + pub fn from_request( mut body: impl std::io::Read, query: &str, diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index e06fc1c1..32e5601f 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -353,6 +353,7 @@ impl<'a> RequestBuilder<'a> { /// /// You need to send this request over HTTP(S) to the receiver. #[non_exhaustive] +#[derive(Debug, Clone)] pub struct Request { /// URL to send the request to. /// diff --git a/payjoin/src/uri.rs b/payjoin/src/uri.rs index b8f32b37..eb3152e7 100644 --- a/payjoin/src/uri.rs +++ b/payjoin/src/uri.rs @@ -8,6 +8,7 @@ use url::Url; #[derive(Debug, Clone)] pub enum Payjoin { Supported(PayjoinParams), + V2Only(PayjoinParams), Unsupported, } @@ -15,6 +16,7 @@ impl Payjoin { pub fn pj_is_supported(&self) -> bool { match self { Payjoin::Supported(_) => true, + Payjoin::V2Only(_) => true, Payjoin::Unsupported => false, } } @@ -74,7 +76,7 @@ impl<'a> UriExtNetworkUnchecked<'a> for Uri<'a, NetworkUnchecked> { impl<'a> UriExt<'a> for Uri<'a, NetworkChecked> { fn check_pj_supported(self) -> Result, bip21::Uri<'a>> { match self.extras { - Payjoin::Supported(payjoin) => { + Payjoin::Supported(payjoin) | Payjoin::V2Only(payjoin) => { let mut uri = bip21::Uri::with_extras(self.address, payjoin); uri.amount = self.amount; uri.label = self.label; @@ -169,6 +171,11 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { _endpoint: endpoint, disable_output_substitution: pjos.unwrap_or(false), })) + } else if endpoint.scheme() == "http" { + Ok(Payjoin::V2Only(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), + })) } else { Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index dd4cc545..ab378487 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -1,91 +1,47 @@ #[cfg(all(feature = "send", feature = "receive"))] mod integration { use std::collections::HashMap; + use std::env; + use std::process::Stdio; use std::str::FromStr; + use std::sync::Arc; + use bitcoin::address::NetworkChecked; use bitcoin::psbt::Psbt; - use bitcoin::{Amount, OutPoint}; + use bitcoin::{Amount, FeeRate, OutPoint}; use bitcoind::bitcoincore_rpc; use bitcoind::bitcoincore_rpc::core_rpc_json::{AddressType, WalletProcessPsbtResult}; use bitcoind::bitcoincore_rpc::RpcApi; use log::{debug, log_enabled, Level}; use payjoin::bitcoin::base64; - use payjoin::receive::Headers; + use payjoin::receive::{Headers, UncheckedProposal}; use payjoin::send::{Request, RequestBuilder}; - use payjoin::{bitcoin, Error, Uri}; + use payjoin::Uri; + use testcontainers::Container; + use testcontainers_modules::postgres::Postgres; + use testcontainers_modules::testcontainers::clients::Cli; + use tokio::process::{Child, Command}; + use tokio::task::spawn_blocking; - #[test] - fn integration_test() { - let _ = env_logger::try_init(); - let bitcoind_exe = std::env::var("BITCOIND_EXE") - .ok() - .or_else(|| bitcoind::downloaded_exe_path().ok()) - .expect("version feature or env BITCOIND_EXE is required for tests"); - let mut conf = bitcoind::Conf::default(); - conf.view_stdout = log_enabled!(Level::Debug); - let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf).unwrap(); - let receiver = bitcoind.create_wallet("receiver").unwrap(); - let receiver_address = - receiver.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); - let sender = bitcoind.create_wallet("sender").unwrap(); - let sender_address = - sender.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); - bitcoind.client.generate_to_address(1, &receiver_address).unwrap(); - bitcoind.client.generate_to_address(101, &sender_address).unwrap(); + const EXAMPLE_URL: &str = "https://example.com"; + const RELAY_URL: &str = "https://localhost:8088"; + const LOCAL_CERT_FILE: &str = "localhost.der"; - assert_eq!( - Amount::from_btc(50.0).unwrap(), - receiver.get_balances().unwrap().mine.trusted, - "receiver doesn't own bitcoin" - ); + type BoxError = Box; - assert_eq!( - Amount::from_btc(50.0).unwrap(), - sender.get_balances().unwrap().mine.trusted, - "sender doesn't own bitcoin" - ); + #[test] + fn v1_to_v1() -> Result<(), BoxError> { + let _ = env_logger::try_init(); + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; // Receiver creates the payjoin URI - let pj_receiver_address = receiver.get_new_address(None, None).unwrap().assume_checked(); - let amount = Amount::from_btc(1.0).unwrap(); - let pj_uri_string = format!( - "{}?amount={}&pj=https://example.com", - pj_receiver_address.to_qr_uri(), - amount.to_btc() - ); - let pj_uri = Uri::from_str(&pj_uri_string).unwrap(); - let pj_uri = pj_uri.assume_checked(); + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL); // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - let mut outputs = HashMap::with_capacity(1); - outputs.insert(pj_uri.address.to_string(), pj_uri.amount.unwrap()); - debug!("outputs: {:?}", outputs); - let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { - lock_unspent: Some(true), - fee_rate: Some(payjoin::bitcoin::Amount::from_sat(2000)), - ..Default::default() - }; - let psbt = sender - .wallet_create_funded_psbt( - &[], // inputs - &outputs, - None, // locktime - Some(options), - None, - ) - .expect("failed to create PSBT") - .psbt; - let psbt = sender.wallet_process_psbt(&psbt, None, None, None).unwrap().psbt; - let psbt = Psbt::from_str(&psbt).unwrap(); + let psbt = build_original_psbt(&sender, &pj_uri)?; debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri) - .unwrap() - .build_with_additional_fee( - payjoin::bitcoin::Amount::from_sat(10000), - None, - bitcoin::FeeRate::ZERO, - false, - ) - .unwrap(); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; let headers = HeaderMock::from_vec(&req.body); // ********************** @@ -97,16 +53,187 @@ mod integration { // ********************** // Inside the Sender: // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes()).unwrap(); - let payjoin_base64_string = base64::encode(&checked_payjoin_proposal_psbt.serialize()); - let payjoin_psbt = - sender.wallet_process_psbt(&payjoin_base64_string, None, None, None).unwrap().psbt; - let payjoin_psbt = sender.finalize_psbt(&payjoin_psbt, Some(false)).unwrap().psbt.unwrap(); - let payjoin_psbt = Psbt::from_str(&payjoin_psbt).unwrap(); - debug!("Sender's Payjoin PSBT: {:#?}", payjoin_psbt); + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + Ok(()) + } + + #[tokio::test] + async fn v2_to_v2() -> Result<(), BoxError> { + std::env::set_var("RUST_LOG", "debug"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let (mut relay, _db) = init_relay(&docker).await; + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // ********************** + // 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??; + 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 pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + 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 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??; + log::info!("Response: {:#?}", &response); + assert!(response.status() == 202); + // no response body yet since we are async and pushed fallback_psbt to the buffer + + // ********************** + // 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 response = spawn_blocking(move || http_agent().get(&receive_endpoint).call()).await??; + let response = handle_relay_response(response.into_reader(), receiver); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + log::info!("sent"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn v1_to_v2() -> Result<(), BoxError> { + std::env::set_var("RUST_LOG", "debug"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let (mut relay, _db) = init_relay(&docker).await; + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // ********************** + // 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(); + assert!(enroll.status() == 204); - let payjoin_tx = payjoin_psbt.extract_tx(); - bitcoind.client.send_raw_transaction(&payjoin_tx).unwrap(); + // 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 pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + + // ********************** + // Inside the V1 Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + 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?; + match res { + Err(ureq::Error::Status(code, _)) => assert_eq!(code, 503), + _ => panic!("Expected response status code 503, found {:?}", res), + } + + // ********************** + // Inside the Receiver: + let receiver_loop = tokio::task::spawn(async move { + let fallback_psbt_body = loop { + let pk64 = pubkey_base64.clone(); + let response = spawn_blocking(move || { + let receive_endpoint = format!("{}/{}", RELAY_URL, &pk64); + http_agent().get(&receive_endpoint).call() + }) + .await??; + + if response.status() == 200 { + debug!("GET'd fallback_psbt"); + break response.into_reader(); + } else if response.status() == 202 { + log::info!("No response yet for POST payjoin request, retrying some seconds"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } else { + log::error!("Unexpected response status: {}", response.status()); + panic!("Unexpected response status: {}", response.status()) + } + }; + debug!("handle relay response"); + let response = handle_relay_response(fallback_psbt_body, receiver); + 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 response = + spawn_blocking(move || http_agent().post(&payjoin_endpoint).send_string(&response)) + .await??; + debug!("POSTed with payjoin_psbt response status {}", response.status()); + assert!(response.status() == 204); + Ok::<_, Box>(()) + }); + + // ********************** + // 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?; + log::info!("Response: {:#?}", &response); + assert!(response.status() == 200); + + 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"); + assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) } struct HeaderMock(HashMap); @@ -124,6 +251,102 @@ mod integration { } } + async fn init_relay<'a>(docker: &'a Cli) -> (Child, Container<'a, Postgres>) { + println!("Initializing relay server"); + env::set_var("PJ_RELAY_PORT", "8088"); + env::set_var("PJ_RELAY_TIMEOUT_SECS", "2"); + //env::set_var("PGPASSWORD", "welcome"); + let postgres = docker.run(Postgres::default()); + env::set_var("PJ_DB_HOST", format!("127.0.0.1:{}", postgres.get_host_port_ipv4(5432))); + println!("Postgres running on {}", postgres.get_host_port_ipv4(5432)); + compile_payjoin_relay().await.wait().await.unwrap(); + let workspace_root = env::var("CARGO_MANIFEST_DIR").unwrap(); + let binary_path = format!("{}/../target/debug/payjoin-relay", workspace_root); + let mut command = Command::new(binary_path); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + (command.spawn().unwrap(), postgres) + } + + async fn compile_payjoin_relay() -> Child { + // set payjoin relay target dir to payjoin-relay + let mut command = Command::new("cargo"); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ + "build", + "--package", + "payjoin-relay", + "--features", + "danger-local-https", + ]); + command.spawn().unwrap() + } + + fn init_bitcoind_sender_receiver( + ) -> Result<(bitcoind::BitcoinD, bitcoincore_rpc::Client, bitcoincore_rpc::Client), BoxError> + { + let bitcoind_exe = + env::var("BITCOIND_EXE").ok().or_else(|| bitcoind::downloaded_exe_path().ok()).unwrap(); + let mut conf = bitcoind::Conf::default(); + conf.view_stdout = log_enabled!(Level::Debug); + let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf)?; + let receiver = bitcoind.create_wallet("receiver")?; + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + let sender = bitcoind.create_wallet("sender")?; + let sender_address = + sender.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + bitcoind.client.generate_to_address(1, &receiver_address)?; + bitcoind.client.generate_to_address(101, &sender_address)?; + + assert_eq!( + Amount::from_btc(50.0)?, + receiver.get_balances()?.mine.trusted, + "receiver doesn't own bitcoin" + ); + + assert_eq!( + Amount::from_btc(50.0)?, + sender.get_balances()?.mine.trusted, + "sender doesn't own bitcoin" + ); + Ok((bitcoind, sender, receiver)) + } + + fn build_pj_uri( + address: bitcoin::Address, + amount: Amount, + pj: &str, + ) -> Uri<'_, NetworkChecked> { + let pj_uri_string = + format!("{}?amount={}&pj={}", address.to_qr_uri(), amount.to_btc(), pj,); + let pj_uri = Uri::from_str(&pj_uri_string).unwrap(); + pj_uri.assume_checked() + } + + fn build_original_psbt( + sender: &bitcoincore_rpc::Client, + pj_uri: &Uri<'_, NetworkChecked>, + ) -> Result { + let mut outputs = HashMap::with_capacity(1); + outputs.insert(pj_uri.address.to_string(), pj_uri.amount.unwrap()); + debug!("outputs: {:?}", outputs); + let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { + lock_unspent: Some(true), + fee_rate: Some(Amount::from_sat(2000)), + ..Default::default() + }; + let psbt = sender + .wallet_create_funded_psbt( + &[], // inputs + &outputs, + None, // locktime + Some(options), + None, + )? + .psbt; + let psbt = sender.wallet_process_psbt(&psbt, None, None, None)?.psbt; + Ok(Psbt::from_str(&psbt)?) + } + // Receiver receive and process original_psbt from a sender // In production it it will come in as an HTTP request (over ssl or onion) fn handle_pj_request( @@ -138,7 +361,15 @@ mod integration { headers, ) .unwrap(); + handle_proposal(proposal, receiver) + } + 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) + } + + fn handle_proposal(proposal: UncheckedProposal, receiver: bitcoincore_rpc::Client) -> String { // 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(); @@ -226,4 +457,37 @@ mod integration { debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); base64::encode(&psbt.serialize()) } + + fn extract_pj_tx( + sender: &bitcoincore_rpc::Client, + psbt: Psbt, + ) -> Result> { + let payjoin_base64_string = base64::encode(&psbt.serialize()); + let payjoin_psbt = + sender.wallet_process_psbt(&payjoin_base64_string, None, None, None)?.psbt; + let payjoin_psbt = sender.finalize_psbt(&payjoin_psbt, Some(false))?.psbt.unwrap(); + let payjoin_psbt = Psbt::from_str(&payjoin_psbt)?; + debug!("Sender's Payjoin PSBT: {:#?}", payjoin_psbt); + + Ok(payjoin_psbt.extract_tx()) + } + + fn http_agent() -> ureq::Agent { + use rustls::client::ClientConfig; + use rustls::{Certificate, RootCertStore}; + use ureq::AgentBuilder; + + let mut local_cert_path = std::env::temp_dir(); + local_cert_path.push(LOCAL_CERT_FILE); + println!("TEST CERT PATH {:?}", &local_cert_path); + let cert_der = std::fs::read(local_cert_path).unwrap(); + let mut root_cert_store = RootCertStore::empty(); + root_cert_store.add(&Certificate(cert_der)).unwrap(); + let client_config = ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + AgentBuilder::new().tls_config(Arc::new(client_config)).build() + } } From a8c4d2bdfa0cd50c7f37ee807d4db3510dcb2944 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 8 Nov 2023 17:32:23 -0500 Subject: [PATCH 2/6] De/Serialize v2 payload --- payjoin-relay/Cargo.toml | 1 - payjoin/Cargo.toml | 1 + payjoin/src/receive/error.rs | 9 + payjoin/src/receive/mod.rs | 23 +- payjoin/src/send/mod.rs | 62 +++- payjoin/tests/integration.rs | 606 ++++++++++++++++++----------------- 6 files changed, 385 insertions(+), 317 deletions(-) diff --git a/payjoin-relay/Cargo.toml b/payjoin-relay/Cargo.toml index f04b5f02..b374af0a 100644 --- a/payjoin-relay/Cargo.toml +++ b/payjoin-relay/Cargo.toml @@ -28,4 +28,3 @@ sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] } tokio = { version = "1.12.0", features = ["full"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } - diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 63ea7384..ee2e4f1f 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,6 +18,7 @@ exclude = ["tests"] send = [] receive = ["rand"] base64 = ["bitcoin/base64"] +v2 = [] [dependencies] bitcoin = { version = "0.30.0", features = ["base64"] } diff --git a/payjoin/src/receive/error.rs b/payjoin/src/receive/error.rs index ffc91360..9bfdfcf1 100644 --- a/payjoin/src/receive/error.rs +++ b/payjoin/src/receive/error.rs @@ -65,6 +65,11 @@ pub(crate) enum InternalRequestError { /// Original PSBT input has been seen before. Only automatic receivers, aka "interactive" in the spec /// look out for these to prevent probing attacks. InputSeen(bitcoin::OutPoint), + /// Serde deserialization failed + #[cfg(feature = "v2")] + ParsePsbt(bitcoin::psbt::PsbtParseError), + #[cfg(feature = "v2")] + Utf8(std::string::FromUtf8Error), } impl From for RequestError { @@ -125,6 +130,10 @@ impl fmt::Display for RequestError { write_error(f, "original-psbt-rejected", &format!("Input Type Error: {}.", e)), InternalRequestError::InputSeen(_) => write_error(f, "original-psbt-rejected", "The receiver rejected the original PSBT."), + #[cfg(feature = "v2")] + InternalRequestError::ParsePsbt(e) => write_error(f, "Error parsing PSBT:", e), + #[cfg(feature = "v2")] + InternalRequestError::Utf8(e) => write_error(f, "Error parsing PSBT:", e), } } } diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 72e257ae..88bd36c7 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -303,23 +303,22 @@ pub struct UncheckedProposal { } 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 base64 = bitcoin::base64::decode(buf).map_err(InternalRequestError::Base64)?; - let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; - + 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); - - // TODO accept parameters - // let pairs = url::form_urlencoded::parse(query.as_bytes()); - // let params = Params::from_query_pairs(pairs).map_err(InternalRequestError::SenderParams)?; - // log::debug!("Received request with params: {:?}", params); - - // TODO handle v1 and v2 - - Ok(UncheckedProposal { psbt, params: Params::default() }) + 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( diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 32e5601f..161a9804 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -326,16 +326,34 @@ impl<'a> RequestBuilder<'a> { let sequence = zeroth_input.txin.sequence; let txout = zeroth_input.previous_txout().expect("We already checked this above"); let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin).unwrap(); - let url = serialize_url( - self.uri.extras._endpoint.into(), - disable_output_substitution, - fee_contribution, - self.min_fee_rate, - ) - .map_err(InternalCreateRequestError::Url)?; - let body = serialize_psbt(&psbt); + + #[cfg(not(feature = "v2"))] + let request = { + let url = serialize_url( + self.uri.extras._endpoint.into(), + disable_output_substitution, + fee_contribution, + self.min_fee_rate, + ) + .map_err(InternalCreateRequestError::Url)?; + let body = psbt.to_string().as_bytes().to_vec(); + Request { url, body } + }; + + #[cfg(feature = "v2")] + let request = { + let url = self.uri.extras._endpoint; + let body = serialize_v2_body( + &psbt, + disable_output_substitution, + fee_contribution, + self.min_fee_rate, + )?; + Request { url, body } + }; + Ok(( - Request { url, body }, + request, Context { original_psbt: psbt, disable_output_substitution, @@ -372,6 +390,7 @@ pub struct Request { /// /// This type is used to process the response. Get it from [`RequestBuilder`](crate::send::RequestBuilder)'s build methods. /// Then you only need to call [`.process_response()`](crate::send::Context::process_response()) on it to continue BIP78 flow. +#[derive(Debug)] pub struct Context { original_psbt: Psbt, disable_output_substitution: bool, @@ -769,6 +788,26 @@ fn determine_fee_contribution( }) } +#[cfg(feature = "v2")] +fn serialize_v2_body( + psbt: &Psbt, + disable_output_substitution: bool, + fee_contribution: Option<(bitcoin::Amount, usize)>, + min_feerate: FeeRate, +) -> Result, CreateRequestError> { + // Grug say localhost base be discarded anyway. no big brain needed. + let placeholder_url = serialize_url( + "http:/localhost".to_string(), + disable_output_substitution, + fee_contribution, + min_feerate, + ) + .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()) +} + fn serialize_url( endpoint: String, disable_output_substitution: bool, @@ -793,11 +832,6 @@ fn serialize_url( Ok(url) } -fn serialize_psbt(psbt: &Psbt) -> Vec { - let bytes = psbt.serialize(); - bitcoin::base64::encode(bytes).into_bytes() -} - #[cfg(test)] mod tests { #[test] diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index ab378487..5b57d91d 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -2,9 +2,7 @@ mod integration { use std::collections::HashMap; use std::env; - use std::process::Stdio; use std::str::FromStr; - use std::sync::Arc; use bitcoin::address::NetworkChecked; use bitcoin::psbt::Psbt; @@ -14,270 +12,339 @@ mod integration { use bitcoind::bitcoincore_rpc::RpcApi; use log::{debug, log_enabled, Level}; use payjoin::bitcoin::base64; - use payjoin::receive::{Headers, UncheckedProposal}; - use payjoin::send::{Request, RequestBuilder}; + use payjoin::receive::UncheckedProposal; + use payjoin::send::RequestBuilder; use payjoin::Uri; - use testcontainers::Container; - use testcontainers_modules::postgres::Postgres; - use testcontainers_modules::testcontainers::clients::Cli; - use tokio::process::{Child, Command}; - use tokio::task::spawn_blocking; - - const EXAMPLE_URL: &str = "https://example.com"; - const RELAY_URL: &str = "https://localhost:8088"; - const LOCAL_CERT_FILE: &str = "localhost.der"; type BoxError = Box; - #[test] - fn v1_to_v1() -> Result<(), BoxError> { - let _ = env_logger::try_init(); - let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; - - // Receiver creates the payjoin URI - let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL); - // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - let psbt = build_original_psbt(&sender, &pj_uri)?; - debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? - .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; - let headers = HeaderMock::from_vec(&req.body); - - // ********************** - // Inside the Receiver: - // this data would transit from one party to another over the network in production - let response = handle_pj_request(req, headers, receiver); - // this response would be returned as http response to the sender - - // ********************** - // Inside the Sender: - // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; - let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; - sender.send_raw_transaction(&payjoin_tx)?; - Ok(()) - } + #[cfg(not(feature = "v2"))] + mod v1 { + use payjoin::receive::Headers; + use payjoin::send::RequestBuilder; + + use super::*; + + const EXAMPLE_URL: &str = "https://example.com"; + + #[test] + fn v1_to_v1() -> Result<(), BoxError> { + let _ = env_logger::try_init(); + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL); + // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; + let headers = HeaderMock::from_vec(&req.body); + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let response = handle_pj_request(req, headers, receiver); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + Ok(()) + } - #[tokio::test] - async fn v2_to_v2() -> Result<(), BoxError> { - std::env::set_var("RUST_LOG", "debug"); - let _ = env_logger::builder().is_test(true).try_init(); - let docker = Cli::default(); - let (mut relay, _db) = init_relay(&docker).await; - let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; - - // ********************** - // 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??; - 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 pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); - - // ********************** - // Inside the Sender: - // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - let psbt = build_original_psbt(&sender, &pj_uri)?; - debug!("Original psbt: {:#?}", psbt); - 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 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??; - log::info!("Response: {:#?}", &response); - assert!(response.status() == 202); - // no response body yet since we are async and pushed fallback_psbt to the buffer - - // ********************** - // 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 response = spawn_blocking(move || http_agent().get(&receive_endpoint).call()).await??; - let response = handle_relay_response(response.into_reader(), receiver); - // this response would be returned as http response to the sender - - // ********************** - // Inside the Sender: - // Sender checks, signs, finalizes, extracts, and broadcasts - let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; - let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; - sender.send_raw_transaction(&payjoin_tx)?; - log::info!("sent"); - relay.kill().await?; - let output = &relay.wait_with_output().await?; - log::info!("Status: {}", output.status); - Ok(()) - } + struct HeaderMock(HashMap); - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn v1_to_v2() -> Result<(), BoxError> { - std::env::set_var("RUST_LOG", "debug"); - let _ = env_logger::builder().is_test(true).try_init(); - let docker = Cli::default(); - let (mut relay, _db) = init_relay(&docker).await; - let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; - - // ********************** - // 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(); - 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 pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); - - // ********************** - // Inside the V1 Sender: - // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri - let psbt = build_original_psbt(&sender, &pj_uri)?; - debug!("Original psbt: {:#?}", psbt); - 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?; - match res { - Err(ureq::Error::Status(code, _)) => assert_eq!(code, 503), - _ => panic!("Expected response status code 503, found {:?}", res), + impl Headers for HeaderMock { + fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } } - // ********************** - // Inside the Receiver: - let receiver_loop = tokio::task::spawn(async move { - let fallback_psbt_body = loop { - let pk64 = pubkey_base64.clone(); - let response = spawn_blocking(move || { - let receive_endpoint = format!("{}/{}", RELAY_URL, &pk64); - http_agent().get(&receive_endpoint).call() - }) - .await??; + impl HeaderMock { + fn from_vec(body: &[u8]) -> HeaderMock { + let mut h = HashMap::new(); + h.insert("content-type".to_string(), "text/plain".to_string()); + h.insert("content-length".to_string(), body.len().to_string()); + HeaderMock(h) + } + } + + // Receiver receive and process original_psbt from a sender + // In production it it will come in as an HTTP request (over ssl or onion) + fn handle_pj_request( + req: Request, + headers: impl Headers, + receiver: bitcoincore_rpc::Client, + ) -> String { + // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) + let proposal = payjoin::receive::UncheckedProposal::from_request( + req.body.as_slice(), + req.url.query().unwrap_or(""), + headers, + ) + .unwrap(); + handle_proposal(proposal, receiver) + } + } - if response.status() == 200 { - debug!("GET'd fallback_psbt"); - break response.into_reader(); - } else if response.status() == 202 { - log::info!("No response yet for POST payjoin request, retrying some seconds"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - } else { - log::error!("Unexpected response status: {}", response.status()); - panic!("Unexpected response status: {}", response.status()) - } - }; - debug!("handle relay response"); - let response = handle_relay_response(fallback_psbt_body, receiver); - 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); + #[cfg(feature = "v2")] + mod v2 { + use std::process::Stdio; + use std::sync::Arc; + + use testcontainers::Container; + use testcontainers_modules::postgres::Postgres; + use testcontainers_modules::testcontainers::clients::Cli; + use tokio::process::{Child, Command}; + use tokio::task::spawn_blocking; + + use super::*; + + const RELAY_URL: &str = "https://localhost:8088"; + const LOCAL_CERT_FILE: &str = "localhost.der"; + + #[tokio::test] + async fn v2_to_v2() -> Result<(), BoxError> { + std::env::set_var("RUST_LOG", "debug"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let (mut relay, _db) = init_relay(&docker).await; + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // ********************** + // 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??; + 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 pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + + // ********************** + // Inside the Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + 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 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??; + log::info!("Response: {:#?}", &response); + assert!(response.status() == 202); + // no response body yet since we are async and pushed fallback_psbt to the buffer + + // ********************** + // 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 response = - spawn_blocking(move || http_agent().post(&payjoin_endpoint).send_string(&response)) + spawn_blocking(move || http_agent().get(&receive_endpoint).call()).await??; + let response = handle_relay_response(response.into_reader(), receiver); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + log::info!("sent"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[cfg(feature = "v2")] + async fn v1_to_v2() -> Result<(), BoxError> { + std::env::set_var("RUST_LOG", "debug"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let (mut relay, _db) = init_relay(&docker).await; + let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + + // ********************** + // 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(); + 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 pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + + // ********************** + // Inside the V1 Sender: + // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let psbt = build_original_psbt(&sender, &pj_uri)?; + debug!("Original psbt: {:#?}", psbt); + 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?; + match res { + Err(ureq::Error::Status(code, _)) => assert_eq!(code, 503), + _ => panic!("Expected response status code 503, found {:?}", res), + } + + // ********************** + // Inside the Receiver: + let receiver_loop = tokio::task::spawn(async move { + let fallback_psbt_body = loop { + let pk64 = pubkey_base64.clone(); + let response = spawn_blocking(move || { + let receive_endpoint = format!("{}/{}", RELAY_URL, &pk64); + http_agent().get(&receive_endpoint).call() + }) .await??; - debug!("POSTed with payjoin_psbt response status {}", response.status()); - assert!(response.status() == 204); - Ok::<_, Box>(()) - }); - - // ********************** - // 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?; - log::info!("Response: {:#?}", &response); - assert!(response.status() == 200); - - 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"); - assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); - relay.kill().await?; - let output = &relay.wait_with_output().await?; - log::info!("Status: {}", output.status); - Ok(()) - } - struct HeaderMock(HashMap); + if response.status() == 200 { + debug!("GET'd fallback_psbt"); + break response.into_reader(); + } else if response.status() == 202 { + log::info!( + "No response yet for POST payjoin request, retrying some seconds" + ); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } else { + log::error!("Unexpected response status: {}", response.status()); + panic!("Unexpected response status: {}", response.status()) + } + }; + debug!("handle relay response"); + let response = handle_relay_response(fallback_psbt_body, receiver); + 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 response = spawn_blocking(move || { + http_agent().post(&payjoin_endpoint).send_string(&response) + }) + .await??; + debug!("POSTed with payjoin_psbt response status {}", response.status()); + assert!(response.status() == 204); + Ok::<_, Box>(()) + }); + + // ********************** + // 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?; + log::info!("Response: {:#?}", &response); + assert!(response.status() == 200); + + 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"); + assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); + relay.kill().await?; + let output = &relay.wait_with_output().await?; + log::info!("Status: {}", output.status); + Ok(()) + } - impl Headers for HeaderMock { - fn get_header(&self, key: &str) -> Option<&str> { self.0.get(key).map(|e| e.as_str()) } - } + async fn init_relay<'a>(docker: &'a Cli) -> (Child, Container<'a, Postgres>) { + println!("Initializing relay server"); + env::set_var("PJ_RELAY_PORT", "8088"); + env::set_var("PJ_RELAY_TIMEOUT_SECS", "2"); + //env::set_var("PGPASSWORD", "welcome"); + let postgres = docker.run(Postgres::default()); + env::set_var("PJ_DB_HOST", format!("127.0.0.1:{}", postgres.get_host_port_ipv4(5432))); + println!("Postgres running on {}", postgres.get_host_port_ipv4(5432)); + compile_payjoin_relay().await.wait().await.unwrap(); + let workspace_root = env::var("CARGO_MANIFEST_DIR").unwrap(); + let binary_path = format!("{}/../target/debug/payjoin-relay", workspace_root); + let mut command = Command::new(binary_path); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + (command.spawn().unwrap(), postgres) + } - impl HeaderMock { - fn from_vec(body: &[u8]) -> HeaderMock { - let mut h = HashMap::new(); - h.insert("content-type".to_string(), "text/plain".to_string()); - h.insert("content-length".to_string(), body.len().to_string()); - HeaderMock(h) + async fn compile_payjoin_relay() -> Child { + // set payjoin relay target dir to payjoin-relay + let mut command = Command::new("cargo"); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ + "build", + "--package", + "payjoin-relay", + "--features", + "danger-local-https", + ]); + command.spawn().unwrap() } - } - async fn init_relay<'a>(docker: &'a Cli) -> (Child, Container<'a, Postgres>) { - println!("Initializing relay server"); - env::set_var("PJ_RELAY_PORT", "8088"); - env::set_var("PJ_RELAY_TIMEOUT_SECS", "2"); - //env::set_var("PGPASSWORD", "welcome"); - let postgres = docker.run(Postgres::default()); - env::set_var("PJ_DB_HOST", format!("127.0.0.1:{}", postgres.get_host_port_ipv4(5432))); - println!("Postgres running on {}", postgres.get_host_port_ipv4(5432)); - compile_payjoin_relay().await.wait().await.unwrap(); - let workspace_root = env::var("CARGO_MANIFEST_DIR").unwrap(); - let binary_path = format!("{}/../target/debug/payjoin-relay", workspace_root); - let mut command = Command::new(binary_path); - command.stdout(Stdio::inherit()).stderr(Stdio::inherit()); - (command.spawn().unwrap(), postgres) - } + 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) + } - async fn compile_payjoin_relay() -> Child { - // set payjoin relay target dir to payjoin-relay - let mut command = Command::new("cargo"); - command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ - "build", - "--package", - "payjoin-relay", - "--features", - "danger-local-https", - ]); - command.spawn().unwrap() + fn http_agent() -> ureq::Agent { + use rustls::client::ClientConfig; + use rustls::{Certificate, RootCertStore}; + use ureq::AgentBuilder; + + let mut local_cert_path = std::env::temp_dir(); + local_cert_path.push(LOCAL_CERT_FILE); + println!("TEST CERT PATH {:?}", &local_cert_path); + let cert_der = std::fs::read(local_cert_path).unwrap(); + let mut root_cert_store = RootCertStore::empty(); + root_cert_store.add(&Certificate(cert_der)).unwrap(); + let client_config = ClientConfig::builder() + .with_safe_defaults() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + + AgentBuilder::new().tls_config(Arc::new(client_config)).build() + } } fn init_bitcoind_sender_receiver( @@ -347,28 +414,6 @@ mod integration { Ok(Psbt::from_str(&psbt)?) } - // Receiver receive and process original_psbt from a sender - // In production it it will come in as an HTTP request (over ssl or onion) - fn handle_pj_request( - req: Request, - headers: impl Headers, - receiver: bitcoincore_rpc::Client, - ) -> String { - // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) - let proposal = payjoin::receive::UncheckedProposal::from_request( - req.body.as_slice(), - req.url.query().unwrap_or(""), - headers, - ) - .unwrap(); - handle_proposal(proposal, receiver) - } - - 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) - } - fn handle_proposal(proposal: UncheckedProposal, receiver: bitcoincore_rpc::Client) -> String { // 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(); @@ -471,23 +516,4 @@ mod integration { Ok(payjoin_psbt.extract_tx()) } - - fn http_agent() -> ureq::Agent { - use rustls::client::ClientConfig; - use rustls::{Certificate, RootCertStore}; - use ureq::AgentBuilder; - - let mut local_cert_path = std::env::temp_dir(); - local_cert_path.push(LOCAL_CERT_FILE); - println!("TEST CERT PATH {:?}", &local_cert_path); - let cert_der = std::fs::read(local_cert_path).unwrap(); - let mut root_cert_store = RootCertStore::empty(); - root_cert_store.add(&Certificate(cert_der)).unwrap(); - let client_config = ClientConfig::builder() - .with_safe_defaults() - .with_root_certificates(root_cert_store) - .with_no_client_auth(); - - AgentBuilder::new().tls_config(Arc::new(client_config)).build() - } } From a1d090c4a1354e94fb4c557cf9d9b71953b38228 Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 31 Aug 2023 16:21:25 -0400 Subject: [PATCH 3/6] 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( From 5935775a35cd650761810e96ac1b3527136a1f68 Mon Sep 17 00:00:00 2001 From: DanGould Date: Wed, 15 Nov 2023 15:45:42 -0500 Subject: [PATCH 4/6] Protect metadata with Oblivious HTTP --- Cargo.lock | 438 +++++++++++++++++++++++++++--- payjoin-cli/Cargo.toml | 1 + payjoin-cli/src/app.rs | 4 +- payjoin-cli/tests/e2e.rs | 1 + payjoin-relay/Cargo.toml | 6 +- payjoin-relay/src/main.rs | 195 +++++++++++--- payjoin/Cargo.toml | 4 +- payjoin/src/receive/mod.rs | 112 +------- payjoin/src/receive/v2.rs | 441 ++++++++++++++++++++++++++++++ payjoin/src/send/error.rs | 2 +- payjoin/src/send/mod.rs | 231 +++++++++------- payjoin/src/uri.rs | 75 +++++- payjoin/src/v2.rs | 54 +++- payjoin/tests/integration.rs | 505 ++++++++++++++++++++++------------- 14 files changed, 1592 insertions(+), 477 deletions(-) create mode 100644 payjoin/src/receive/v2.rs diff --git a/Cargo.lock b/Cargo.lock index e5805e9f..317658ca 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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", + "rand_core 0.6.4", +] + [[package]] name = "aead" version = "0.5.2" @@ -27,6 +37,57 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.2.11", + "opaque-debug", +] + +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.11", +] + +[[package]] +name = "aes-gcm" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +dependencies = [ + "aead 0.4.3", + "aes 0.7.5", + "cipher 0.3.0", + "ctr 0.8.0", + "ghash 0.4.4", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.3", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.0", + "subtle", +] + [[package]] name = "ahash" version = "0.7.7" @@ -158,6 +219,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bhttp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3dbe26a7ede99e55ac5ba2e1bcbfa1656a65d01e6b580dd6224eb4c26a20cc5" +dependencies = [ + "thiserror", + "url", +] + [[package]] name = "bip21" version = "0.3.1" @@ -258,6 +329,15 @@ dependencies = [ "serde", ] +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -331,6 +411,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7ad89dc1128635074c268ee661f90c3f7e83d9fd12910608c36b47d6c3412" +dependencies = [ + "cfg-if", + "cipher 0.3.0", + "cpufeatures 0.1.5", + "zeroize", +] + [[package]] name = "chacha20" version = "0.9.1" @@ -338,8 +430,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.11", +] + +[[package]] +name = "chacha20poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580317203210c517b6d44794abfbe600698276db18127e37ad3e69bf5e848e5" +dependencies = [ + "aead 0.4.3", + "chacha20 0.7.1", + "cipher 0.3.0", + "poly1305 0.7.2", + "zeroize", ] [[package]] @@ -348,13 +453,22 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", "zeroize", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "cipher" version = "0.4.4" @@ -472,6 +586,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cpufeatures" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.11" @@ -531,10 +654,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher 0.3.0", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + [[package]] name = "darling" version = "0.13.4" @@ -590,13 +754,22 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", @@ -850,6 +1023,26 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval 0.5.3", +] + +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval 0.6.1", +] + [[package]] name = "gimli" version = "0.28.1" @@ -939,13 +1132,33 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" +dependencies = [ + "digest 0.9.0", + "hmac 0.11.0", +] + [[package]] name = "hkdf" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest 0.9.0", ] [[package]] @@ -954,7 +1167,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -966,6 +1179,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "hpke" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf39e5461bfdc6ad0fbc97067519fcaf96a7a2e67f24cc0eb8a1e7c0c45af792" +dependencies = [ + "aead 0.5.2", + "aes-gcm 0.10.3", + "byteorder", + "chacha20poly1305 0.10.1", + "digest 0.10.7", + "generic-array", + "hkdf 0.12.3", + "hmac 0.12.1", + "rand_core 0.6.4", + "sha2 0.10.8", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "0.2.11" @@ -1159,9 +1393,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.150" +version = "0.2.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" [[package]] name = "libm" @@ -1224,7 +1458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1370,6 +1604,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohttp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef3e1559d017cef55f296e593737811b65106eb822bac13622ed49cbfebcf62" +dependencies = [ + "aead 0.4.3", + "aes-gcm 0.9.4", + "byteorder", + "chacha20poly1305 0.8.0", + "hex", + "hkdf 0.11.0", + "hpke", + "lazy_static", + "log", + "rand", + "serde", + "serde_derive", + "sha2 0.9.9", + "thiserror", + "toml", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1449,12 +1706,14 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" name = "payjoin" version = "0.11.0" dependencies = [ + "bhttp", "bip21", "bitcoin", "bitcoind", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "env_logger", "log", + "ohttp", "rand", "rustls", "testcontainers", @@ -1492,9 +1751,11 @@ name = "payjoin-relay" version = "0.0.1" dependencies = [ "anyhow", + "bhttp", + "bitcoin", "hyper", "hyper-rustls", - "payjoin", + "ohttp", "rcgen", "rustls", "sqlx", @@ -1576,7 +1837,7 @@ checksum = "7c747191d4ad9e4a4ab9c8798f1e82a39affe7ef9648390b7e5548d18e099de6" dependencies = [ "once_cell", "pest", - "sha2", + "sha2 0.10.8", ] [[package]] @@ -1618,15 +1879,50 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.4.1", +] + [[package]] name = "poly1305" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.11", + "opaque-debug", + "universal-hash 0.4.1", +] + +[[package]] +name = "polyval" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52cff9d1d4dee5fe6d03729099f4a310a41179e0a10dbf542039873f2e826fb" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.11", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -1691,7 +1987,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1701,9 +1997,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + [[package]] name = "rand_core" version = "0.6.4" @@ -1825,13 +2127,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2045,8 +2347,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.11", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.11", + "digest 0.9.0", + "opaque-debug", ] [[package]] @@ -2056,8 +2371,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.11", + "digest 0.10.7", ] [[package]] @@ -2084,8 +2399,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] @@ -2202,7 +2517,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "smallvec", "sqlformat", "thiserror", @@ -2241,7 +2556,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -2264,7 +2579,7 @@ dependencies = [ "byteorder", "bytes", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2273,8 +2588,8 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", - "hmac", + "hkdf 0.12.3", + "hmac 0.12.1", "itoa", "log", "md-5", @@ -2285,7 +2600,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.8", "smallvec", "sqlx-core", "stringprep", @@ -2312,8 +2627,8 @@ dependencies = [ "futures-io", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.12.3", + "hmac 0.12.1", "home", "itoa", "log", @@ -2324,7 +2639,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2", + "sha2 0.10.8", "smallvec", "sqlx-core", "stringprep", @@ -2375,9 +2690,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "subtle" -version = "2.5.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" [[package]] name = "syn" @@ -2443,12 +2758,12 @@ dependencies = [ "bollard-stubs", "futures", "hex", - "hmac", + "hmac 0.12.1", "log", "rand", "serde", "serde_json", - "sha2", + "sha2 0.10.8", ] [[package]] @@ -2733,6 +3048,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -3079,13 +3404,26 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "x25519-dalek" +version = "2.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5da623d8af10a62342bcbbb230e33e58a63255a58012f8653c578e54bab48df" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "xattr" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc6ab6ec1907d1a901cdbcd2bd4cb9e7d64ce5c9739cbb97d3c391acd8c7fae" +checksum = "d367426ae76bdfce3d8eaea6e94422afd6def7d46f9c89e2980309115b3c2c41" dependencies = [ "libc", + "linux-raw-sys", + "rustix", ] [[package]] @@ -3131,6 +3469,20 @@ name = "zeroize" version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.39", +] [[package]] name = "zip" diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index fe757a53..a178312d 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" [features] native-certs = ["ureq/native-certs"] danger-local-https = ["rcgen", "rustls/dangerous_configuration", "hyper-rustls"] +v2 = ["payjoin/v2"] [dependencies] anyhow = "1.0.70" diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 8531fa04..99e70de8 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -94,7 +94,9 @@ impl App { let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) .with_context(|| "Failed to build payjoin request")? .build_recommended(fee_rate) - .with_context(|| "Failed to build payjoin request")?; + .with_context(|| "Failed to build payjoin request")? + .extract_v1()?; + let http = http_agent()?; println!("Sending fallback request to {}", &req.url); let response = spawn_blocking(move || { diff --git a/payjoin-cli/tests/e2e.rs b/payjoin-cli/tests/e2e.rs index e26d9d6f..13194f22 100644 --- a/payjoin-cli/tests/e2e.rs +++ b/payjoin-cli/tests/e2e.rs @@ -1,4 +1,5 @@ #[cfg(feature = "danger-local-https")] +#[cfg(not(feature = "v2"))] mod e2e { use std::env; use std::process::Stdio; diff --git a/payjoin-relay/Cargo.toml b/payjoin-relay/Cargo.toml index b374af0a..30462c80 100644 --- a/payjoin-relay/Cargo.toml +++ b/payjoin-relay/Cargo.toml @@ -18,10 +18,12 @@ exclude = ["tests"] danger-local-https = ["hyper-rustls", "rcgen", "rustls"] [dependencies] +anyhow = "1.0.71" +bitcoin = { version = "0.30.0", features = ["base64"] } +bhttp = { version = "0.4.0", features = ["http"] } hyper = { version = "0.14", features = ["full"] } hyper-rustls = { version = "0.24", optional = true } -anyhow = "1.0.71" -payjoin = { path = "../payjoin", features = ["base64"] } +ohttp = "0.4.0" rcgen = { version = "0.11", optional = true } rustls = { version = "0.21", optional = true } sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] } diff --git a/payjoin-relay/src/main.rs b/payjoin-relay/src/main.rs index 44dd99a3..dcd7dd39 100644 --- a/payjoin-relay/src/main.rs +++ b/payjoin-relay/src/main.rs @@ -1,13 +1,15 @@ use std::env; use std::net::SocketAddr; -use std::str::FromStr; +use std::sync::Arc; use anyhow::Result; +use bitcoin::{self, base64}; use hyper::server::conn::AddrIncoming; use hyper::server::Builder; use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, HeaderMap, Method, Request, Response, Server, StatusCode}; -use tracing::{debug, error, info}; +use hyper::{Body, Method, Request, Response, Server, StatusCode, Uri}; +use tokio::sync::Mutex; +use tracing::{debug, error, info, trace}; use tracing_subscriber::filter::LevelFilter; use tracing_subscriber::EnvFilter; @@ -15,6 +17,8 @@ const DEFAULT_RELAY_PORT: &str = "8080"; const DEFAULT_DB_HOST: &str = "localhost:5432"; const DEFAULT_TIMEOUT_SECS: u64 = 30; const MAX_BUFFER_SIZE: usize = 65536; +const V1_REJECT_RES_JSON: &str = + r#"{{"errorCode": "original-psbt-rejected ", "message": "Body is not a string"}}"#; const V1_UNAVAILABLE_RES_JSON: &str = r#"{{"errorCode": "unavailable", "message": "V2 receiver offline. V1 sends require synchronous communications."}}"#; mod db; @@ -32,10 +36,12 @@ async fn main() -> Result<(), Box> { let db_host = env::var("PJ_DB_HOST").unwrap_or_else(|_| DEFAULT_DB_HOST.to_string()); let pool = DbPool::new(timeout, db_host).await?; + let ohttp = Arc::new(Mutex::new(init_ohttp()?)); let make_svc = make_service_fn(|_| { let pool = pool.clone(); + let ohttp = ohttp.clone(); async move { - let handler = move |req| handle_web_req(pool.clone(), req); + let handler = move |req| handle_ohttp_gateway(req, pool.clone(), ohttp.clone()); Ok::<_, hyper::Error>(service_fn(handler)) } }); @@ -88,17 +94,42 @@ fn init_server(bind_addr: &SocketAddr) -> Result) -> Result> { +fn init_ohttp() -> Result { + use ohttp::hpke::{Aead, Kdf, Kem}; + use ohttp::{KeyId, SymmetricSuite}; + + const KEY_ID: KeyId = 1; + const KEM: Kem = Kem::X25519Sha256; + const SYMMETRIC: &[SymmetricSuite] = + &[SymmetricSuite::new(Kdf::HkdfSha256, Aead::ChaCha20Poly1305)]; + + // 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::encode_config( + encoded_config, + base64::Config::new(base64::CharacterSet::UrlSafe, false), + ); + info!("ohttp server config base64 UrlSafe: {:?}", b64_config); + Ok(ohttp::Server::new(server_config)?) +} + +async fn handle_ohttp_gateway( + req: Request, + pool: DbPool, + ohttp: Arc>, +) -> Result> { let path = req.uri().path().to_string(); + let query = req.uri().query().unwrap_or_default().to_string(); let (parts, body) = req.into_parts(); let path_segments: Vec<&str> = path.split('/').collect(); - debug!("{:?}", &path_segments); + debug!("handle_ohttp_gateway: {:?}", &path_segments); let mut response = match (parts.method, path_segments.as_slice()) { - (Method::POST, &["", ""]) => post_enroll(body).await, - (Method::POST, &["", id]) => post_fallback(id, body, parts.headers, pool).await, - (Method::GET, &["", id]) => get_fallback(id, pool).await, - (Method::POST, &["", id, "payjoin"]) => post_payjoin(id, body, pool).await, + (Method::POST, ["", ""]) => handle_ohttp(body, pool, ohttp).await, + (Method::GET, ["", "ohttp-config"]) => + Ok(get_ohttp_config(ohttp_config(&ohttp).await?).await), + (Method::POST, ["", id]) => post_fallback_v1(id, query, body, pool).await, _ => Ok(not_found()), } .unwrap_or_else(|e| e.to_response()); @@ -111,30 +142,88 @@ async fn handle_web_req(pool: DbPool, req: Request) -> Result>, +) -> Result, HandlerError> { + // decapsulate + let ohttp_body = + hyper::body::to_bytes(body).await.map_err(|e| HandlerError::BadRequest(e.into()))?; + let mut ohttp_locked = ohttp.lock().await; + let (bhttp_req, res_ctx) = + ohttp_locked.decapsulate(&ohttp_body).map_err(|e| HandlerError::BadRequest(e.into()))?; + drop(ohttp_locked); + let mut cursor = std::io::Cursor::new(bhttp_req); + let req = + bhttp::Message::read_bhttp(&mut cursor).map_err(|e| HandlerError::BadRequest(e.into()))?; + let uri = Uri::builder() + .scheme(req.control().scheme().unwrap_or_default()) + .authority(req.control().authority().unwrap_or_default()) + .path_and_query(req.control().path().unwrap_or_default()) + .build()?; + let body = req.content().to_vec(); + let mut http_req = + Request::builder().uri(uri).method(req.control().method().unwrap_or_default()); + for header in req.header().fields() { + http_req = http_req.header(header.name(), header.value()) + } + let request = http_req.body(Body::from(body))?; + + let response = handle_v2(pool, request).await?; + + let (parts, body) = response.into_parts(); + let mut bhttp_res = bhttp::Message::response(parts.status.as_u16()); + let full_body = hyper::body::to_bytes(body) + .await + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + bhttp_res.write_content(&full_body); + let mut bhttp_bytes = Vec::new(); + bhttp_res + .write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_bytes) + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + let ohttp_res = res_ctx + .encapsulate(&bhttp_bytes) + .map_err(|e| HandlerError::InternalServerError(e.into()))?; + Ok(Response::new(Body::from(ohttp_res))) +} + +async fn handle_v2(pool: DbPool, req: Request) -> Result, HandlerError> { + let path = req.uri().path().to_string(); + let (parts, body) = req.into_parts(); + + let path_segments: Vec<&str> = path.split('/').collect(); + debug!("handle_v2: {:?}", &path_segments); + match (parts.method, path_segments.as_slice()) { + (Method::POST, &["", ""]) => post_enroll(body).await, + (Method::POST, &["", id]) => post_fallback_v2(id, body, pool).await, + (Method::GET, &["", id]) => get_fallback(id, pool).await, + (Method::POST, &["", id, "payjoin"]) => post_payjoin(id, body, pool).await, + _ => Ok(not_found()), + } +} + enum HandlerError { PayloadTooLarge, - ReceiverOffline, InternalServerError(anyhow::Error), BadRequest(anyhow::Error), } impl HandlerError { fn to_response(&self) -> Response { - let (status, body) = match self { - HandlerError::PayloadTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, Body::empty()), - HandlerError::ReceiverOffline => - (StatusCode::SERVICE_UNAVAILABLE, Body::from(V1_UNAVAILABLE_RES_JSON)), + let status = match self { + HandlerError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, HandlerError::BadRequest(e) => { error!("Bad request: {}", e); - (StatusCode::BAD_REQUEST, Body::empty()) + StatusCode::BAD_REQUEST } HandlerError::InternalServerError(e) => { error!("Internal server error: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()) + StatusCode::INTERNAL_SERVER_ERROR } }; - let mut res = Response::new(body); + let mut res = Response::new(Body::empty()); *res.status_mut() = status; res } @@ -145,7 +234,6 @@ impl From for HandlerError { } async fn post_enroll(body: Body) -> Result, HandlerError> { - use payjoin::{base64, bitcoin}; let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); let bytes = hyper::body::to_bytes(body).await.map_err(|e| HandlerError::BadRequest(e.into()))?; @@ -159,16 +247,51 @@ async fn post_enroll(body: Body) -> Result, HandlerError> { Ok(Response::builder().status(StatusCode::NO_CONTENT).body(Body::empty())?) } -async fn post_fallback( +async fn post_fallback_v1( + id: &str, + query: String, + body: Body, + pool: DbPool, +) -> Result, HandlerError> { + trace!("Post fallback v1"); + let none_response = Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .body(Body::from(V1_UNAVAILABLE_RES_JSON))?; + let bad_request_body_res = + Response::builder().status(StatusCode::BAD_REQUEST).body(Body::from(V1_REJECT_RES_JSON))?; + + let body_bytes = match hyper::body::to_bytes(body).await { + Ok(bytes) => bytes.to_vec(), + Err(_) => return Ok(bad_request_body_res), + }; + + let body_str = match String::from_utf8(body_bytes) { + Ok(body_str) => body_str, + Err(_) => return Ok(bad_request_body_res), + }; + + let v2_compat_body = Body::from(format!("{}\n{}", body_str, query)); + post_fallback(id, v2_compat_body, pool, none_response).await +} + +async fn post_fallback_v2( id: &str, body: Body, - headers: HeaderMap, pool: DbPool, ) -> Result, HandlerError> { - use hyper::header::HeaderValue; + trace!("Post fallback v2"); + let none_response = Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?; + post_fallback(id, body, pool, none_response).await +} +async fn post_fallback( + id: &str, + body: Body, + pool: DbPool, + none_response: Response, +) -> Result, HandlerError> { + tracing::trace!("Post fallback"); let id = shorten_string(id); - let is_async = headers.get("Async") == Some(&HeaderValue::from_static("true")); let req = hyper::body::to_bytes(body) .await .map_err(|e| HandlerError::InternalServerError(e.into()))?; @@ -186,19 +309,12 @@ async fn post_fallback( Ok(buffered_res) => Ok(Response::new(Body::from(buffered_res))), Err(e) => Err(HandlerError::BadRequest(e.into())), }, - None => fallback_timeout_response(is_async), - } -} - -fn fallback_timeout_response(is_req_async: bool) -> Result, HandlerError> { - if is_req_async { - Ok(Response::builder().status(StatusCode::ACCEPTED).body(Body::empty())?) - } else { - Err(HandlerError::ReceiverOffline) + None => Ok(none_response), } } async fn get_fallback(id: &str, pool: DbPool) -> Result, HandlerError> { + trace!("GET fallback"); let id = shorten_string(id); match pool.peek_req(&id).await { Some(result) => match result { @@ -210,6 +326,7 @@ async fn get_fallback(id: &str, pool: DbPool) -> Result, HandlerE } async fn post_payjoin(id: &str, body: Body, pool: DbPool) -> Result, HandlerError> { + trace!("POST payjoin"); let id = shorten_string(id); let res = hyper::body::to_bytes(body) .await @@ -227,4 +344,18 @@ fn not_found() -> Response { res } +async fn get_ohttp_config(config: String) -> Response { + trace!("GET ohttp config: {:?}", config); + let mut res = Response::default(); + *res.body_mut() = Body::from(config); + res +} + fn shorten_string(input: &str) -> String { input.chars().take(8).collect() } + +async fn ohttp_config(server: &Arc>) -> Result { + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let server = server.lock().await; + let encoded_config = server.config().encode()?; + Ok(base64::encode_config(encoded_config, b64_config)) +} diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 77e2194d..a6e36f6c 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,13 +18,15 @@ exclude = ["tests"] send = [] receive = ["rand"] base64 = ["bitcoin/base64"] -v2 = ["bitcoin/rand-std", "chacha20poly1305"] +v2 = ["bitcoin/rand-std", "chacha20poly1305", "ohttp", "bhttp"] [dependencies] bitcoin = { version = "0.30.0", features = ["base64"] } bip21 = "0.3.1" chacha20poly1305 = { version = "0.10.1", optional = true } log = { version = "0.4.14"} +ohttp = { version = "0.4.0", optional = true } +bhttp = { version = "0.4.0", optional = true } rand = { version = "0.8.4", optional = true } url = "2.2.2" diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 52ff0fff..596b7750 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -270,10 +270,12 @@ use std::cmp::{max, min}; use std::collections::{BTreeMap, HashMap}; use bitcoin::psbt::Psbt; -use bitcoin::{Amount, FeeRate, OutPoint, Script, TxOut}; +use bitcoin::{base64, Amount, FeeRate, OutPoint, Script, TxOut}; mod error; mod optional_parameters; +#[cfg(feature = "v2")] +pub mod v2; pub use error::{Error, RequestError, SelectionError}; use error::{InternalRequestError, InternalSelectionError}; @@ -288,45 +290,6 @@ 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 @@ -339,7 +302,6 @@ impl ProposalContext { pub struct UncheckedProposal { psbt: Psbt, params: Params, - v2_context: Option, } impl UncheckedProposal { @@ -367,7 +329,7 @@ impl UncheckedProposal { // enforce the limit let mut buf = vec![0; content_length as usize]; // 4_000_000 * 4 / 3 fits in u32 body.read_exact(&mut buf).map_err(InternalRequestError::Io)?; - let base64 = bitcoin::base64::decode(&buf).map_err(InternalRequestError::Base64)?; + let base64 = base64::decode(&buf).map_err(InternalRequestError::Base64)?; let unchecked_psbt = Psbt::deserialize(&base64).map_err(InternalRequestError::Psbt)?; let psbt = unchecked_psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; @@ -379,28 +341,7 @@ impl UncheckedProposal { // TODO check that params are valid for the request's Original PSBT - 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 }) + Ok(UncheckedProposal { psbt, params }) } /// The Sender's Original PSBT @@ -425,11 +366,7 @@ 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, - v2_context: self.v2_context, - }) + Ok(MaybeInputsOwned { psbt: self.psbt, params: self.params }) } else { Err(Error::BadRequest(InternalRequestError::OriginalPsbtNotBroadcastable.into())) } @@ -441,7 +378,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, v2_context: self.v2_context } + MaybeInputsOwned { psbt: self.psbt, params: self.params } } } @@ -451,7 +388,6 @@ impl UncheckedProposal { pub struct MaybeInputsOwned { psbt: Psbt, params: Params, - v2_context: Option, } impl MaybeInputsOwned { @@ -485,11 +421,7 @@ impl MaybeInputsOwned { } err?; - Ok(MaybeMixedInputScripts { - psbt: self.psbt, - params: self.params, - v2_context: self.v2_context, - }) + Ok(MaybeMixedInputScripts { psbt: self.psbt, params: self.params }) } } @@ -499,7 +431,6 @@ impl MaybeInputsOwned { pub struct MaybeMixedInputScripts { psbt: Psbt, params: Params, - v2_context: Option, } impl MaybeMixedInputScripts { @@ -542,7 +473,7 @@ impl MaybeMixedInputScripts { })?; } - Ok(MaybeInputsSeen { psbt: self.psbt, params: self.params, v2_context: self.v2_context }) + Ok(MaybeInputsSeen { psbt: self.psbt, params: self.params }) } } @@ -552,7 +483,6 @@ 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. @@ -575,7 +505,7 @@ impl MaybeInputsSeen { } })?; - Ok(OutputsUnknown { psbt: self.psbt, params: self.params, v2_context: self.v2_context }) + Ok(OutputsUnknown { psbt: self.psbt, params: self.params }) } } @@ -586,7 +516,6 @@ impl MaybeInputsSeen { pub struct OutputsUnknown { psbt: Psbt, params: Params, - v2_context: Option, } impl OutputsUnknown { @@ -617,18 +546,17 @@ impl OutputsUnknown { payjoin_psbt: self.psbt, params: self.params, owned_vouts, - v2_context: self.v2_context, }) } } /// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +#[derive(Debug)] pub struct ProvisionalProposal { original_psbt: Psbt, payjoin_psbt: Psbt, params: Params, owned_vouts: Vec, - v2_context: Option, } impl ProvisionalProposal { @@ -857,7 +785,6 @@ impl ProvisionalProposal { payjoin_psbt: self.payjoin_psbt, owned_vouts: self.owned_vouts, params: self.params, - v2_context: self.v2_context, }) } @@ -878,7 +805,6 @@ pub struct PayjoinProposal { payjoin_psbt: Psbt, params: Params, owned_vouts: Vec, - v2_context: Option, } impl PayjoinProposal { @@ -893,22 +819,6 @@ 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/receive/v2.rs b/payjoin/src/receive/v2.rs new file mode 100644 index 00000000..d1363e7a --- /dev/null +++ b/payjoin/src/receive/v2.rs @@ -0,0 +1,441 @@ +use std::collections::HashMap; + +use bitcoin::psbt::Psbt; +use bitcoin::{base64, Amount, FeeRate, OutPoint, Script, TxOut}; + +use super::{Error, InternalRequestError, RequestError, SelectionError}; +use crate::psbt::PsbtExt; +use crate::receive::optional_parameters::Params; + +/// Represents data that needs to be transmitted to the payjoin relay. +/// +/// You need to send this request over HTTP(S) to the relay. +#[non_exhaustive] +#[derive(Debug)] +pub struct Request { + /// URL to send the request to. + /// + /// This is full URL with scheme etc - you can pass it right to `reqwest` or a similar library. + pub url: url::Url, + + /// Bytes to be sent to the receiver. + pub body: Vec, +} + +#[derive(Debug)] +pub struct V2Context { + relay_url: url::Url, + ohttp_config: Vec, + ohttp_proxy: url::Url, + s: bitcoin::secp256k1::KeyPair, + e: Option, +} + +#[derive(Debug)] +pub struct Enroller { + relay_url: url::Url, + ohttp_config: Vec, + ohttp_proxy: url::Url, + s: bitcoin::secp256k1::KeyPair, +} + +#[cfg(feature = "v2")] +impl Enroller { + pub fn from_relay_config( + relay_url: &str, + ohttp_config_base64: &str, + ohttp_proxy_url: &str, + ) -> Self { + let ohttp_config = base64::decode_config(ohttp_config_base64, base64::URL_SAFE).unwrap(); + let ohttp_proxy = url::Url::parse(ohttp_proxy_url).unwrap(); + let relay_url = url::Url::parse(relay_url).unwrap(); + let secp = bitcoin::secp256k1::Secp256k1::new(); + let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng); + Enroller { + ohttp_config, + ohttp_proxy, + relay_url, + s: bitcoin::secp256k1::KeyPair::from_secret_key(&secp, &sk), + } + } + + pub fn subdirectory(&self) -> String { + let pubkey = &self.s.public_key().serialize(); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + base64::encode_config(pubkey, b64_config) + } + + pub fn payjoin_subdir(&self) -> String { format!("{}/{}", self.subdirectory(), "payjoin") } + + pub fn extract_req(&mut self) -> Result<(Request, ohttp::ClientResponse), crate::v2::Error> { + let url = self.ohttp_proxy.clone(); + let (body, ctx) = crate::v2::ohttp_encapsulate( + &self.ohttp_config, + "POST", + self.relay_url.as_str(), + Some(self.subdirectory().as_bytes()), + )?; + let req = Request { url, body }; + Ok((req, ctx)) + } + + pub fn process_res( + self, + mut res: impl std::io::Read, + ctx: ohttp::ClientResponse, + ) -> Result { + // TODO decapsulate enroll response, for now it does no auth or nothing + let mut buf = Vec::new(); + let _ = res.read_to_end(&mut buf); + let _success = crate::v2::ohttp_decapsulate(ctx, &buf).map_err(Error::V2)?; + + let ctx = Enrolled { + relay_url: self.relay_url, + ohttp_config: self.ohttp_config, + ohttp_proxy: self.ohttp_proxy, + s: self.s, + }; + Ok(ctx) + } +} + +fn subdirectory(pubkey: &bitcoin::secp256k1::PublicKey) -> String { + let pubkey = pubkey.serialize(); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + base64::encode_config(pubkey, b64_config) +} + +#[derive(Debug)] +pub struct Enrolled { + relay_url: url::Url, + ohttp_config: Vec, + ohttp_proxy: url::Url, + s: bitcoin::secp256k1::KeyPair, +} + +impl Enrolled { + pub fn extract_req(&self) -> Result<(Request, ohttp::ClientResponse), Error> { + let (body, ohttp_ctx) = self.fallback_req_body()?; + let url = self.ohttp_proxy.clone(); + let req = Request { url, body }; + Ok((req, ohttp_ctx)) + } + + /// The response can either be an UncheckedProposal or an ACCEPTED message + /// indicating no UncheckedProposal is available yet. + pub fn process_res( + &self, + mut body: impl std::io::Read, + context: ohttp::ClientResponse, + ) -> Result, Error> { + let mut buf = Vec::new(); + let _ = body.read_to_end(&mut buf); + log::trace!("decapsulating relay response"); + let response = crate::v2::ohttp_decapsulate(context, &buf)?; + if response.is_empty() { + log::debug!("response is empty"); + return Ok(None); + } + // parse v1 or v2 proposal + match String::from_utf8(response.clone()) { + Ok(proposal) => { + let context = V2Context { + relay_url: self.relay_url.clone(), + ohttp_config: self.ohttp_config.clone(), + ohttp_proxy: self.ohttp_proxy.clone(), + s: self.s, + e: None, + }; + log::debug!("Received proposal: {}", proposal); + Ok(Some(UncheckedProposal::from_v2_payload(proposal.into_bytes(), context)?)) + } + Err(_) => { + let (proposal, e) = crate::v2::decrypt_message_a(&response, self.s.secret_key())?; + log::debug!("Some e: {}", e); + let context = V2Context { + relay_url: self.relay_url.clone(), + ohttp_config: self.ohttp_config.clone(), + ohttp_proxy: self.ohttp_proxy.clone(), + s: self.s, + e: Some(e), + }; + let proposal = UncheckedProposal::from_v2_payload(proposal, context)?; + + Ok(Some(proposal)) + } + } + } + + fn fallback_req_body(&self) -> Result<(Vec, ohttp::ClientResponse), crate::v2::Error> { + let fallback_target = format!("{}{}", &self.relay_url, self.fallback_target()); + log::trace!("Fallback request target: {}", fallback_target.as_str()); + crate::v2::ohttp_encapsulate(&self.ohttp_config, "GET", &self.fallback_target(), None) + } + + pub fn fallback_target(&self) -> String { + let pubkey = &self.s.public_key().serialize(); + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let pubkey_base64 = base64::encode_config(pubkey, b64_config); + format!("{}{}", &self.relay_url, pubkey_base64) + } +} + +/// The sender's original PSBT and optional parameters +/// +/// This type is used to proces the request. It is returned by +/// [`UncheckedProposal::from_request()`](super::::UncheckedProposal::from_request()). +/// +/// If you are implementing an interactive payment processor, you should get extract the original +/// transaction with extract_tx_to_schedule_broadcast() and schedule, followed by checking +/// that the transaction can be broadcast with check_can_broadcast. Otherwise it is safe to +/// call assume_interactive_receive to proceed with validation. +pub struct UncheckedProposal { + inner: super::UncheckedProposal, + context: V2Context, +} + +impl UncheckedProposal { + fn from_v2_payload(body: Vec, context: V2Context) -> Result { + let buf_as_string = String::from_utf8(body).map_err(InternalRequestError::Utf8)?; + log::debug!("{}", &buf_as_string); + let (base64, padded_query) = buf_as_string.split_once('\n').unwrap_or_default(); + let query = padded_query.trim_matches('\0'); + log::trace!("Received query: {}, base64: {}", query, base64); // my guess is no \n so default is wrong + 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 inner = super::UncheckedProposal { psbt, params }; + Ok(Self { inner, context }) + } + + /// The Sender's Original PSBT + pub fn extract_tx_to_schedule_broadcast(&self) -> bitcoin::Transaction { + self.inner.extract_tx_to_schedule_broadcast() + } + + /// Call after checking that the Original PSBT can be broadcast. + /// + /// Receiver MUST check that the Original PSBT from the sender + /// can be broadcast, i.e. `testmempoolaccept` bitcoind rpc returns { "allowed": true,.. } + /// for `extract_tx_to_sheculed_broadcast()` before calling this method. + /// + /// Do this check if you generate bitcoin uri to receive Payjoin on sender request without manual human approval, like a payment processor. + /// Such so called "non-interactive" receivers are otherwise vulnerable to probing attacks. + /// If a sender can make requests at will, they can learn which bitcoin the receiver owns at no cost. + /// Broadcasting the Original PSBT after some time in the failure case makes incurs sender cost and prevents probing. + /// + /// Call this after checking downstream. + pub fn check_can_broadcast( + self, + can_broadcast: impl Fn(&bitcoin::Transaction) -> Result, + ) -> Result { + let inner = self.inner.check_can_broadcast(can_broadcast)?; + Ok(MaybeInputsOwned { inner, context: self.context }) + } + + /// Call this method if the only way to initiate a Payjoin with this receiver + /// requires manual intervention, as in most consumer wallets. + /// + /// 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 { + let inner = self.inner.assume_interactive_receiver(); + MaybeInputsOwned { inner, context: self.context } + } +} + +/// Typestate to validate that the Original PSBT has no receiver-owned inputs. +/// +/// Call [`check_no_receiver_owned_inputs()`](struct.UncheckedProposal.html#method.check_no_receiver_owned_inputs) to proceed. +pub struct MaybeInputsOwned { + inner: super::MaybeInputsOwned, + context: V2Context, +} + +impl MaybeInputsOwned { + /// Check that the Original PSBT has no receiver-owned inputs. + /// Return original-psbt-rejected error or otherwise refuse to sign undesirable inputs. + /// + /// An attacker could try to spend receiver's own inputs. This check prevents that. + pub fn check_inputs_not_owned( + self, + is_owned: impl Fn(&Script) -> Result, + ) -> Result { + let inner = self.inner.check_inputs_not_owned(is_owned)?; + Ok(MaybeMixedInputScripts { inner, context: self.context }) + } +} + +/// Typestate to validate that the Original PSBT has no mixed input types. +/// +/// Call [`check_no_mixed_input_types`](struct.UncheckedProposal.html#method.check_no_mixed_input_scripts) to proceed. +pub struct MaybeMixedInputScripts { + inner: super::MaybeMixedInputScripts, + context: V2Context, +} + +impl MaybeMixedInputScripts { + /// Verify the original transaction did not have mixed input types + /// Call this after checking downstream. + /// + /// Note: mixed spends do not necessarily indicate distinct wallet fingerprints. + /// This check is intended to prevent some types of wallet fingerprinting. + pub fn check_no_mixed_input_scripts(self) -> Result { + let inner = self.inner.check_no_mixed_input_scripts()?; + Ok(MaybeInputsSeen { inner, context: self.context }) + } +} + +/// Typestate to validate that the Original PSBT has no inputs that have been seen before. +/// +/// Call [`check_no_inputs_seen`](struct.MaybeInputsSeen.html#method.check_no_inputs_seen_before) to proceed. +pub struct MaybeInputsSeen { + inner: super::MaybeInputsSeen, + context: V2Context, +} + +impl MaybeInputsSeen { + /// Make sure that the original transaction inputs have never been seen before. + /// This prevents probing attacks. This prevents reentrant Payjoin, where a sender + /// proposes a Payjoin PSBT as a new Original PSBT for a new Payjoin. + pub fn check_no_inputs_seen_before( + self, + is_known: impl Fn(&OutPoint) -> Result, + ) -> Result { + let inner = self.inner.check_no_inputs_seen_before(is_known)?; + Ok(OutputsUnknown { inner, context: self.context }) + } +} + +/// The receiver has not yet identified which outputs belong to the receiver. +/// +/// Only accept PSBTs that send us money. +/// Identify those outputs with `identify_receiver_outputs()` to proceed +pub struct OutputsUnknown { + inner: super::OutputsUnknown, + context: V2Context, +} + +impl OutputsUnknown { + /// Find which outputs belong to the receiver + pub fn identify_receiver_outputs( + self, + is_receiver_output: impl Fn(&Script) -> Result, + ) -> Result { + let inner = self.inner.identify_receiver_outputs(is_receiver_output)?; + Ok(ProvisionalProposal { inner, context: self.context }) + } +} + +/// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +#[derive(Debug)] +pub struct ProvisionalProposal { + pub inner: super::ProvisionalProposal, + context: V2Context, +} + +impl ProvisionalProposal { + /// Select receiver input such that the payjoin avoids surveillance. + /// Return the input chosen that has been applied to the Proposal. + /// + /// Proper coin selection allows payjoin to resemble ordinary transactions. + /// To ensure the resemblence, a number of heuristics must be avoided. + /// + /// UIH "Unecessary input heuristic" is one class of them to avoid. We define + /// UIH1 and UIH2 according to the BlockSci practice + /// BlockSci UIH1 and UIH2: + // if min(out) < min(in) then UIH1 else UIH2 + // https://eprint.iacr.org/2022/589.pdf + pub fn try_preserving_privacy( + &self, + candidate_inputs: HashMap, + ) -> Result { + self.inner.try_preserving_privacy(candidate_inputs) + } + + pub fn contribute_witness_input(&mut self, txo: TxOut, outpoint: OutPoint) { + self.inner.contribute_witness_input(txo, outpoint) + } + + pub fn contribute_non_witness_input(&mut self, tx: bitcoin::Transaction, outpoint: OutPoint) { + self.inner.contribute_non_witness_input(tx, outpoint) + } + + /// Just replace an output address with + pub fn substitute_output_address(&mut self, substitute_address: bitcoin::Address) { + self.inner.substitute_output_address(substitute_address) + } + + pub fn finalize_proposal( + self, + wallet_process_psbt: impl Fn(&Psbt) -> Result, + min_feerate_sat_per_vb: Option, + ) -> Result { + let inner = self.inner.finalize_proposal(wallet_process_psbt, min_feerate_sat_per_vb)?; + Ok(PayjoinProposal { inner, context: self.context }) + } +} + +/// A mutable checked proposal that the receiver may contribute inputs to to make a payjoin. +pub struct PayjoinProposal { + inner: super::PayjoinProposal, + context: V2Context, +} + +impl PayjoinProposal { + pub fn utxos_to_be_locked(&self) -> impl '_ + Iterator { + self.inner.utxos_to_be_locked() + } + + pub fn is_output_substitution_disabled(&self) -> bool { + self.inner.is_output_substitution_disabled() + } + + pub fn owned_vouts(&self) -> &Vec { self.inner.owned_vouts() } + + pub fn psbt(&self) -> &Psbt { self.inner.psbt() } + + pub fn extract_v1_req(&self) -> String { base64::encode(self.inner.payjoin_psbt.serialize()) } + + #[cfg(feature = "v2")] + pub fn extract_v2_req(&self) -> Result<(Request, ohttp::ClientResponse), Error> { + 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) + } + None => Ok(self.extract_v1_req().as_bytes().to_vec()), + }?; + let post_payjoin_target = format!( + "{}{}/payjoin", + self.context.relay_url.as_str(), + subdirectory(&self.context.s.public_key()) + ); + log::debug!("Payjoin post target: {}", post_payjoin_target.as_str()); + let (body, ctx) = crate::v2::ohttp_encapsulate( + &self.context.ohttp_config, + "POST", + &post_payjoin_target, + Some(&body), + )?; + let url = self.context.ohttp_proxy.clone(); + let req = Request { url, body }; + Ok((req, ctx)) + } + + #[cfg(feature = "v2")] + pub fn deserialize_res( + &self, + res: Vec, + ohttp_context: ohttp::ClientResponse, + ) -> Result, Error> { + // TODO return error code + // display success or failure + let res = crate::v2::ohttp_decapsulate(ohttp_context, &res)?; + Ok(res) + } +} diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 058dca83..6e70a822 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -196,7 +196,7 @@ impl fmt::Display for CreateRequestError { AmbiguousChangeOutput => write!(f, "can not determine which output is change because there's more than two outputs"), ChangeIndexOutOfBounds => write!(f, "fee output index is points out of bounds"), ChangeIndexPointsAtPayee => write!(f, "fee output index is points at output belonging to the payee"), - Url(e) => write!(f, "cannot parse endpoint url: {:#?}", e), + Url(e) => write!(f, "cannot parse url: {:#?}", e), 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), diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 49aa3f95..735f4c02 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -211,7 +211,7 @@ impl<'a> RequestBuilder<'a> { pub fn build_recommended( self, min_fee_rate: FeeRate, - ) -> Result<(Request, Context), CreateRequestError> { + ) -> Result, CreateRequestError> { // TODO support optional batched payout scripts. This would require a change to // build() which now checks for a single payee. let mut payout_scripts = std::iter::once(self.uri.address.script_pubkey()); @@ -283,7 +283,7 @@ impl<'a> RequestBuilder<'a> { change_index: Option, min_fee_rate: FeeRate, clamp_fee_contribution: bool, - ) -> Result<(Request, Context), CreateRequestError> { + ) -> Result, CreateRequestError> { self.fee_contribution = Some((max_fee_contribution, change_index)); self.clamp_fee_contribution = clamp_fee_contribution; self.min_fee_rate = min_fee_rate; @@ -294,7 +294,7 @@ impl<'a> RequestBuilder<'a> { /// /// While it's generally better to offer some contribution some users may wish not to. /// This function disables contribution. - pub fn build_non_incentivizing(mut self) -> Result<(Request, Context), CreateRequestError> { + pub fn build_non_incentivizing(mut self) -> Result, CreateRequestError> { // since this is a builder, these should already be cleared // but we'll reset them to be sure self.fee_contribution = None; @@ -303,7 +303,7 @@ impl<'a> RequestBuilder<'a> { self.build() } - fn build(self) -> Result<(Request, Context), CreateRequestError> { + fn build(self) -> Result, CreateRequestError> { let mut psbt = self.psbt.validate().map_err(InternalCreateRequestError::InconsistentOriginalPsbt)?; psbt.validate_input_utxos(true) @@ -327,66 +327,109 @@ impl<'a> RequestBuilder<'a> { let txout = zeroth_input.previous_txout().expect("We already checked this above"); let input_type = InputType::from_spent_input(txout, zeroth_input.psbtin).unwrap(); - #[cfg(not(feature = "v2"))] - let (req, ctx) = { - let url = serialize_url( - self.uri.extras._endpoint.into(), - disable_output_substitution, - fee_contribution, - self.min_fee_rate, - ) - .map_err(InternalCreateRequestError::Url)?; - let body = psbt.to_string().as_bytes().to_vec(); - ( - 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, - }, - ) - }; + Ok(RequestContext { + psbt, + uri: self.uri, + disable_output_substitution, + fee_contribution, + payee, + input_type, + sequence, + min_fee_rate: self.min_fee_rate, + }) + } +} + +pub struct RequestContext<'a> { + psbt: Psbt, + uri: PjUri<'a>, + disable_output_substitution: bool, + fee_contribution: Option<(bitcoin::Amount, usize)>, + min_fee_rate: FeeRate, + input_type: InputType, + sequence: Sequence, + payee: ScriptBuf, +} - #[cfg(feature = "v2")] - 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, - disable_output_substitution, - fee_contribution, - self.min_fee_rate, - )?; - 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, +impl<'a> RequestContext<'a> { + /// Extract serialized V1 Request and Context froma Payjoin Proposal + pub fn extract_v1(self) -> Result<(Request, ContextV1), CreateRequestError> { + let url = serialize_url( + self.uri.extras._endpoint.into(), + self.disable_output_substitution, + self.fee_contribution, + self.min_fee_rate, + ) + .map_err(InternalCreateRequestError::Url)?; + let body = self.psbt.to_string().as_bytes().to_vec(); + Ok(( + Request { url, body }, + ContextV1 { + original_psbt: self.psbt, + disable_output_substitution: self.disable_output_substitution, + fee_contribution: self.fee_contribution, + payee: self.payee, + input_type: self.input_type, + sequence: self.sequence, + min_fee_rate: self.min_fee_rate, + }, + )) + } + + /// Extract serialized Request and Context from a Payjoin Proposal. + /// + /// In order to support polling, this may need to be called many times to be encrypted with + /// new unique nonces to make independent OHTTP requests. + /// + /// The `ohttp_proxy` merely passes the encrypted payload to the ohttp gateway of the receiver + #[cfg(feature = "v2")] + pub fn extract_v2( + &self, + ohttp_proxy_url: &str, + ) -> Result<(Request, ContextV2), CreateRequestError> { + 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.clone(); + let body = serialize_v2_body( + &self.psbt, + self.disable_output_substitution, + self.fee_contribution, + self.min_fee_rate, + )?; + let (body, e) = + crate::v2::encrypt_message_a(body, rs).map_err(InternalCreateRequestError::V2)?; + let (body, ohttp_res) = crate::v2::ohttp_encapsulate( + &self.uri.extras.ohttp_config.as_ref().unwrap().encode().unwrap(), + "POST", + url.as_str(), + Some(&body), + ) + .map_err(InternalCreateRequestError::V2)?; + log::debug!("ohttp_proxy_url: {:?}", ohttp_proxy_url); + let url = Url::parse(ohttp_proxy_url).map_err(InternalCreateRequestError::Url)?; + Ok(( + Request { url, body }, + // this method may be called more than once to re-construct the ohttp, therefore we must clone (or TODO memoize) + ContextV2 { + context_v1: ContextV1 { + original_psbt: self.psbt.clone(), + disable_output_substitution: self.disable_output_substitution, + fee_contribution: self.fee_contribution, + payee: self.payee.clone(), + input_type: self.input_type, + sequence: self.sequence, min_fee_rate: self.min_fee_rate, - v2_context: Some(e), }, - ) - }; - - Ok((req, ctx)) + e, + ohttp_res, + }, + )) } } @@ -414,7 +457,7 @@ pub struct Request { /// This type is used to process the response. Get it from [`RequestBuilder`](crate::send::RequestBuilder)'s build methods. /// Then you only need to call [`.process_response()`](crate::send::Context::process_response()) on it to continue BIP78 flow. #[derive(Debug)] -pub struct Context { +pub struct ContextV1 { original_psbt: Psbt, disable_output_substitution: bool, fee_contribution: Option<(bitcoin::Amount, usize)>, @@ -422,7 +465,13 @@ pub struct Context { input_type: InputType, sequence: Sequence, payee: ScriptBuf, - v2_context: Option, +} + +#[cfg(feature = "v2")] +pub struct ContextV2 { + context_v1: ContextV1, + e: bitcoin::secp256k1::SecretKey, + ohttp_res: ohttp::ClientResponse, } macro_rules! check_eq { @@ -443,27 +492,41 @@ macro_rules! ensure { }; } -impl Context { +#[cfg(feature = "v2")] +impl ContextV2 { /// Decodes and validates the response. /// - /// Call this method with response from receiver to continue BIP78 flow. If the response is - /// valid you will get appropriate PSBT that you should sign and broadcast. + /// Call this method with response from receiver to continue BIP-??? flow. + /// A successful response can either be None if the relay has not response yet or Some(Psbt). + /// + /// If the response is some valid PSBT you should sign and broadcast. #[inline] 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), + ) -> Result, ValidationError> { + let mut res_buf = Vec::new(); + response.read_to_end(&mut res_buf).map_err(InternalValidationError::Io)?; + let mut res_buf = crate::v2::ohttp_decapsulate(self.ohttp_res, &res_buf) + .map_err(InternalValidationError::V2)?; + let psbt = crate::v2::decrypt_message_b(&mut res_buf, self.e) + .map_err(InternalValidationError::V2)?; + if psbt.is_empty() { + return Ok(None); } - - #[cfg(not(feature = "v2"))] - self.process_response_v1(response) + let proposal = Psbt::deserialize(&psbt).map_err(InternalValidationError::Psbt)?; + let processed_proposal = self.context_v1.process_proposal(proposal)?; + Ok(Some(processed_proposal)) } +} - pub fn process_response_v1( +impl ContextV1 { + /// Decodes and validates the response. + /// + /// Call this method with response from receiver to continue BIP78 flow. If the response is + /// valid you will get appropriate PSBT that you should sign and broadcast. + #[inline] + pub fn process_response( self, response: &mut impl std::io::Read, ) -> Result { @@ -475,21 +538,6 @@ impl Context { 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) - } - fn process_proposal(self, proposal: Psbt) -> InternalResult { self.basic_checks(&proposal)?; let in_stats = self.check_inputs(&proposal)?; @@ -888,7 +936,6 @@ fn serialize_url( #[cfg(test)] mod tests { #[test] - #[cfg(not(feature = "v2"))] fn official_vectors() { use std::str::FromStr; @@ -906,7 +953,7 @@ mod tests { eprintln!("original: {:#?}", original_psbt); let payee = original_psbt.unsigned_tx.output[1].script_pubkey.clone(); let sequence = original_psbt.unsigned_tx.input[0].sequence; - let ctx = super::Context { + let ctx = super::ContextV1 { original_psbt, disable_output_substitution: false, fee_contribution: None, diff --git a/payjoin/src/uri.rs b/payjoin/src/uri.rs index eb3152e7..ddaa8173 100644 --- a/payjoin/src/uri.rs +++ b/payjoin/src/uri.rs @@ -5,7 +5,6 @@ use bitcoin::address::{Error, NetworkChecked, NetworkUnchecked}; use bitcoin::Network; use url::Url; -#[derive(Debug, Clone)] pub enum Payjoin { Supported(PayjoinParams), V2Only(PayjoinParams), @@ -22,10 +21,11 @@ impl Payjoin { } } -#[derive(Debug, Clone)] pub struct PayjoinParams { pub(crate) _endpoint: Url, pub(crate) disable_output_substitution: bool, + #[cfg(feature = "v2")] + pub(crate) ohttp_config: Option, } pub type Uri<'a, NetworkValidation> = bip21::Uri<'a, NetworkValidation, Payjoin>; @@ -112,6 +112,8 @@ impl<'a> bip21::de::DeserializeParams<'a> for Payjoin { pub struct DeserializationState { pj: Option, pjos: Option, + #[cfg(feature = "v2")] + ohttp: Option, } #[derive(Debug)] @@ -135,6 +137,19 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { ::Error, > { match key { + #[cfg(feature = "v2")] + "ohttp" if self.ohttp.is_none() => { + let base64_config = Cow::try_from(value).map_err(InternalPjParseError::NotUtf8)?; + let config_bytes = + bitcoin::base64::decode_config(&*base64_config, bitcoin::base64::URL_SAFE) + .map_err(InternalPjParseError::NotBase64)?; + let config = ohttp::KeyConfig::decode(&config_bytes) + .map_err(InternalPjParseError::BadOhttp)?; + self.ohttp = Some(config); + Ok(bip21::de::ParamKind::Known) + } + #[cfg(feature = "v2")] + "ohttp" => Err(PjParseError(InternalPjParseError::MultipleParams("ohttp"))), "pj" if self.pj.is_none() => { let endpoint = Cow::try_from(value).map_err(InternalPjParseError::NotUtf8)?; let url = Url::parse(&endpoint).map_err(InternalPjParseError::BadEndpoint)?; @@ -156,13 +171,28 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { } } + #[cfg(feature = "v2")] fn finalize( self, ) -> std::result::Result::Error> { - match (self.pj, self.pjos) { - (None, None) => Ok(Payjoin::Unsupported), - (None, Some(_)) => Err(PjParseError(InternalPjParseError::MissingEndpoint)), - (Some(endpoint), pjos) => { + match (self.pj, self.pjos, self.ohttp) { + (None, None, _) => Ok(Payjoin::Unsupported), + (None, Some(_), _) => Err(PjParseError(InternalPjParseError::MissingEndpoint)), + (Some(endpoint), pjos, None) => { + if endpoint.scheme() == "https" + || endpoint.scheme() == "http" + && endpoint.domain().unwrap_or_default().ends_with(".onion") + { + Ok(Payjoin::Supported(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: None, + })) + } else { + Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) + } + } + (Some(endpoint), pjos, Some(ohttp)) => { if endpoint.scheme() == "https" || endpoint.scheme() == "http" && endpoint.domain().unwrap_or_default().ends_with(".onion") @@ -170,11 +200,36 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { Ok(Payjoin::Supported(PayjoinParams { _endpoint: endpoint, disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: Some(ohttp), })) } else if endpoint.scheme() == "http" { Ok(Payjoin::V2Only(PayjoinParams { _endpoint: endpoint, disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: Some(ohttp), + })) + } else { + Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) + } + } + } + } + + #[cfg(not(feature = "v2"))] + fn finalize( + self, + ) -> std::result::Result::Error> { + match (self.pj, self.pjos) { + (None, None) => Ok(Payjoin::Unsupported), + (None, Some(_)) => Err(PjParseError(InternalPjParseError::MissingEndpoint)), + (Some(endpoint), pjos) => { + if endpoint.scheme() == "https" + || endpoint.scheme() == "http" + && endpoint.domain().unwrap_or_default().ends_with(".onion") + { + Ok(Payjoin::Supported(PayjoinParams { + _endpoint: endpoint, + disable_output_substitution: pjos.unwrap_or(false), })) } else { Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) @@ -193,7 +248,11 @@ impl std::fmt::Display for PjParseError { } InternalPjParseError::MissingEndpoint => write!(f, "Missing payjoin endpoint"), InternalPjParseError::NotUtf8(_) => write!(f, "Endpoint is not valid UTF-8"), + #[cfg(feature = "v2")] + InternalPjParseError::NotBase64(_) => write!(f, "ohttp config is not valid base64"), InternalPjParseError::BadEndpoint(_) => write!(f, "Endpoint is not valid"), + #[cfg(feature = "v2")] + InternalPjParseError::BadOhttp(_) => write!(f, "ohttp config is not valid"), InternalPjParseError::UnsecureEndpoint => { write!(f, "Endpoint scheme is not secure (https or onion)") } @@ -207,7 +266,11 @@ enum InternalPjParseError { MultipleParams(&'static str), MissingEndpoint, NotUtf8(core::str::Utf8Error), + #[cfg(feature = "v2")] + NotBase64(bitcoin::base64::DecodeError), BadEndpoint(url::ParseError), + #[cfg(feature = "v2")] + BadOhttp(ohttp::Error), UnsecureEndpoint, } diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index 0b12eab0..5f4fe54e 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -1,7 +1,6 @@ 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 { @@ -53,10 +52,7 @@ pub fn encrypt_message_a( Ok((message_a, e_sec)) } -pub fn decrypt_message_a( - message_a: &mut [u8], - s: SecretKey, -) -> Result<(Vec, PublicKey), Error> { +pub fn decrypt_message_a(message_a: &[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]); @@ -119,6 +115,8 @@ pub struct Error(InternalError); #[derive(Debug)] pub(crate) enum InternalError { + Ohttp(ohttp::Error), + Bhttp(bhttp::Error), ParseUrl(url::ParseError), Secp256k1(bitcoin::secp256k1::Error), ChaCha20Poly1305(chacha20poly1305::aead::Error), @@ -126,6 +124,14 @@ pub(crate) enum InternalError { PayloadTooLarge, } +impl From for Error { + fn from(value: ohttp::Error) -> Self { Self(InternalError::Ohttp(value)) } +} + +impl From for Error { + fn from(value: bhttp::Error) -> Self { Self(InternalError::Bhttp(value)) } +} + impl From for Error { fn from(value: url::ParseError) -> Self { Self(InternalError::ParseUrl(value)) } } @@ -145,6 +151,8 @@ impl fmt::Display for Error { use InternalError::*; match &self.0 { + Ohttp(e) => e.fmt(f), + Bhttp(e) => e.fmt(f), ParseUrl(e) => e.fmt(f), Secp256k1(e) => e.fmt(f), ChaCha20Poly1305(e) => e.fmt(f), @@ -160,6 +168,8 @@ impl error::Error for Error { use InternalError::*; match &self.0 { + Ohttp(e) => Some(e), + Bhttp(e) => Some(e), ParseUrl(e) => Some(e), Secp256k1(e) => Some(e), ChaCha20Poly1305(_) | InvalidKeyLength | PayloadTooLarge => None, @@ -170,3 +180,37 @@ impl error::Error for Error { impl From for Error { fn from(value: InternalError) -> Self { Self(value) } } + +pub fn ohttp_encapsulate( + ohttp_config: &[u8], + method: &str, + target_resource: &str, + body: Option<&[u8]>, +) -> Result<(Vec, ohttp::ClientResponse), Error> { + let ctx = ohttp::ClientRequest::from_encoded_config(ohttp_config)?; + let url = url::Url::parse(target_resource)?; + let mut bhttp_message = bhttp::Message::request( + method.as_bytes().to_vec(), + url.scheme().as_bytes().to_vec(), + url.authority().as_bytes().to_vec(), + url.path().as_bytes().to_vec(), + ); + if let Some(body) = body { + bhttp_message.write_content(body); + } + let mut bhttp_req = Vec::new(); + let _ = bhttp_message.write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_req); + let encapsulated = ctx.encapsulate(&bhttp_req)?; + Ok(encapsulated) +} + +/// decapsulate ohttp, bhttp response and return http response body and status code +pub fn ohttp_decapsulate( + res_ctx: ohttp::ClientResponse, + ohttp_body: &[u8], +) -> Result, Error> { + let bhttp_body = res_ctx.decapsulate(ohttp_body)?; + let mut r = std::io::Cursor::new(bhttp_body); + let response = bhttp::Message::read_bhttp(&mut r)?; + Ok(response.content().to_vec()) +} diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index bd94f4a2..e41b67cf 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -2,6 +2,7 @@ mod integration { use std::collections::HashMap; use std::env; + use std::fmt::Write; use std::str::FromStr; use bitcoin::address::NetworkChecked; @@ -12,16 +13,14 @@ mod integration { use bitcoind::bitcoincore_rpc::RpcApi; use log::{debug, log_enabled, Level}; use payjoin::bitcoin::base64; - use payjoin::receive::{PayjoinProposal, UncheckedProposal}; - use payjoin::send::RequestBuilder; + use payjoin::send::{Request, RequestBuilder}; use payjoin::Uri; type BoxError = Box; #[cfg(not(feature = "v2"))] mod v1 { - use payjoin::receive::Headers; - use payjoin::send::RequestBuilder; + use payjoin::receive::{Headers, PayjoinProposal, UncheckedProposal}; use super::*; @@ -34,12 +33,13 @@ mod integration { // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL); + let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, EXAMPLE_URL, None); // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri let psbt = build_original_psbt(&sender, &pj_uri)?; debug!("Original psbt: {:#?}", psbt); let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? - .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v1()?; let headers = HeaderMock::from_vec(&req.body); // ********************** @@ -86,10 +86,101 @@ mod integration { headers, ) .unwrap(); - let psbt = handle_proposal(proposal, receiver); + let proposal = handle_proposal(proposal, receiver); + let psbt = proposal.psbt(); debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); base64::encode(&psbt.serialize()) } + + 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(); + + // Receive Check 1: Can Broadcast + let proposal = proposal + .check_can_broadcast(|tx| { + Ok(receiver + .test_mempool_accept(&[bitcoin::consensus::encode::serialize_hex(&tx)]) + .unwrap() + .first() + .unwrap() + .allowed) + }) + .expect("Payjoin proposal should be broadcastable"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal + .check_inputs_not_owned(|input| { + let address = + bitcoin::Address::from_script(&input, bitcoin::Network::Regtest).unwrap(); + Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) + }) + .expect("Receiver should not own any of the inputs"); + + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts().unwrap(); + + // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let mut payjoin = proposal + .check_no_inputs_seen_before(|_| Ok(false)) + .unwrap() + .identify_receiver_outputs(|output_script| { + let address = + bitcoin::Address::from_script(&output_script, bitcoin::Network::Regtest) + .unwrap(); + Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) + }) + .expect("Receiver should have at least one output"); + + // Select receiver payjoin inputs. TODO Lock them. + let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .unwrap(); + + // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount.to_sat(), + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + let outpoint_to_contribute = + bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; + payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); + + let receiver_substitute_address = + receiver.get_new_address(None, None).unwrap().assume_checked(); + payjoin.substitute_output_address(receiver_substitute_address); + let payjoin_proposal = payjoin + .finalize_proposal( + |psbt: &Psbt| { + Ok(receiver + .wallet_process_psbt( + &bitcoin::base64::encode(psbt.serialize()), + None, + None, + Some(false), + ) + .map(|res: WalletProcessPsbtResult| { + let psbt = Psbt::from_str(&res.psbt).unwrap(); + return psbt; + }) + .unwrap()) + }, + Some(bitcoin::FeeRate::MIN), + ) + .unwrap(); + payjoin_proposal + } } #[cfg(feature = "v2")] @@ -97,8 +188,7 @@ mod integration { use std::process::Stdio; use std::sync::Arc; - use payjoin::receive::{PayjoinProposal, ProposalContext}; - use payjoin::send::Request; + use payjoin::receive::v2::{Enroller, PayjoinProposal, UncheckedProposal}; use testcontainers::Container; use testcontainers_modules::postgres::Postgres; use testcontainers_modules::testcontainers::clients::Cli; @@ -107,7 +197,8 @@ mod integration { use super::*; - const RELAY_URL: &str = "https://localhost:8088"; + const PJ_RELAY_URL: &str = "https://localhost:8088"; + const OH_RELAY_URL: &str = "https://localhost:8088"; const LOCAL_CERT_FILE: &str = "localhost.der"; #[tokio::test] @@ -118,33 +209,51 @@ mod integration { let (mut relay, _db) = init_relay(&docker).await; let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + // get ohttp-config at PJ_RELAY_URL in spawn_blocking + let ohttp_config = { + let response = spawn_blocking(move || { + http_agent().get(&format!("{}/ohttp-config", PJ_RELAY_URL)).call() + }) + .await??; + response.into_string()? + }; + debug!("GET'd ohttp-config: {}", ohttp_config); + // ********************** // Inside the Receiver: // Enroll with relay - 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); + let mut enroller = + Enroller::from_relay_config(&PJ_RELAY_URL, &ohttp_config, &OH_RELAY_URL); + let (req, ctx) = enroller.extract_req()?; + let res = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + assert!(is_success(res.status())); + let enrolled = enroller.process_res(res.into_reader(), ctx)?; + let fallback_target = enrolled.fallback_target(); // Receiver creates the payjoin URI let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); - let relay_endpoint = format!("{}/{}", RELAY_URL, &subdirectory); - let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + let pj_uri = build_pj_uri( + pj_receiver_address, + Amount::ONE_BTC, + &fallback_target, + Some(&ohttp_config), + ); // ********************** // Inside the Sender: // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri let psbt = build_original_psbt(&sender, &pj_uri)?; debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? - .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; + let (send_req, send_ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v2(OH_RELAY_URL)?; log::info!("send fallback v2"); - log::debug!("Request: {:#?}", &req.body); + log::debug!("Request: {:#?}", &send_req.body); let response = { - let Request { url, body, .. } = req.clone(); + let Request { url, body, .. } = send_req.clone(); spawn_blocking(move || { http_agent() .post(url.as_str()) @@ -155,37 +264,41 @@ mod integration { .await?? }; log::info!("Response: {:#?}", &response); - assert!(response.status() == 202); + assert!(is_success(response.status())); // no response body yet since we are async and pushed fallback_psbt to the buffer // ********************** // Inside the Receiver: - // this data would transit from one party to another over the network in production - let receive_endpoint = format!("{}/{}", RELAY_URL, &subdirectory.clone()); + + // GET fallback psbt + let (req, ctx) = enrolled.extract_req()?; let response = - spawn_blocking(move || http_agent().get(&receive_endpoint).call()).await??; - 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??; + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + + // POST payjoin + let proposal = enrolled.process_res(response.into_reader(), ctx)?.unwrap(); + let payjoin_proposal = handle_relay_proposal(receiver, proposal); + let (req, ctx) = payjoin_proposal.extract_v2_req()?; + let response = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + let mut res = Vec::new(); + response.into_reader().read_to_end(&mut res)?; + let _response = payjoin_proposal.deserialize_res(res, ctx)?; + // response should be 204 http // ********************** // Inside the Sender: // Sender checks, signs, finalizes, extracts, and broadcasts // 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 response = spawn_blocking(move || { + http_agent().post(send_req.url.as_str()).send_bytes(&send_req.body) + }) + .await??; let checked_payjoin_proposal_psbt = - ctx.process_response(&mut response.into_reader())?; + send_ctx.process_response(&mut response.into_reader())?.unwrap(); let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; sender.send_raw_transaction(&payjoin_tx)?; log::info!("sent"); @@ -204,34 +317,51 @@ mod integration { let (mut relay, _db) = init_relay(&docker).await; let (_bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + // get ohttp-config at PJ_RELAY_URL in spawn_blocking + let ohttp_config = { + let response = spawn_blocking(move || { + http_agent().get(&format!("{}/ohttp-config", PJ_RELAY_URL)).call() + }) + .await??; + response.into_string()? + }; + debug!("GET'd ohttp-config: {}", ohttp_config); + // ********************** // Inside the Receiver: // Enroll with relay - 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); + let mut enroller = + Enroller::from_relay_config(&PJ_RELAY_URL, &ohttp_config, &OH_RELAY_URL); + let (req, ctx) = enroller.extract_req()?; + let res = + spawn_blocking(move || http_agent().post(req.url.as_str()).send_bytes(&req.body)) + .await??; + assert!(is_success(res.status())); + let enrolled = enroller.process_res(res.into_reader(), ctx)?; // Receiver creates the payjoin URI - let pj_receiver_address = - receiver.get_new_address(None, None).unwrap().assume_checked(); - let relay_endpoint = format!("{}/{}", RELAY_URL, &subdirectory); - let pj_uri = build_pj_uri(pj_receiver_address, Amount::ONE_BTC, &relay_endpoint); + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let fallback_target = enrolled.fallback_target(); + let pj_uri = build_pj_uri( + pj_receiver_address, + Amount::ONE_BTC, + &fallback_target, + Some(&ohttp_config), + ); // ********************** // Inside the V1 Sender: // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri let psbt = build_original_psbt(&sender, &pj_uri)?; debug!("Original psbt: {:#?}", psbt); - let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? - .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)?; + let (send_req, _) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v1()?; log::info!("send fallback v1 to offline receiver fail"); let res = { - let Request { url, body, .. } = req.clone(); + let Request { url, body, .. } = send_req.clone(); spawn_blocking(move || { http_agent() .post(url.as_str()) @@ -247,19 +377,17 @@ mod integration { // ********************** // Inside the Receiver: - let receiver_loop = tokio::task::spawn(async move { - let subdirectory = receive_context.subdirectory(); - let fallback_psbt_body = loop { - let subdir_clone = subdirectory.clone(); + let _receiver_loop = tokio::task::spawn(async move { + let (response, ctx) = loop { + let (req, ctx) = enrolled.extract_req().unwrap(); let response = spawn_blocking(move || { - let receive_endpoint = format!("{}/{}", RELAY_URL, &subdir_clone); - http_agent().get(&receive_endpoint).call() + http_agent().post(req.url.as_str()).send_bytes(&req.body) }) .await??; if response.status() == 200 { debug!("GET'd fallback_psbt"); - break response.into_reader(); + break (response.into_reader(), ctx); } else if response.status() == 202 { log::info!( "No response yet for POST payjoin request, retrying some seconds" @@ -271,27 +399,20 @@ mod integration { } }; debug!("handle relay response"); - 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"); + let proposal = enrolled.process_res(response, ctx).unwrap().unwrap(); + let payjoin_proposal = handle_relay_proposal(receiver, proposal); // Respond with payjoin psbt within the time window the sender is willing to wait - let payjoin_endpoint = format!("{}/{}/payjoin", RELAY_URL, &subdirectory); + // this response would be returned as http response to the sender + let (req, ctx) = payjoin_proposal.extract_v2_req().unwrap(); let response = spawn_blocking(move || { - http_agent() - .post(&payjoin_endpoint) - .send_bytes(&payjoin_proposal.serialize_body().unwrap()) + http_agent().post(req.url.as_str()).send_bytes(&req.body) }) .await??; - debug!("POSTed with payjoin_psbt response status {}", response.status()); - assert!(response.status() == 204); + let mut res = Vec::new(); + response.into_reader().read_to_end(&mut res)?; + let _response = payjoin_proposal.deserialize_res(res, ctx).unwrap(); + debug!("Post payjoin_psbt to relay"); + // assert!(_response.status() == 204); Ok::<_, Box>(()) }); @@ -299,7 +420,7 @@ mod integration { // send fallback v1 to online receiver log::info!("send fallback v1 to online receiver should succeed"); let response = { - let Request { url, body, .. } = req.clone(); + let Request { url, body, .. } = send_req.clone(); spawn_blocking(move || { http_agent() .post(url.as_str()) @@ -307,17 +428,18 @@ mod integration { .send_bytes(&body) .expect("Failed to send request") }) - .await? + .await }; log::info!("Response: {:#?}", &response); - assert!(response.status() == 200); - - 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"); - assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); + assert!(response.is_err()); + + // TODO support v1 relay + // let checked_payjoin_proposal_psbt = + // send_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"); + // assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); relay.kill().await?; let output = &relay.wait_with_output().await?; log::info!("Status: {}", output.status); @@ -353,15 +475,95 @@ mod integration { command.spawn().unwrap() } - fn handle_relay_response( - res: impl std::io::Read, + fn handle_relay_proposal( receiver: bitcoincore_rpc::Client, - ctx: ProposalContext, + proposal: UncheckedProposal, ) -> PayjoinProposal { - let proposal = ctx.parse_relay_response(res).unwrap(); - let proposal = handle_proposal(proposal, receiver); - debug!("Receiver's Payjoin proposal PSBT: {:#?}", &proposal.psbt()); - proposal + // 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(); + + // Receive Check 1: Can Broadcast + let proposal = proposal + .check_can_broadcast(|tx| { + Ok(receiver + .test_mempool_accept(&[bitcoin::consensus::encode::serialize_hex(&tx)]) + .unwrap() + .first() + .unwrap() + .allowed) + }) + .expect("Payjoin proposal should be broadcastable"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal + .check_inputs_not_owned(|input| { + let address = + bitcoin::Address::from_script(&input, bitcoin::Network::Regtest).unwrap(); + Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) + }) + .expect("Receiver should not own any of the inputs"); + + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts().unwrap(); + + // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let mut payjoin = proposal + .check_no_inputs_seen_before(|_| Ok(false)) + .unwrap() + .identify_receiver_outputs(|output_script| { + let address = + bitcoin::Address::from_script(&output_script, bitcoin::Network::Regtest) + .unwrap(); + Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) + }) + .expect("Receiver should have at least one output"); + + // Select receiver payjoin inputs. TODO Lock them. + let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .unwrap(); + + // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount.to_sat(), + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + let outpoint_to_contribute = + bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; + payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); + + let receiver_substitute_address = + receiver.get_new_address(None, None).unwrap().assume_checked(); + payjoin.substitute_output_address(receiver_substitute_address); + let payjoin_proposal = payjoin + .finalize_proposal( + |psbt: &Psbt| { + Ok(receiver + .wallet_process_psbt( + &bitcoin::base64::encode(psbt.serialize()), + None, + None, + Some(false), + ) + .map(|res: WalletProcessPsbtResult| { + let psbt = Psbt::from_str(&res.psbt).unwrap(); + return psbt; + }) + .unwrap()) + }, + Some(bitcoin::FeeRate::MIN), + ) + .unwrap(); + debug!("Receiver's Payjoin proposal PSBT: {:#?}", &payjoin_proposal.psbt()); + payjoin_proposal } fn http_agent() -> ureq::Agent { @@ -415,13 +617,18 @@ mod integration { Ok((bitcoind, sender, receiver)) } - fn build_pj_uri( + fn build_pj_uri<'a>( address: bitcoin::Address, amount: Amount, - pj: &str, - ) -> Uri<'_, NetworkChecked> { - let pj_uri_string = + pj: &'a str, + ohttp: Option<&'a str>, + ) -> Uri<'a, NetworkChecked> { + let mut pj_uri_string = format!("{}?amount={}&pj={}", address.to_qr_uri(), amount.to_btc(), pj,); + if let Some(ohttp) = ohttp { + write!(pj_uri_string, "&ohttp={}", ohttp).unwrap(); + } + debug!("PJ URI: {}", &pj_uri_string); let pj_uri = Uri::from_str(&pj_uri_string).unwrap(); pj_uri.assume_checked() } @@ -451,96 +658,6 @@ mod integration { Ok(Psbt::from_str(&psbt)?) } - 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(); - - // Receive Check 1: Can Broadcast - let proposal = proposal - .check_can_broadcast(|tx| { - Ok(receiver - .test_mempool_accept(&[bitcoin::consensus::encode::serialize_hex(&tx)]) - .unwrap() - .first() - .unwrap() - .allowed) - }) - .expect("Payjoin proposal should be broadcastable"); - - // Receive Check 2: receiver can't sign for proposal inputs - let proposal = proposal - .check_inputs_not_owned(|input| { - let address = - bitcoin::Address::from_script(&input, bitcoin::Network::Regtest).unwrap(); - Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) - }) - .expect("Receiver should not own any of the inputs"); - - // Receive Check 3: receiver can't sign for proposal inputs - let proposal = proposal.check_no_mixed_input_scripts().unwrap(); - - // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. - let mut payjoin = proposal - .check_no_inputs_seen_before(|_| Ok(false)) - .unwrap() - .identify_receiver_outputs(|output_script| { - let address = - bitcoin::Address::from_script(&output_script, bitcoin::Network::Regtest) - .unwrap(); - Ok(receiver.get_address_info(&address).unwrap().is_mine.unwrap()) - }) - .expect("Receiver should have at least one output"); - - // Select receiver payjoin inputs. TODO Lock them. - let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); - let candidate_inputs: HashMap = available_inputs - .iter() - .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) - .collect(); - - let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); - let selected_utxo = available_inputs - .iter() - .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) - .unwrap(); - - // calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt, - let txo_to_contribute = bitcoin::TxOut { - value: selected_utxo.amount.to_sat(), - script_pubkey: selected_utxo.script_pub_key.clone(), - }; - let outpoint_to_contribute = - bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout }; - payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute); - - let receiver_substitute_address = - receiver.get_new_address(None, None).unwrap().assume_checked(); - payjoin.substitute_output_address(receiver_substitute_address); - let payjoin_proposal = payjoin - .finalize_proposal( - |psbt: &Psbt| { - Ok(receiver - .wallet_process_psbt( - &bitcoin::base64::encode(psbt.serialize()), - None, - None, - Some(false), - ) - .map(|res: WalletProcessPsbtResult| { - let psbt = Psbt::from_str(&res.psbt).unwrap(); - return psbt; - }) - .unwrap()) - }, - Some(bitcoin::FeeRate::MIN), - ) - .unwrap(); - payjoin_proposal - } - fn extract_pj_tx( sender: &bitcoincore_rpc::Client, psbt: Psbt, @@ -554,4 +671,6 @@ mod integration { Ok(payjoin_psbt.extract_tx()) } + + fn is_success(status: u16) -> bool { status >= 200 && status < 300 } } From b06552105198daed43b9cc2f115fb6184ac5b2ff Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 7 Nov 2023 14:58:20 -0500 Subject: [PATCH 5/6] Send and receive v2 via cli --- payjoin-cli/Cargo.toml | 1 + payjoin-cli/src/app.rs | 385 +++++++++++++++++++++++++++++------ payjoin-cli/src/main.rs | 6 + payjoin-relay/tests/e2e.rs | 192 +++++++++++++++++ payjoin/tests/integration.rs | 23 +-- 5 files changed, 533 insertions(+), 74 deletions(-) create mode 100644 payjoin-relay/tests/e2e.rs diff --git a/payjoin-cli/Cargo.toml b/payjoin-cli/Cargo.toml index a178312d..ab1699b6 100644 --- a/payjoin-cli/Cargo.toml +++ b/payjoin-cli/Cargo.toml @@ -42,3 +42,4 @@ url = "2.2.1" [dev-dependencies] bitcoind = { version = "0.31.1", features = ["0_21_2"] } +tokio = { version = "1.12.0", features = ["full"] } diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 99e70de8..768f5147 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::convert::TryFrom; +#[cfg(not(feature = "v2"))] use std::net::SocketAddr; use std::str::FromStr; use std::sync::{Arc, Mutex}; @@ -10,12 +11,20 @@ use bitcoincore_rpc::jsonrpc::serde_json; use bitcoincore_rpc::RpcApi; use clap::ArgMatches; use config::{Config, File, FileFormat}; +#[cfg(not(feature = "v2"))] use hyper::service::{make_service_fn, service_fn}; +#[cfg(not(feature = "v2"))] use hyper::{Body, Method, Request, Response, Server, StatusCode}; -use payjoin::bitcoin; use payjoin::bitcoin::psbt::Psbt; -use payjoin::receive::{Error, ProvisionalProposal}; +use payjoin::bitcoin::{self, base64}; +#[cfg(feature = "v2")] +use payjoin::receive::v2; +use payjoin::receive::Error; +#[cfg(not(feature = "v2"))] +use payjoin::receive::{PayjoinProposal, UncheckedProposal}; +use payjoin::send::RequestContext; use serde::{Deserialize, Serialize}; +#[cfg(feature = "v2")] use tokio::task::spawn_blocking; #[cfg(feature = "danger-local-https")] @@ -50,10 +59,158 @@ impl App { .with_context(|| "Failed to connect to bitcoind") } + #[cfg(feature = "v2")] pub async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()> { + let req_ctx = self.create_pj_request(bip21, fee_rate)?; + log::debug!("Awaiting response"); + let res = self.long_poll_post(req_ctx).await?; + self.process_pj_response(res)?; + Ok(()) + } + + #[cfg(not(feature = "v2"))] + pub async fn send_payjoin(&self, bip21: &str, fee_rate: &f32) -> Result<()> { + let (req, ctx) = self.create_pj_request(bip21, fee_rate)?.extract_v1()?; + let http = http_agent()?; + let body = String::from_utf8(req.body.clone()).unwrap(); + println!("Sending fallback request to {}", &req.url); + let response = http + .post(req.url.as_str()) + .set("Content-Type", "text/plain") + .send_string(&body.clone()) + .with_context(|| "HTTP request failed")?; + let fallback_tx = Psbt::from_str(&body) + .map_err(|e| anyhow!("Failed to load PSBT from base64: {}", e))? + .extract_tx(); + println!("Sent fallback transaction txid: {}", fallback_tx.txid()); + println!( + "Sent fallback transaction hex: {:#}", + payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) + ); + let psbt = ctx + .process_response(&mut response.into_reader()) + .map_err(|e| anyhow!("Failed to process response {}", e))?; + + self.process_pj_response(psbt)?; + Ok(()) + } + + #[cfg(feature = "v2")] + pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { + use v2::Enroller; + + let mut enroller = Enroller::from_relay_config( + &self.config.pj_endpoint, + &self.config.ohttp_config, + &self.config.ohttp_proxy, + ); + let (req, ctx) = enroller.extract_req()?; + log::debug!("Enrolling receiver"); + let ohttp_response = spawn_blocking(move || { + let http = http_agent()?; + http.post(req.url.as_ref()).send_bytes(&req.body).with_context(|| "HTTP request failed") + }) + .await??; + + let mut enrolled = enroller + .process_res(ohttp_response.into_reader(), ctx) + .map_err(|_| anyhow!("Enrollment failed"))?; + log::debug!("Enrolled receiver"); + + let pj_uri_string = + self.construct_payjoin_uri(amount_arg, Some(&enrolled.fallback_target()))?; + println!( + "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", + self.config.pj_host + ); + println!("{}", pj_uri_string); + + log::debug!("Awaiting proposal"); + let res = self.long_poll_fallback(&mut enrolled).await?; + log::debug!("Received request"); + let payjoin_proposal = self + .process_v2_proposal(res) + .map_err(|e| anyhow!("Failed to process proposal {}", e))?; + log::debug!("Posting payjoin back"); + let (req, ohttp_ctx) = payjoin_proposal + .extract_v2_req() + .map_err(|e| anyhow!("v2 req extraction failed {}", e))?; + let http = http_agent()?; + let res = http.post(req.url.as_str()).send_bytes(&req.body)?; + let mut buf = Vec::new(); + let _ = res.into_reader().read_to_end(&mut buf)?; + let res = payjoin_proposal.deserialize_res(buf, ohttp_ctx); + log::debug!("Received response {:?}", res); + Ok(()) + } + + #[cfg(not(feature = "v2"))] + pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { + let pj_uri_string = self.construct_payjoin_uri(amount_arg, None)?; + println!( + "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", + self.config.pj_host + ); + println!("{}", pj_uri_string); + + self.start_http_server().await?; + Ok(()) + } + + #[cfg(feature = "v2")] + async fn long_poll_post(&self, req_ctx: payjoin::send::RequestContext<'_>) -> Result { + loop { + let (req, ctx) = req_ctx.extract_v2(&self.config.ohttp_proxy)?; + println!("Sending fallback request to {}", &req.url); + let http = http_agent()?; + let response = spawn_blocking(move || { + http.post(req.url.as_ref()) + .set("Content-Type", "text/plain") + .send_bytes(&req.body) + .with_context(|| "HTTP request failed") + }) + .await??; + + println!("Sent fallback transaction"); + let psbt = ctx.process_response(&mut response.into_reader())?; + if let Some(psbt) = psbt { + return Ok(psbt); + } else { + log::info!("No response yet for POST payjoin request, retrying some seconds"); + std::thread::sleep(std::time::Duration::from_secs(5)); + } + } + } + + #[cfg(feature = "v2")] + async fn long_poll_fallback( + &self, + enrolled: &mut payjoin::receive::v2::Enrolled, + ) -> Result { + loop { + let (req, context) = + enrolled.extract_req().map_err(|_| anyhow!("Failed to extract request"))?; + log::debug!("GET fallback_psbt"); + let http = http_agent()?; + let ohttp_response = + spawn_blocking(move || http.post(req.url.as_str()).send_bytes(&req.body)).await??; + + let proposal = enrolled + .process_res(ohttp_response.into_reader(), context) + .map_err(|_| anyhow!("GET fallback failed"))?; + log::debug!("got response"); + match proposal { + Some(proposal) => break Ok(proposal), + None => std::thread::sleep(std::time::Duration::from_secs(5)), + } + } + } + + fn create_pj_request<'a>(&self, bip21: &'a str, fee_rate: &f32) -> Result> { let uri = payjoin::Uri::try_from(bip21) - .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))? - .assume_checked(); + .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?; + + let uri = uri.assume_checked(); let amount = uri.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?; @@ -90,31 +247,16 @@ impl App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - let fallback_tx = psbt.clone().extract_tx(); - let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) + let req_ctx = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri) .with_context(|| "Failed to build payjoin request")? .build_recommended(fee_rate) - .with_context(|| "Failed to build payjoin request")? - .extract_v1()?; + .with_context(|| "Failed to build payjoin request")?; - let http = http_agent()?; - println!("Sending fallback request to {}", &req.url); - let response = spawn_blocking(move || { - http.post(req.url.as_str()) - .set("Content-Type", "text/plain") - .send_bytes(&req.body) - .with_context(|| "HTTP request failed") - }) - .await??; - println!("Sent fallback transaction txid: {}", fallback_tx.txid()); - println!( - "Sent fallback transaction hex: {:#}", - payjoin::bitcoin::consensus::encode::serialize_hex(&fallback_tx) - ); + Ok(req_ctx) + } + + fn process_pj_response(&self, psbt: Psbt) -> Result { // TODO display well-known errors and log::debug the rest - let psbt = ctx - .process_response(&mut response.into_reader()) - .with_context(|| "Failed to process response")?; log::debug!("Proposed psbt: {:#?}", psbt); let psbt = self .bitcoind()? @@ -132,30 +274,12 @@ impl App { .send_raw_transaction(&tx) .with_context(|| "Failed to send raw transaction")?; println!("Payjoin sent: {}", txid); - Ok(()) + Ok(txid) } - pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { - use payjoin::Uri; - - let pj_receiver_address = self.bitcoind()?.get_new_address(None, None)?.assume_checked(); - let amount = Amount::from_sat(amount_arg.parse()?); - let pj_uri_string = format!( - "{}?amount={}&pj={}", - pj_receiver_address.to_qr_uri(), - amount.to_btc(), - self.config.pj_endpoint - ); - // check that the URI is corrctly formatted - let _pj_uri = Uri::from_str(&pj_uri_string) - .map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))? - .assume_checked(); + #[cfg(not(feature = "v2"))] + async fn start_http_server(self) -> Result<()> { let bind_addr: SocketAddr = self.config.pj_host.parse()?; - println!( - "Listening at {}. Configured to accept payjoin at BIP 21 Payjoin Uri:", - self.config.pj_host - ); - println!("{}", pj_uri_string); #[cfg(feature = "danger-local-https")] let server = { @@ -183,9 +307,9 @@ impl App { #[cfg(not(feature = "danger-local-https"))] let server = Server::bind(&bind_addr); - + let app = self.clone(); let make_svc = make_service_fn(|_| { - let app = self.clone(); + let app = app.clone(); async move { let handler = move |req| app.clone().handle_web_request(req); Ok::<_, hyper::Error>(service_fn(handler)) @@ -195,6 +319,34 @@ impl App { Ok(()) } + fn construct_payjoin_uri( + &self, + amount_arg: &str, + fallback_target: Option<&str>, + ) -> Result { + let pj_receiver_address = self.bitcoind()?.get_new_address(None, None)?.assume_checked(); + let amount = Amount::from_sat(amount_arg.parse()?); + let pj_part = match fallback_target { + Some(target) => target, + None => self.config.pj_endpoint.as_str(), + }; + + let pj_uri_string = format!( + "{}?amount={}&pj={}&ohttp={}", + pj_receiver_address.to_qr_uri(), + amount.to_btc(), + pj_part, + self.config.ohttp_config, + ); + + // to check uri validity + let _pj_uri = payjoin::Uri::from_str(&pj_uri_string) + .map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))?; + + Ok(pj_uri_string) + } + + #[cfg(not(feature = "v2"))] async fn handle_web_request(self, req: Request) -> Result> { log::debug!("Received request: {:?}", req); let mut response = match (req.method(), req.uri().path()) { @@ -238,6 +390,7 @@ impl App { Ok(response) } + #[cfg(not(feature = "v2"))] fn handle_get_bip21(&self, amount: Option) -> Result, Error> { let address = self .bitcoind() @@ -262,6 +415,7 @@ impl App { Ok(Response::new(Body::from(uri_string))) } + #[cfg(not(feature = "v2"))] async fn handle_payjoin_post(&self, req: Request) -> Result, Error> { let (parts, body) = req.into_parts(); let headers = Headers(&parts.headers); @@ -272,6 +426,15 @@ impl App { let proposal = payjoin::receive::UncheckedProposal::from_request(body, query_string, headers)?; + let payjoin_proposal = self.process_v1_proposal(proposal)?; + let psbt = payjoin_proposal.psbt(); + let body = base64::encode(psbt.serialize()); + println!("Responded with Payjoin proposal {}", psbt.clone().extract_tx().txid()); + Ok(Response::new(Body::from(body))) + } + + #[cfg(not(feature = "v2"))] + fn process_v1_proposal(&self, proposal: UncheckedProposal) -> Result { let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx @@ -342,26 +505,110 @@ impl App { .assume_checked(); provisional_payjoin.substitute_output_address(receiver_substitute_address); - let payjoi_proposal = provisional_payjoin.finalize_proposal( + let payjoin_proposal = provisional_payjoin.finalize_proposal( |psbt: &Psbt| { bitcoind - .wallet_process_psbt( - &payjoin::base64::encode(psbt.serialize()), - None, - None, - Some(false), - ) + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) .map(|res| Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) .map_err(|e| Error::Server(e.into()))? }, Some(bitcoin::FeeRate::MIN), )?; - let payjoin_proposal_psbt = payjoi_proposal.psbt(); - log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + println!( + "Responded with Payjoin proposal {}", + payjoin_proposal_psbt.clone().extract_tx().txid() + ); + Ok(payjoin_proposal) + } + + #[cfg(feature = "v2")] + fn process_v2_proposal( + &self, + proposal: v2::UncheckedProposal, + ) -> Result { + let bitcoind = self.bitcoind().map_err(|e| Error::Server(e.into()))?; + + // 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(); + + // The network is used for checks later + let network = + bitcoind.get_blockchain_info().map_err(|e| Error::Server(e.into())).and_then( + |info| bitcoin::Network::from_str(&info.chain).map_err(|e| Error::Server(e.into())), + )?; + + // Receive Check 1: Can Broadcast + let proposal = proposal.check_can_broadcast(|tx| { + let raw_tx = bitcoin::consensus::encode::serialize_hex(&tx); + let mempool_results = + bitcoind.test_mempool_accept(&[raw_tx]).map_err(|e| Error::Server(e.into()))?; + match mempool_results.first() { + Some(result) => Ok(result.allowed), + None => Err(Error::Server( + anyhow!("No mempool results returned on broadcast check").into(), + )), + } + })?; + log::trace!("check1"); + + // Receive Check 2: receiver can't sign for proposal inputs + let proposal = proposal.check_inputs_not_owned(|input| { + if let Ok(address) = bitcoin::Address::from_script(input, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })?; + log::trace!("check2"); + // Receive Check 3: receiver can't sign for proposal inputs + let proposal = proposal.check_no_mixed_input_scripts()?; + log::trace!("check3"); + + // Receive Check 4: have we seen this input before? More of a check for non-interactive i.e. payment processor receivers. + let payjoin = proposal.check_no_inputs_seen_before(|input| { + Ok(!self.insert_input_seen_before(*input).map_err(|e| Error::Server(e.into()))?) + })?; + log::trace!("check4"); + + let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output_script| { + if let Ok(address) = bitcoin::Address::from_script(output_script, network) { + bitcoind + .get_address_info(&address) + .map(|info| info.is_mine.unwrap_or(false)) + .map_err(|e| Error::Server(e.into())) + } else { + Ok(false) + } + })?; - let payload = payjoin::base64::encode(payjoin_proposal_psbt.serialize()); - log::info!("successful response"); - Ok(Response::new(Body::from(payload))) + if !self.config.sub_only { + // Select receiver payjoin inputs. + _ = try_contributing_inputs(&mut provisional_payjoin.inner, &bitcoind) + .map_err(|e| log::warn!("Failed to contribute inputs: {}", e)); + } + + let receiver_substitute_address = bitcoind + .get_new_address(None, None) + .map_err(|e| Error::Server(e.into()))? + .assume_checked(); + provisional_payjoin.substitute_output_address(receiver_substitute_address); + + let payjoin_proposal = provisional_payjoin.finalize_proposal( + |psbt: &Psbt| { + bitcoind + .wallet_process_psbt(&base64::encode(psbt.serialize()), None, None, Some(false)) + .map(|res| Psbt::from_str(&res.psbt).map_err(|e| Error::Server(e.into()))) + .map_err(|e| Error::Server(e.into()))? + }, + Some(bitcoin::FeeRate::MIN), + )?; + let payjoin_proposal_psbt = payjoin_proposal.psbt(); + log::debug!("Receiver's Payjoin proposal PSBT Rsponse: {:#?}", payjoin_proposal_psbt); + Ok(payjoin_proposal) } fn insert_input_seen_before(&self, input: bitcoin::OutPoint) -> Result { @@ -408,6 +655,9 @@ pub(crate) struct AppConfig { pub bitcoind_cookie: Option, pub bitcoind_rpcuser: String, pub bitcoind_rpcpass: String, + pub ohttp_config: String, + #[cfg(feature = "v2")] + pub ohttp_proxy: String, // receive-only pub pj_host: String, @@ -438,6 +688,16 @@ impl AppConfig { "bitcoind_rpcpass", matches.get_one::("rpcpass").map(|s| s.as_str()), )? + .set_default("ohttp_config", "")? + .set_override_option( + "ohttp_config", + matches.get_one::("ohttp_config").map(|s| s.as_str()), + )? + .set_default("ohttp_proxy", "")? + .set_override_option( + "ohttp_proxy", + matches.get_one::("ohttp_proxy").map(|s| s.as_str()), + )? // Subcommand defaults without which file serialization fails. .set_default("pj_host", "0.0.0.0:3000")? .set_default("pj_endpoint", "https://localhost:3000")? @@ -459,12 +719,13 @@ impl AppConfig { _ => unreachable!(), // If all subcommands are defined above, anything else is unreachabe!() }; let app_conf = builder.build()?; + log::debug!("App config: {:?}", app_conf); app_conf.try_deserialize().context("Failed to deserialize config") } } fn try_contributing_inputs( - payjoin: &mut ProvisionalProposal, + payjoin: &mut payjoin::receive::ProvisionalProposal, bitcoind: &bitcoincore_rpc::Client, ) -> Result<()> { use bitcoin::OutPoint; @@ -502,7 +763,7 @@ impl payjoin::receive::Headers for Headers<'_> { } } -fn serialize_psbt(psbt: &Psbt) -> String { payjoin::base64::encode(&psbt.serialize()) } +fn serialize_psbt(psbt: &Psbt) -> String { base64::encode(psbt.serialize()) } #[cfg(feature = "danger-local-https")] fn http_agent() -> Result { diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index 0c82f06f..f6c53e58 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -52,6 +52,12 @@ fn cli() -> ArgMatches { .takes_value(true) .help("The password for the bitcoin node")) .subcommand_required(true) + .arg(Arg::new("ohttp_config") + .long("ohttp-config") + .help("The ohttp config file")) + .arg(Arg::new("ohttp_proxy") + .long("ohttp-proxy") + .help("The ohttp proxy url")) .subcommand( Command::new("send") .arg_required_else_help(true) diff --git a/payjoin-relay/tests/e2e.rs b/payjoin-relay/tests/e2e.rs new file mode 100644 index 00000000..b21db82e --- /dev/null +++ b/payjoin-relay/tests/e2e.rs @@ -0,0 +1,192 @@ +use std::env; +use std::process::Stdio; + +use bitcoind::bitcoincore_rpc::core_rpc_json::AddressType; +use bitcoind::bitcoincore_rpc::RpcApi; +use log::{log_enabled, Level}; +use payjoin::bitcoin::Amount; +use testcontainers_modules::postgres::Postgres; +use testcontainers_modules::testcontainers::clients::Cli; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; + +const PJ_RELAY_URL: &str = "https://localhost:8088"; +const OH_RELAY_URL: &str = "https://localhost:8088"; +const RECEIVE_SATS: &str = "54321"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[cfg(feature = "danger-local-https")] +async fn e2e() { + // Compile payjoin-cli with default features + let mut compile_v1 = compile_payjoin_cli(false).await; + // Compile payjoin-cli with v2 features + let mut compile_v2 = compile_payjoin_cli(true).await; + + std::env::set_var("RUST_LOG", "debug"); + std::env::set_var("PJ_RELAY_PORT", "8088"); + std::env::set_var("PJ_RELAY_TIMEOUT_SECS", "1"); + let _ = env_logger::builder().is_test(true).try_init(); + let docker = Cli::default(); + let node = docker.run(Postgres::default()); + std::env::set_var("PJ_DB_HOST", format!("localhost:{}", node.get_host_port_ipv4(5432))); + + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| bitcoind::downloaded_exe_path().ok()) + .expect("version feature or env BITCOIND_EXE is required for tests"); + let mut conf = bitcoind::Conf::default(); + conf.view_stdout = log_enabled!(Level::Debug); + let mut relay = Command::new(env!("CARGO_BIN_EXE_payjoin-relay")) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-relay"); + log::debug!("Relay started"); + let bitcoind = bitcoind::BitcoinD::with_conf(bitcoind_exe, &conf).unwrap(); + let receiver = bitcoind.create_wallet("receiver").unwrap(); + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); + let sender = bitcoind.create_wallet("sender").unwrap(); + let sender_address = + sender.get_new_address(None, Some(AddressType::Bech32)).unwrap().assume_checked(); + bitcoind.client.generate_to_address(1, &receiver_address).unwrap(); + bitcoind.client.generate_to_address(101, &sender_address).unwrap(); + + assert_eq!( + Amount::from_btc(50.0).unwrap(), + receiver.get_balances().unwrap().mine.trusted, + "receiver doesn't own bitcoin" + ); + + assert_eq!( + Amount::from_btc(50.0).unwrap(), + sender.get_balances().unwrap().mine.trusted, + "sender doesn't own bitcoin" + ); + + let http = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("Failed to build reqwest http client"); + + // ********************** + // From a connection distinct from the client, perhaps a service provider, or over a VPN or Tor + let response = http + .get(format!("{}/ohttp-config", PJ_RELAY_URL)) + .send() + .await + .expect("Failed to send request"); + let ohttp_config = response.text().await.expect("Failed to read response"); + log::debug!("Got ohttp-config {}", &ohttp_config); + + let receiver_rpchost = format!("{}/wallet/receiver", bitcoind.params.rpc_socket); + let sender_rpchost = format!("{}/wallet/sender", bitcoind.params.rpc_socket); + + let cookie_file = &bitcoind.params.cookie_file; + + // Paths to the compiled binaries + let v1_status = compile_v1.wait().await.unwrap(); + let v2_status = compile_v2.wait().await.unwrap(); + assert!(v1_status.success(), "Process did not exit successfully"); + assert!(v2_status.success(), "Process did not exit successfully"); + + let v2_receiver = "target/v2/debug/payjoin"; + let v1_sender = "target/v1/debug/payjoin"; + + let mut cli_receiver = Command::new(v2_receiver) + .arg("--rpchost") + .arg(&receiver_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--ohttp-config") + .arg(&ohttp_config) + .arg("--ohttp-proxy") + .arg(OH_RELAY_URL) + .arg("receive") + .arg(RECEIVE_SATS) + .arg("--endpoint") + .arg(PJ_RELAY_URL) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + + let stdout = cli_receiver.stdout.take().expect("Failed to take stdout of child process"); + let reader = BufReader::new(stdout); + let mut stdout = tokio::io::stdout(); + + let mut bip21 = String::new(); + + let mut lines = reader.lines(); + + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + // Write to stdout regardless + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + // Check if it's the line we're interested in + if line.starts_with("BITCOIN") { + bip21 = line; + break; + } + } + log::debug!("Got bip21 {}", &bip21); + tokio::spawn(async move { + let mut stdout = tokio::io::stdout(); + while let Some(line) = lines.next_line().await.expect("Failed to read line from stdout") { + // Continue to write to stdout + stdout + .write_all(format!("{}\n", line).as_bytes()) + .await + .expect("Failed to write to stdout"); + + if line.contains("Transaction sent") { + log::debug!("HOLY MOLY BATMAN! Transaction sent!") + } + } + }); + + let mut cli_sender = Command::new(v1_sender) + .arg("--rpchost") + .arg(&sender_rpchost) + .arg("--cookie-file") + .arg(&cookie_file) + .arg("--ohttp-config") + .arg(&ohttp_config) + .arg("--ohttp-proxy") + .arg(OH_RELAY_URL) + .arg("send") + .arg(&bip21) + .arg("--fee_rate") + .arg("1") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("Failed to execute payjoin-cli"); + + // delay 10 seconds + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + relay.kill().await.expect("Failed to kill payjoin-relay"); + cli_receiver.kill().await.expect("Failed to kill payjoin-cli"); + cli_sender.kill().await.expect("Failed to kill payjoin-cli"); +} + +async fn compile_payjoin_cli(feature_v2: bool) -> Child { + let target_dir = if feature_v2 { "target/v2" } else { "target/v1" }; + + env::set_var("CARGO_TARGET_DIR", target_dir); + + let mut command = Command::new("cargo"); + command.stdout(Stdio::inherit()).stderr(Stdio::inherit()).args([ + "build", + "--package", + "payjoin-cli", + ]); + + if feature_v2 { + command.args(["--features", "v2"]); + } + command.spawn().unwrap() +} diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index e41b67cf..7848408a 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -356,7 +356,7 @@ mod integration { // Create a funded PSBT (not broadcasted) to address with amount given in the pj_uri let psbt = build_original_psbt(&sender, &pj_uri)?; debug!("Original psbt: {:#?}", psbt); - let (send_req, _) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? + let (send_req, send_ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)? .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? .extract_v1()?; log::info!("send fallback v1 to offline receiver fail"); @@ -377,7 +377,7 @@ mod integration { // ********************** // Inside the Receiver: - let _receiver_loop = tokio::task::spawn(async move { + let receiver_loop = tokio::task::spawn(async move { let (response, ctx) = loop { let (req, ctx) = enrolled.extract_req().unwrap(); let response = spawn_blocking(move || { @@ -428,18 +428,17 @@ mod integration { .send_bytes(&body) .expect("Failed to send request") }) - .await + .await? }; log::info!("Response: {:#?}", &response); - assert!(response.is_err()); - - // TODO support v1 relay - // let checked_payjoin_proposal_psbt = - // send_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"); - // assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); + assert!(is_success(response.status())); + + let checked_payjoin_proposal_psbt = + send_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"); + assert!(receiver_loop.await.is_ok(), "The spawned task panicked or returned an error"); relay.kill().await?; let output = &relay.wait_with_output().await?; log::info!("Status: {}", output.status); From f81f4019558f6b702f416bee2180c6200ae0a51d Mon Sep 17 00:00:00 2001 From: DanGould Date: Thu, 30 Nov 2023 15:09:38 -0500 Subject: [PATCH 6/6] Impl traits for session persistence --- Cargo.lock | 1 + payjoin/Cargo.toml | 3 +- payjoin/src/receive/v2.rs | 138 +++++++++++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 317658ca..049a99ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1716,6 +1716,7 @@ dependencies = [ "ohttp", "rand", "rustls", + "serde", "testcontainers", "testcontainers-modules", "tokio", diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index a6e36f6c..9e73fa22 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -18,7 +18,7 @@ exclude = ["tests"] send = [] receive = ["rand"] base64 = ["bitcoin/base64"] -v2 = ["bitcoin/rand-std", "chacha20poly1305", "ohttp", "bhttp"] +v2 = ["bitcoin/rand-std", "chacha20poly1305", "ohttp", "bhttp", "serde"] [dependencies] bitcoin = { version = "0.30.0", features = ["base64"] } @@ -28,6 +28,7 @@ log = { version = "0.4.14"} ohttp = { version = "0.4.0", optional = true } bhttp = { version = "0.4.0", optional = true } rand = { version = "0.8.4", optional = true } +serde = { version = "1.0.186", default-features = false, optional = true } url = "2.2.2" [dev-dependencies] diff --git a/payjoin/src/receive/v2.rs b/payjoin/src/receive/v2.rs index d1363e7a..97850990 100644 --- a/payjoin/src/receive/v2.rs +++ b/payjoin/src/receive/v2.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use bitcoin::psbt::Psbt; use bitcoin::{base64, Amount, FeeRate, OutPoint, Script, TxOut}; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Serialize, Serializer}; use super::{Error, InternalRequestError, RequestError, SelectionError}; use crate::psbt::PsbtExt; @@ -105,7 +107,7 @@ fn subdirectory(pubkey: &bitcoin::secp256k1::PublicKey) -> String { base64::encode_config(pubkey, b64_config) } -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct Enrolled { relay_url: url::Url, ohttp_config: Vec, @@ -113,6 +115,138 @@ pub struct Enrolled { s: bitcoin::secp256k1::KeyPair, } +impl Serialize for Enrolled { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("Enrolled", 4)?; + state.serialize_field("relay_url", &self.relay_url.to_string())?; + state.serialize_field("ohttp_config", &self.ohttp_config)?; + state.serialize_field("ohttp_proxy", &self.ohttp_proxy.to_string())?; + state.serialize_field("s", &self.s.secret_key().secret_bytes())?; + + state.end() + } +} + +use std::fmt; +use std::str::FromStr; + +use serde::de::{self, Deserializer, MapAccess, Visitor}; + +impl<'de> Deserialize<'de> for Enrolled { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + enum Field { + RelayUrl, + OhttpConfig, + OhttpProxy, + S, + } + + impl<'de> Deserialize<'de> for Field { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct FieldVisitor; + + impl<'de> Visitor<'de> for FieldVisitor { + type Value = Field; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("`relay_url`, `ohttp_config`, `ohttp_proxy`, or `s`") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + match value { + "relay_url" => Ok(Field::RelayUrl), + "ohttp_config" => Ok(Field::OhttpConfig), + "ohttp_proxy" => Ok(Field::OhttpProxy), + "s" => Ok(Field::S), + _ => Err(de::Error::unknown_field(value, FIELDS)), + } + } + } + + deserializer.deserialize_identifier(FieldVisitor) + } + } + + struct EnrolledVisitor; + + impl<'de> Visitor<'de> for EnrolledVisitor { + type Value = Enrolled; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("struct Enrolled") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut relay_url = None; + let mut ohttp_config = None; + let mut ohttp_proxy = None; + let mut s = None; + while let Some(key) = map.next_key()? { + match key { + Field::RelayUrl => { + if relay_url.is_some() { + return Err(de::Error::duplicate_field("relay_url")); + } + let url_str: String = map.next_value()?; + relay_url = Some(url::Url::parse(&url_str).map_err(de::Error::custom)?); + } + Field::OhttpConfig => { + if ohttp_config.is_some() { + return Err(de::Error::duplicate_field("ohttp_config")); + } + ohttp_config = Some(map.next_value()?); + } + Field::OhttpProxy => { + if ohttp_proxy.is_some() { + return Err(de::Error::duplicate_field("ohttp_proxy")); + } + let proxy_str: String = map.next_value()?; + ohttp_proxy = + Some(url::Url::parse(&proxy_str).map_err(de::Error::custom)?); + } + Field::S => { + if s.is_some() { + return Err(de::Error::duplicate_field("s")); + } + let s_bytes: Vec = map.next_value()?; + let secp = bitcoin::secp256k1::Secp256k1::new(); + s = Some( + bitcoin::secp256k1::KeyPair::from_seckey_slice(&secp, &s_bytes) + .map_err(de::Error::custom)?, + ); + } + } + } + let relay_url = relay_url.ok_or_else(|| de::Error::missing_field("relay_url"))?; + let ohttp_config = + ohttp_config.ok_or_else(|| de::Error::missing_field("ohttp_config"))?; + let ohttp_proxy = + ohttp_proxy.ok_or_else(|| de::Error::missing_field("ohttp_proxy"))?; + let s = s.ok_or_else(|| de::Error::missing_field("s"))?; + Ok(Enrolled { relay_url, ohttp_config, ohttp_proxy, s }) + } + } + + const FIELDS: &[&str] = &["relay_url", "ohttp_config", "ohttp_proxy", "s"]; + deserializer.deserialize_struct("Enrolled", FIELDS, EnrolledVisitor) + } +} + impl Enrolled { pub fn extract_req(&self) -> Result<(Request, ohttp::ClientResponse), Error> { let (body, ohttp_ctx) = self.fallback_req_body()?; @@ -172,6 +306,8 @@ impl Enrolled { crate::v2::ohttp_encapsulate(&self.ohttp_config, "GET", &self.fallback_target(), None) } + pub fn pubkey(&self) -> [u8; 33] { self.s.public_key().serialize() } + pub fn fallback_target(&self) -> String { let pubkey = &self.s.public_key().serialize(); let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false);