From f6efdab42b7f94e2a282974413e52b782675e069 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 22 Sep 2023 13:32:45 -0400 Subject: [PATCH] Protect metadata with Oblivious HTTP --- Cargo.lock | 431 +++++++++++++++++++++++++++++++++---- payjoin-cli/src/app.rs | 135 +++++++----- payjoin-cli/src/main.rs | 6 + payjoin-relay/Cargo.toml | 7 + payjoin-relay/src/main.rs | 94 +++++++- payjoin/Cargo.toml | 5 +- payjoin/src/receive/mod.rs | 86 ++++++-- payjoin/src/send/error.rs | 2 +- payjoin/src/send/mod.rs | 223 +++++++++++-------- payjoin/src/uri.rs | 28 ++- payjoin/src/v2.rs | 32 ++- 11 files changed, 839 insertions(+), 210 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c244e7b4..b884be09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[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" @@ -33,6 +43,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.9", + "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.9", +] + +[[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.6" @@ -286,6 +347,23 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bhttp" +version = "0.4.0" +dependencies = [ + "thiserror", +] + +[[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" @@ -386,6 +464,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" @@ -480,6 +567,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" @@ -487,8 +586,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.9", +] + +[[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]] @@ -497,10 +609,10 @@ 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", ] @@ -522,6 +634,15 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cca491388666e04d7248af3f60f0c40cfb0991c72205595d7c396e3510207d1a" +[[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" @@ -633,6 +754,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.9" @@ -692,10 +822,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 = "deflate" version = "1.0.0" @@ -723,13 +894,22 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2696e8a945f658fd14dc3b87242e6b80cd0f36ff04ea560fa39082368847946" +[[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", @@ -991,6 +1171,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.0" @@ -1089,13 +1289,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]] @@ -1104,7 +1324,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1116,6 +1336,27 @@ dependencies = [ "windows-sys", ] +[[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.7", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "0.2.9" @@ -1400,7 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1596,6 +1837,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohttp" +version = "0.4.0" +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.18.0" @@ -1717,12 +1979,14 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" name = "payjoin" version = "0.9.0" dependencies = [ + "bhttp 0.4.0", "bip21", "bitcoin", "bitcoind", - "chacha20poly1305", + "chacha20poly1305 0.10.1", "env_logger", "log", + "ohttp", "rand", "serde", "serde_json", @@ -1754,9 +2018,15 @@ version = "0.0.1" dependencies = [ "anyhow", "axum", + "bhttp 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "http", + "httparse", + "hyper", + "ohttp", "payjoin", "sqlx", "tokio", + "tower-service", "tracing", "tracing-subscriber", ] @@ -1834,7 +2104,7 @@ checksum = "b42f0394d3123e33353ca5e1e89092e533d2cc490389f2bd6131c43c634ebc5f" dependencies = [ "once_cell", "pest", - "sha2", + "sha2 0.10.7", ] [[package]] @@ -1896,15 +2166,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.9", + "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.9", + "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.9", + "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.9", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", ] [[package]] @@ -1945,7 +2250,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1955,9 +2260,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" @@ -2127,14 +2438,14 @@ checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" dependencies = [ "byteorder", "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-iter", "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -2329,8 +2640,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.9", + "digest 0.10.7", ] [[package]] @@ -2339,6 +2650,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" +[[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.9", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.7" @@ -2346,8 +2670,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479fb9d862239e610720565ca91403019f2f00410f1864c5aa7479b950a76ed8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.9", + "digest 0.10.7", ] [[package]] @@ -2374,8 +2698,8 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" dependencies = [ - "digest", - "rand_core", + "digest 0.10.7", + "rand_core 0.6.4", ] [[package]] @@ -2492,7 +2816,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.7", "smallvec", "sqlformat", "thiserror", @@ -2530,7 +2854,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.7", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -2553,7 +2877,7 @@ dependencies = [ "byteorder", "bytes", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2562,8 +2886,8 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", - "hmac", + "hkdf 0.12.3", + "hmac 0.12.1", "itoa", "log", "md-5", @@ -2574,7 +2898,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.7", "smallvec", "sqlx-core", "stringprep", @@ -2601,8 +2925,8 @@ dependencies = [ "futures-io", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.12.3", + "hmac 0.12.1", "home", "itoa", "log", @@ -2613,7 +2937,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2", + "sha2 0.10.7", "smallvec", "sqlx-core", "stringprep", @@ -2663,9 +2987,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" @@ -3060,6 +3384,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" @@ -3361,6 +3695,17 @@ dependencies = [ "windows-sys", ] +[[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.0.1" @@ -3393,6 +3738,20 @@ name = "zeroize" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +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.37", +] [[package]] name = "zip" diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 9afcabdc..062406e9 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -11,7 +11,10 @@ use clap::ArgMatches; use config::{Config, File, FileFormat}; use payjoin::bitcoin::psbt::Psbt; use payjoin::bitcoin::{self, base64}; -use payjoin::receive::{Error, PayjoinProposal, ProvisionalProposal, UncheckedProposal}; +use payjoin::receive::{ + EnrollContext, Error, PayjoinProposal, ProvisionalProposal, UncheckedProposal, +}; +use payjoin::send::RequestContext; #[cfg(not(feature = "v2"))] use rouille::{Request, Response}; use serde::{Deserialize, Serialize}; @@ -44,7 +47,8 @@ impl App { #[cfg(feature = "v2")] pub async fn send_payjoin(&self, bip21: &str) -> Result<()> { - let (req, ctx) = self.create_pj_request(bip21)?; + // TODO extract requests inside poll loop for unique OHTTP payloads + let req_ctx = self.create_pj_request(bip21)?; let client = reqwest::Client::builder() .danger_accept_invalid_certs(self.config.danger_accept_invalid_certs) @@ -52,53 +56,52 @@ impl App { .with_context(|| "Failed to build reqwest http client")?; log::debug!("Awaiting response"); - let res = Self::long_poll_post(&client, req).await?; - let mut res = std::io::Cursor::new(&res); - self.process_pj_response(ctx, &mut res)?; + let res = self.long_poll_post(&client, req_ctx).await?; + self.process_pj_response(res)?; Ok(()) } #[cfg(feature = "v2")] async fn long_poll_post( + &self, client: &reqwest::Client, - req: payjoin::send::Request, - ) -> Result, reqwest::Error> { + req_ctx: payjoin::send::RequestContext<'_>, + ) -> Result { loop { + let (req, ctx) = req_ctx.extract_v2(&self.config.ohttp_proxy)?; let response = client - .post(req.url.as_str()) + .post(req.url) .body(req.body.clone()) .header("Content-Type", "text/plain") .send() .await?; - - if response.status() == reqwest::StatusCode::OK { - let body = response.bytes().await?.to_vec(); - return Ok(body); - } else if response.status() == reqwest::StatusCode::ACCEPTED { + let bytes = response.bytes().await?; + let mut cursor = std::io::Cursor::new(bytes); + let psbt = ctx.process_response(&mut cursor)?; + if let Some(psbt) = psbt { + return Ok(psbt); + } else { log::info!("No response yet for POST payjoin request, retrying some seconds"); tokio::time::sleep(std::time::Duration::from_secs(5)).await; - } else { - log::error!("Unexpected response status: {}", response.status()); - // TODO handle error - panic!("Unexpected response status: {}", response.status()) } } } #[cfg(feature = "v2")] - async fn long_poll_get(client: &reqwest::Client, url: &str) -> Result, reqwest::Error> { + async fn long_poll_get( + &self, + client: &reqwest::Client, + enroll_context: &mut EnrollContext, + ) -> Result { loop { - let response = client.get(url).send().await?; - - if response.status().is_success() { - let body = response.bytes().await?; - if !body.is_empty() { - return Ok(body.to_vec()); - } else { - log::info!("No response yet for GET payjoin request, retrying in 5 seconds"); - } - - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + let (enroll_body, context) = enroll_context.enroll_body(); + let ohttp_response = + client.post(&self.config.ohttp_proxy).body(enroll_body).send().await?; + let ohttp_response = ohttp_response.bytes().await?; + let proposal = enroll_context.parse_proposal(ohttp_response.as_ref(), context).unwrap(); + match proposal { + Some(proposal) => return Ok(proposal), + None => tokio::time::sleep(std::time::Duration::from_secs(5)).await, } } } @@ -122,10 +125,7 @@ impl App { Ok(()) } - fn create_pj_request( - &self, - bip21: &str, - ) -> Result<(payjoin::send::Request, payjoin::send::Context)> { + fn create_pj_request<'a>(&self, bip21: &'a str) -> Result> { let uri = payjoin::Uri::try_from(bip21) .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?; @@ -167,22 +167,16 @@ impl App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - - 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")?; - Ok((req, ctx)) + Ok(req_ctx) } - fn process_pj_response( - &self, - ctx: payjoin::send::Context, - response: &mut impl std::io::Read, - ) -> Result { + fn process_pj_response(&self, psbt: Psbt) -> Result { // TODO display well-known errors and log::debug the rest - let psbt = ctx.process_response(response).with_context(|| "Failed to process response")?; log::debug!("Proposed psbt: {:#?}", psbt); let psbt = self .bitcoind @@ -218,7 +212,11 @@ impl App { #[cfg(feature = "v2")] pub async fn receive_payjoin(self, amount_arg: &str) -> Result<()> { - let context = payjoin::receive::ProposalContext::new(); + let mut context = EnrollContext::from_relay_config( + &self.config.pj_endpoint, + &self.config.ohttp_config, + &self.config.ohttp_proxy, + ); let pj_uri_string = self.construct_payjoin_uri(amount_arg, Some(&context.subdirectory()))?; println!( @@ -231,25 +229,26 @@ impl App { .danger_accept_invalid_certs(self.config.danger_accept_invalid_certs) .build() .with_context(|| "Failed to build reqwest http client")?; - log::debug!("Awaiting request"); - let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir()); - let mut buffer = Self::long_poll_get(&client, &receive_endpoint).await?; + log::debug!("Awaiting proposal"); + let proposal = self.long_poll_get(&client, &mut context).await?; - log::debug!("Received request"); - let proposal = context - .parse_proposal(&mut buffer) - .map_err(|e| anyhow!("Failed to parse into UncheckedProposal {}", e))?; + log::debug!("Received proposal"); let payjoin_proposal = self .process_proposal(proposal) .map_err(|e| anyhow!("Failed to process UncheckedProposal {}", e))?; - let body = payjoin_proposal.serialize_body(); - let _ = client - .post(receive_endpoint) + let receive_endpoint = format!("{}/{}", self.config.pj_endpoint, context.receive_subdir()); + let (body, ohttp_ctx) = + payjoin_proposal.extract_v2_req(&self.config.ohttp_config, &receive_endpoint); + let res = client + .post(&self.config.ohttp_proxy) .body(body) .send() .await .with_context(|| "HTTP request failed")?; + let res = res.bytes().await?; + let res = payjoin_proposal.deserialize_res(res.to_vec(), ohttp_ctx); + log::debug!("Received response {:?}", res); Ok(()) } @@ -286,14 +285,15 @@ impl App { let amount = Amount::from_sat(amount_arg.parse()?); //let subdir = self.config.pj_endpoint + pubkey.map_or(&String::from(""), |s| &format!("/{}", s)); let pj_uri_string = format!( - "{}?amount={}&pj={}", + "{}?amount={}&pj={}&ohttp={}", pj_receiver_address.to_qr_uri(), amount.to_btc(), format!( "{}{}", self.config.pj_endpoint, pubkey.map_or(String::from(""), |s| format!("/{}", s)) - ) + ), + self.config.ohttp_config, ); // check validity @@ -472,6 +472,19 @@ impl App { } } +fn serialize_request_to_bytes(req: reqwest::Request) -> Vec { + let mut serialized_request = + format!("{} {} HTTP/1.1\r\n", req.method(), req.url()).into_bytes(); + + for (name, value) in req.headers().iter() { + let header_line = format!("{}: {}\r\n", name.as_str(), value.to_str().unwrap()); + serialized_request.extend(header_line.as_bytes()); + } + + serialized_request.extend(b"\r\n"); + serialized_request +} + struct SeenInputs { set: OutPointSet, file: std::fs::File, @@ -511,6 +524,8 @@ pub(crate) struct AppConfig { pub bitcoind_cookie: Option, pub bitcoind_rpcuser: String, pub bitcoind_rpcpass: String, + pub ohttp_config: String, + pub ohttp_proxy: String, // send-only pub danger_accept_invalid_certs: bool, @@ -544,6 +559,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("danger_accept_invalid_certs", false)? .set_default("pj_host", "0.0.0.0:3000")? diff --git a/payjoin-cli/src/main.rs b/payjoin-cli/src/main.rs index 4aca08b6..10f20c2d 100644 --- a/payjoin-cli/src/main.rs +++ b/payjoin-cli/src/main.rs @@ -73,6 +73,12 @@ fn cli() -> ArgMatches { .long("rpcpass") .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/Cargo.toml b/payjoin-relay/Cargo.toml index 2d84157b..381dcd1e 100644 --- a/payjoin-relay/Cargo.toml +++ b/payjoin-relay/Cargo.toml @@ -8,8 +8,15 @@ edition = "2021" [dependencies] axum = "0.6.2" anyhow = "1.0.71" +hyper = "0.14.27" +http = "0.2.4" +# ohttp = "0.4.0" +httparse = "1.8.0" +ohttp = { path = "../../ohttp/ohttp" } +bhttp = { version = "0.4.0", features = ["http"] } payjoin = { path = "../payjoin", features = ["v2"] } sqlx = { version = "0.7.1", features = ["postgres", "runtime-tokio"] } tokio = { version = "1.12.0", features = ["full"] } +tower-service = "0.3.2" tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } diff --git a/payjoin-relay/src/main.rs b/payjoin-relay/src/main.rs index 0cdb55f0..3c47059c 100644 --- a/payjoin-relay/src/main.rs +++ b/payjoin-relay/src/main.rs @@ -1,7 +1,9 @@ -use anyhow::Result; +use std::sync::Arc; + +use anyhow::{Context, Result}; use axum::body::Bytes; use axum::extract::Path; -use axum::http::StatusCode; +use axum::http::{Request, StatusCode}; use axum::routing::{get, post}; use axum::Router; use payjoin::v2::MAX_BUFFER_SIZE; @@ -15,8 +17,9 @@ use crate::db::DbPool; async fn main() -> Result<(), Box> { init_logging(); let pool = DbPool::new(std::time::Duration::from_secs(30)).await?; - - let app = Router::new() + let ohttp = Arc::new(init_ohttp()?); + let ohttp_config = ohttp_config(&*ohttp)?; + let target_resource = Router::new() .route( "/:id", post({ @@ -36,8 +39,13 @@ async fn main() -> Result<(), Box> { }), ); + let ohttp_gateway = Router::new() + .route("/", post(move |body| handle_ohttp(body, target_resource, ohttp))) + .route("/ohttp-keys", get(move || get_ohttp_config(ohttp_config))); + println!("Serverless payjoin relay awaiting HTTP connection on port 8080"); - axum::Server::bind(&"0.0.0.0:8080".parse()?).serve(app.into_make_service()).await?; + axum::Server::bind(&"0.0.0.0:8080".parse()?).serve(ohttp_gateway.into_make_service()).await?; + //hyper::Server::bind(&"0.0.0.0:8080").serve() Ok(()) } @@ -50,6 +58,79 @@ fn init_logging() { println!("Logging initialized"); } +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)).unwrap(); + let encoded_config = server_config.encode().unwrap(); + let b64_config = payjoin::bitcoin::base64::encode_config( + &encoded_config, + payjoin::bitcoin::base64::Config::new( + payjoin::bitcoin::base64::CharacterSet::UrlSafe, + false, + ), + ); + tracing::info!("ohttp server config base64 UrlSafe: {:?}", b64_config); + ohttp::Server::new(server_config).with_context(|| "Failed to initialize ohttp server") +} + +async fn handle_ohttp( + enc_request: Bytes, + mut target: Router, + ohttp: Arc, +) -> (StatusCode, Vec) { + use axum::body::Body; + use http::Uri; + use tower_service::Service; + + // decapsulate + let (bhttp_req, res_ctx) = ohttp.decapsulate(&enc_request).unwrap(); + let mut cursor = std::io::Cursor::new(bhttp_req); + let req = bhttp::Message::read_bhttp(&mut cursor).unwrap(); + // let parsed_request: httparse::Request = httparse::Request::new(&mut vec![]).parse(cursor).unwrap(); + // // handle request + // Request::new + let uri = Uri::builder() + .scheme(req.control().scheme().unwrap()) + .authority(req.control().authority().unwrap()) + .path_and_query(req.control().path().unwrap()) + .build() + .unwrap(); + let body = req.content().to_vec(); + let mut request = Request::builder().uri(uri).method(req.control().method().unwrap()); + for header in req.header().fields() { + request = request.header(header.name(), header.value()) + } + let request = request.body(Body::from(body)).unwrap(); + + let response = target.call(request).await.unwrap(); + + 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.unwrap(); + bhttp_res.write_content(&full_body); + let mut bhttp_bytes = Vec::new(); + bhttp_res.write_bhttp(bhttp::Mode::KnownLength, &mut bhttp_bytes).unwrap(); + let ohttp_res = res_ctx.encapsulate(&bhttp_bytes).unwrap(); + (StatusCode::OK, ohttp_res) +} + +fn ohttp_config(server: &ohttp::Server) -> Result { + use payjoin::bitcoin::base64; + + let b64_config = base64::Config::new(base64::CharacterSet::UrlSafe, false); + let encoded_config = + server.config().encode().with_context(|| "Failed to encode ohttp config")?; + Ok(base64::encode_config(&encoded_config, b64_config)) +} + async fn post_fallback(Path(id): Path, body: Bytes, pool: DbPool) -> (StatusCode, Vec) { let id = shorten_string(&id); let body = body.to_vec(); @@ -70,8 +151,11 @@ async fn post_fallback(Path(id): Path, body: Bytes, pool: DbPool) -> (St } } +async fn get_ohttp_config(config: String) -> (StatusCode, String) { (StatusCode::OK, config) } + async fn get_request(Path(id): Path, pool: DbPool) -> (StatusCode, Vec) { let id = shorten_string(&id); + tracing::debug!("peek request for id: {}", id); match pool.peek_req(&id).await { Some(res) => match res { Ok(buffered_req) => (StatusCode::OK, buffered_req), diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 3d2ed3e9..7158ca5e 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -16,13 +16,16 @@ edition = "2018" send = [] receive = ["rand"] base64 = ["bitcoin/base64"] -v2 = ["bitcoin/rand-std", "chacha20poly1305", "serde", "serde_json"] +v2 = ["bitcoin/rand-std", "chacha20poly1305", "ohttp", "bhttp", "serde", "serde_json"] [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 = { path = "../../ohttp/bhttp", optional = true } +ohttp = { path = "../../ohttp/ohttp", optional = true } rand = { version = "0.8.4", optional = true } serde = { version = "1.0", optional = true } serde_json = { version = "1.0", optional = true } diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index cd39573b..e2c26f04 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -271,7 +271,7 @@ use std::collections::{BTreeMap, HashMap}; use std::str::FromStr; use bitcoin::psbt::Psbt; -use bitcoin::{Amount, FeeRate, OutPoint, Script, TxOut}; +use bitcoin::{base64, secp256k1, Amount, FeeRate, OutPoint, Script, TxOut}; mod error; @@ -280,6 +280,7 @@ use error::{InternalRequestError, InternalSelectionError}; use rand::seq::SliceRandom; use rand::Rng; use serde::Serialize; +use url::Url; use crate::input_type::InputType; use crate::optional_parameters::Params; @@ -290,15 +291,30 @@ pub trait Headers { } #[cfg(feature = "v2")] -pub struct ProposalContext { +pub struct EnrollContext { + relay_url: Url, + ohttp_config: Vec, + ohttp_proxy: Url, s: bitcoin::secp256k1::KeyPair, } -impl ProposalContext { - pub fn new() -> Self { - let secp = bitcoin::secp256k1::Secp256k1::new(); +impl EnrollContext { + 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::parse(ohttp_proxy_url).unwrap(); + let relay_url = Url::parse(relay_url).unwrap(); + let secp = secp256k1::Secp256k1::new(); let (sk, _) = secp.generate_keypair(&mut rand::rngs::OsRng); - ProposalContext { s: bitcoin::secp256k1::KeyPair::from_secret_key(&secp, &sk) } + EnrollContext { + ohttp_config, + ohttp_proxy, + relay_url, + s: secp256k1::KeyPair::from_secret_key(&secp, &sk), + } } pub fn subdirectory(&self) -> String { @@ -313,11 +329,30 @@ impl ProposalContext { format!("{}/{}", self.subdirectory(), crate::v2::RECEIVE) } + pub fn enroll_body(&mut self) -> (Vec, ohttp::ClientResponse) { + let receive_endpoint = self.receive_subdir(); + log::debug!("{}{}", self.relay_url.as_str(), receive_endpoint); + let (ohttp_req, ctx) = crate::v2::ohttp_encapsulate( + &self.ohttp_config, + "GET", + format!("{}{}", self.relay_url.as_str(), receive_endpoint).as_str(), + None, + ); + + (ohttp_req, ctx) + } + pub fn parse_proposal( - self, - encrypted_proposal: &mut [u8], - ) -> Result { - let (proposal, e) = crate::v2::decrypt_message_a(encrypted_proposal, self.s.secret_key()); + &self, + encrypted_proposal: &[u8], + context: ohttp::ClientResponse, + ) -> Result, RequestError> { + let response = crate::v2::ohttp_decapsulate(context, &encrypted_proposal); + if response.is_empty() { + log::debug!("response is empty"); + return Ok(None); + } + let (proposal, e) = crate::v2::decrypt_message_a(&response, self.s.secret_key()); let mut proposal = serde_json::from_slice::(&proposal) .map_err(InternalRequestError::Json)?; proposal.psbt = proposal.psbt.validate().map_err(InternalRequestError::InconsistentPsbt)?; @@ -325,7 +360,7 @@ impl ProposalContext { log::debug!("Received original psbt: {:?}", proposal.psbt); log::debug!("Received request with params: {:?}", proposal.params); - Ok(proposal) + Ok(Some(proposal)) } } @@ -642,14 +677,27 @@ impl PayjoinProposal { pub fn psbt(&self) -> &Psbt { &self.payjoin_psbt } - pub fn serialize_body(&self) -> Vec { - match self.v2_context { - Some(e) => { - let mut payjoin_bytes = self.payjoin_psbt.serialize(); - crate::v2::encrypt_message_b(&mut payjoin_bytes, e) - } - None => bitcoin::base64::encode(self.payjoin_psbt.serialize()).as_bytes().to_vec(), - } + pub fn extract_v1_req(&self) -> String { + bitcoin::base64::encode(self.payjoin_psbt.serialize()) + } + + #[cfg(feature = "v2")] + pub fn extract_v2_req( + &self, + ohttp_config: &str, + receive_endpoint: &str, + ) -> (Vec, ohttp::ClientResponse) { + let e = self.v2_context.unwrap(); // TODO make v2 only + let mut payjoin_bytes = self.payjoin_psbt.serialize(); + let body = crate::v2::encrypt_message_b(&mut payjoin_bytes, e); + let ohttp_config = bitcoin::base64::decode_config(ohttp_config, base64::URL_SAFE).unwrap(); + dbg!(receive_endpoint); + crate::v2::ohttp_encapsulate(&ohttp_config, "POST", receive_endpoint, Some(&body)) + } + + pub fn deserialize_res(&self, res: Vec, ohttp_context: ohttp::ClientResponse) -> Vec { + // display success or failure + crate::v2::ohttp_decapsulate(ohttp_context, &res) } } diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index 8aeebfc5..33e7ea50 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -170,7 +170,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 3b0628fa..edd1aa66 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,65 +327,107 @@ 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 = serialize_psbt(&psbt); - ( - Request { url, body }, - Context { - original_psbt: psbt, - disable_output_substitution, - fee_contribution, - payee, - input_type, - sequence, - min_fee_rate: self.min_fee_rate, - v2_e: None, - }, - ) - }; + Ok(RequestContext { + psbt, + uri: self.uri, + disable_output_substitution, + fee_contribution, + payee, + input_type, + sequence, + min_fee_rate: self.min_fee_rate, + }) + } +} - #[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); - ( - Request { url, body }, - Context { - original_psbt: psbt, - disable_output_substitution, - fee_contribution, - payee, - input_type, - sequence, +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, +} + +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 = serialize_psbt(&self.psbt).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); + let (body, ohttp_res) = crate::v2::ohttp_encapsulate( + &self.uri.extras.ohttp_config.clone().unwrap().encode().unwrap(), + "POST", + &url.as_str(), + Some(&body), + ); + 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, + }, + )) } } @@ -411,7 +453,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. -pub struct Context { +pub struct ContextV1 { original_psbt: Psbt, disable_output_substitution: bool, fee_contribution: Option<(bitcoin::Amount, usize)>, @@ -419,7 +461,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 { @@ -440,23 +488,39 @@ 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 { - 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, &mut res_buf); + let psbt = crate::v2::decrypt_message_b(&mut res_buf, self.e); + if psbt.is_empty() { + return Ok(None); } + let proposal = Psbt::deserialize(&psbt).expect("PSBT deserialization failed"); + 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 { @@ -468,20 +532,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); - let proposal = Psbt::deserialize(&psbt).expect("PSBT deserialization failed"); - // 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)?; @@ -833,7 +883,6 @@ fn determine_fee_contribution( }) } -#[cfg(feature = "v2")] fn serialize_v2_body( psbt: &Psbt, disable_output_substitution: bool, @@ -857,7 +906,6 @@ fn serialize_v2_body( serde_json::to_vec(&body).unwrap() } -#[cfg(not(feature = "v2"))] fn serialize_url( endpoint: String, disable_output_substitution: bool, @@ -896,7 +944,6 @@ fn serialize_minfeerate(min_feerate: FeeRate) -> f32 { #[cfg(test)] mod tests { #[test] - #[cfg(not(feature = "v2"))] fn official_vectors() { use std::str::FromStr; @@ -914,7 +961,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 5d6f4287..b99088ff 100644 --- a/payjoin/src/uri.rs +++ b/payjoin/src/uri.rs @@ -24,6 +24,7 @@ impl Payjoin { pub struct PayjoinParams { pub(crate) _endpoint: Url, pub(crate) disable_output_substitution: bool, + pub(crate) ohttp_config: Option, } pub type Uri<'a, NetworkValidation> = bip21::Uri<'a, NetworkValidation, Payjoin>; @@ -110,6 +111,7 @@ impl<'a> bip21::de::DeserializeParams<'a> for Payjoin { pub struct DeserializationState { pj: Option, pjos: Option, + ohttp: Option, } #[derive(Debug)] @@ -133,6 +135,18 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { ::Error, > { match key { + "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)?; + //let server = ohttp::Server::new(config).map_err(InternalPjParseError::BadOhttp)?; + self.ohttp = Some(config); + Ok(bip21::de::ParamKind::Known) + } + "ohttp" => Err(PjParseError(InternalPjParseError::MultipleParams("ohttp")).into()), "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)?; @@ -181,14 +195,16 @@ impl<'a> bip21::de::DeserializationState<'a> for DeserializationState { 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, None) => Ok(Payjoin::Unsupported), + (None, None, Some(_)) | (None, Some(_), _) => + Err(PjParseError(InternalPjParseError::MissingEndpoint)), + (Some(endpoint), pjos, ohttp) => if endpoint.scheme() == "http" { Ok(Payjoin::Supported(PayjoinParams { _endpoint: endpoint, disable_output_substitution: pjos.unwrap_or(false), + ohttp_config: ohttp, })) } else { Err(PjParseError(InternalPjParseError::UnsecureEndpoint)) @@ -206,7 +222,9 @@ impl std::fmt::Display for PjParseError { } InternalPjParseError::MissingEndpoint => write!(f, "Missing payjoin endpoint"), InternalPjParseError::NotUtf8(_) => write!(f, "Endpoint is not valid UTF-8"), + InternalPjParseError::NotBase64(_) => write!(f, "ohttp config is not valid base64"), InternalPjParseError::BadEndpoint(_) => write!(f, "Endpoint is not valid"), + InternalPjParseError::BadOhttp(_) => write!(f, "ohttp config is not valid"), InternalPjParseError::UnsecureEndpoint => { write!(f, "Endpoint scheme is not secure (https or onion)") } @@ -220,7 +238,9 @@ enum InternalPjParseError { MultipleParams(&'static str), MissingEndpoint, NotUtf8(core::str::Utf8Error), + NotBase64(bitcoin::base64::DecodeError), BadEndpoint(url::ParseError), + BadOhttp(ohttp::Error), UnsecureEndpoint, } diff --git a/payjoin/src/v2.rs b/payjoin/src/v2.rs index ddef89da..0d807e39 100644 --- a/payjoin/src/v2.rs +++ b/payjoin/src/v2.rs @@ -58,7 +58,7 @@ pub fn encrypt_message_a(msg: &[u8], s: PublicKey) -> (Vec, SecretKey) { (message_a, e_sec) } -pub fn decrypt_message_a(message_a: &mut [u8], s: SecretKey) -> (Vec, PublicKey) { +pub fn decrypt_message_a(message_a: &[u8], s: SecretKey) -> (Vec, PublicKey) { // let message a = [pubkey/AD][nonce][authentication tag][ciphertext] let e = PublicKey::from_slice(&message_a[..33]).expect("invalid public key"); log::debug!("e: {:?}", e); @@ -107,3 +107,33 @@ pub fn decrypt_message_b(message_b: &mut Vec, e: SecretKey) -> Vec { let buffer = cipher.decrypt(&nonce, payload).expect("decryption failed"); buffer } + +pub fn ohttp_encapsulate( + ohttp_config: &[u8], + method: &str, + url: &str, + body: Option<&[u8]>, +) -> (Vec, ohttp::ClientResponse) { + let ctx = ohttp::ClientRequest::from_encoded_config(ohttp_config).unwrap(); + let url = url::Url::parse(url).expect("invalid url"); + 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); + ctx.encapsulate(&bhttp_req).expect("encapsulation failed") +} + +/// decapsulate ohttp, bhttp response and return http response body and status code +pub fn ohttp_decapsulate(res_ctx: ohttp::ClientResponse, ohttp_body: &[u8]) -> Vec { + let bhttp_body = res_ctx.decapsulate(ohttp_body).expect("decapsulation failed"); + let mut r = std::io::Cursor::new(bhttp_body); + let response = bhttp::Message::read_bhttp(&mut r).expect("read bhttp failed"); + response.content().to_vec() +}