From 2029eee2e5c323893d348cd84503b639350d4c31 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Fri, 29 May 2026 15:35:01 -0400 Subject: [PATCH 1/2] Add unit and proptest feature flags to scrubber --- crates/scrubber/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/scrubber/Cargo.toml b/crates/scrubber/Cargo.toml index 355c1f96..f44813e8 100644 --- a/crates/scrubber/Cargo.toml +++ b/crates/scrubber/Cargo.toml @@ -3,5 +3,10 @@ name = "scrubber" version.workspace = true edition = "2024" +[features] +default = ["unit-tests", "prop-tests"] +prop-tests = [] +unit-tests = [] + [lints] workspace = true From e0a362e768d3ae2b016f6c8629535a7f46f86320 Mon Sep 17 00:00:00 2001 From: Armin Sabouri Date: Fri, 29 May 2026 10:20:23 -0400 Subject: [PATCH 2/2] Implement scrub() with per-map field filtering Key type bytes overlap across PSBT map types. For example, 0x06 is TX_MODIFIABLE in global but BIP32_DERIVATION in inputs. So filtering requires map context. Buffers the global map to detect version and counts (INPUT_COUNT/ OUTPUT_COUNT for v2, UNSIGNED_TX parsed via Transaction::consensus_decode for v0), then streams remaining maps with per-map allowlists for global, input, and output. Pair::decode is pub(crate) in psbt-v2 0.3.0; worked around with a PairDecode extension trait replicating the upstream logic. --- Cargo.lock | 545 +++++++++++ crates/scrubber/Cargo.toml | 6 + .../scrubber/proptest-regressions/scrub.txt | 8 + crates/scrubber/src/decode.rs | 37 + crates/scrubber/src/fields.rs | 206 +++++ crates/scrubber/src/lib.rs | 7 + crates/scrubber/src/scrub.rs | 861 ++++++++++++++++++ 7 files changed, 1670 insertions(+) create mode 100644 crates/scrubber/proptest-regressions/scrub.txt create mode 100644 crates/scrubber/src/decode.rs create mode 100644 crates/scrubber/src/fields.rs create mode 100644 crates/scrubber/src/scrub.rs diff --git a/Cargo.lock b/Cargo.lock index 0de23660..3f8a0252 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,10 +2,555 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base58ck" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitcoin" +version = "0.32.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin-units" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "concurrent-psbt" version = "0.1.0" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "miniscript" +version = "12.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8343cc1ef1408bd9bdbf69f7aef47017dfab7e6349ec26fddf62e0e9fb5a4cf" +dependencies = [ + "bech32", + "bitcoin", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "psbt-v2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e075616cae99e29d70960a6374c2757dff4dd6b646dd0398ce1012666231c7aa" +dependencies = [ + "bitcoin", + "miniscript", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "scrubber" version = "0.1.0" +dependencies = [ + "proptest", + "psbt-v2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/crates/scrubber/Cargo.toml b/crates/scrubber/Cargo.toml index f44813e8..18660975 100644 --- a/crates/scrubber/Cargo.toml +++ b/crates/scrubber/Cargo.toml @@ -8,5 +8,11 @@ default = ["unit-tests", "prop-tests"] prop-tests = [] unit-tests = [] +[dependencies] +psbt-v2 = "0.3.0" + +[dev-dependencies] +proptest = "1" + [lints] workspace = true diff --git a/crates/scrubber/proptest-regressions/scrub.txt b/crates/scrubber/proptest-regressions/scrub.txt new file mode 100644 index 00000000..8bd9fada --- /dev/null +++ b/crates/scrubber/proptest-regressions/scrub.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc a882f0e0857f83b34929c985f37305d1c5044a1c3edcde6e940d64c1ca3c950c # shrinks to value = [], insensitive_type = 2 +cc 5f6e8c43a00a405dac8e0885e131f7cee115948a86cb5293b75a7ab57c67fb07 # shrinks to psbt = [112, 115, 98, 116, 255, 1, 251, 4, 2, 0, 0, 0, 1, 2, 4, 2, 0, 0, 0, 1, 4, 1, 0, 1, 5, 1, 0, 0], sensitive_type = 1 diff --git a/crates/scrubber/src/decode.rs b/crates/scrubber/src/decode.rs new file mode 100644 index 00000000..a9157024 --- /dev/null +++ b/crates/scrubber/src/decode.rs @@ -0,0 +1,37 @@ +use psbt_v2::bitcoin::consensus::encode::{Decodable, MAX_VEC_SIZE, VarInt}; +use psbt_v2::raw::{Key, Pair}; + +use crate::scrub::Error; + +// Workaround: Pair::decode and Key::decode are pub(crate) in psbt-v2 0.3.0. +// This replicates the upstream logic exactly using the same bitcoin primitives. +// This can be removed once / if the visibility becomes more permissible upstream +pub(crate) trait PairDecode: Sized { + fn decode(input: &mut &[u8]) -> Result, Error>; +} + +impl PairDecode for Pair { + fn decode(input: &mut &[u8]) -> Result, Error> { + let VarInt(byte_size) = + Decodable::consensus_decode(input).map_err(|_| Error::UnexpectedEof)?; + if byte_size == 0 { + return Ok(None); + } + let key_byte_size = byte_size - 1; + if key_byte_size > MAX_VEC_SIZE as u64 { + return Err(Error::OversizedKey); + } + let type_value: u8 = + Decodable::consensus_decode(input).map_err(|_| Error::UnexpectedEof)?; + let mut key = Vec::with_capacity(key_byte_size as usize); + for _ in 0..key_byte_size { + key.push(Decodable::consensus_decode(input).map_err(|_| Error::UnexpectedEof)?); + } + let value: Vec = + Decodable::consensus_decode(input).map_err(|_| Error::UnexpectedEof)?; + Ok(Some(Pair { + key: Key { type_value, key }, + value, + })) + } +} diff --git a/crates/scrubber/src/fields.rs b/crates/scrubber/src/fields.rs new file mode 100644 index 00000000..be926629 --- /dev/null +++ b/crates/scrubber/src/fields.rs @@ -0,0 +1,206 @@ +/// PSBT_GLOBAL_VERSION value identifying a v2 PSBT. +pub(crate) const PSBT_V2: u8 = 2; + +/// Global map key types retained when scrubbing (non-sensitive). +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GlobalInsensitive { + UnsignedTx = 0x00, + TxVersion = 0x02, + FallbackLocktime = 0x03, + InputCount = 0x04, + OutputCount = 0x05, + TxModifiable = 0x06, + Version = 0xFB, +} + +impl TryFrom for GlobalInsensitive { + type Error = (); + + fn try_from(v: u8) -> Result { + match v { + x if x == Self::UnsignedTx as u8 => Ok(Self::UnsignedTx), + x if x == Self::TxVersion as u8 => Ok(Self::TxVersion), + x if x == Self::FallbackLocktime as u8 => Ok(Self::FallbackLocktime), + x if x == Self::InputCount as u8 => Ok(Self::InputCount), + x if x == Self::OutputCount as u8 => Ok(Self::OutputCount), + x if x == Self::TxModifiable as u8 => Ok(Self::TxModifiable), + x if x == Self::Version as u8 => Ok(Self::Version), + _ => Err(()), + } + } +} + +impl GlobalInsensitive { + pub(crate) fn contains(v: u8) -> bool { + Self::try_from(v).is_ok() + } +} + +/// Input map key types retained when scrubbing (non-sensitive). +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InputInsensitive { + NonWitnessUtxo = 0x00, + WitnessUtxo = 0x01, + SighashType = 0x03, + RedeemScript = 0x04, + WitnessScript = 0x05, + FinalScriptsig = 0x07, + FinalScriptwitness = 0x08, + PreviousTxid = 0x0e, + OutputIndex = 0x0f, + Sequence = 0x10, + RequiredTimeLocktime = 0x11, + RequiredHeightLocktime = 0x12, + TapKeySig = 0x13, + TapScriptSig = 0x14, + TapLeafScript = 0x15, +} + +impl TryFrom for InputInsensitive { + type Error = (); + + fn try_from(v: u8) -> Result { + match v { + x if x == Self::NonWitnessUtxo as u8 => Ok(Self::NonWitnessUtxo), + x if x == Self::WitnessUtxo as u8 => Ok(Self::WitnessUtxo), + x if x == Self::SighashType as u8 => Ok(Self::SighashType), + x if x == Self::RedeemScript as u8 => Ok(Self::RedeemScript), + x if x == Self::WitnessScript as u8 => Ok(Self::WitnessScript), + x if x == Self::FinalScriptsig as u8 => Ok(Self::FinalScriptsig), + x if x == Self::FinalScriptwitness as u8 => Ok(Self::FinalScriptwitness), + x if x == Self::PreviousTxid as u8 => Ok(Self::PreviousTxid), + x if x == Self::OutputIndex as u8 => Ok(Self::OutputIndex), + x if x == Self::Sequence as u8 => Ok(Self::Sequence), + x if x == Self::RequiredTimeLocktime as u8 => Ok(Self::RequiredTimeLocktime), + x if x == Self::RequiredHeightLocktime as u8 => Ok(Self::RequiredHeightLocktime), + x if x == Self::TapKeySig as u8 => Ok(Self::TapKeySig), + x if x == Self::TapScriptSig as u8 => Ok(Self::TapScriptSig), + x if x == Self::TapLeafScript as u8 => Ok(Self::TapLeafScript), + _ => Err(()), + } + } +} + +impl InputInsensitive { + pub(crate) fn contains(v: u8) -> bool { + Self::try_from(v).is_ok() + } +} + +/// Output map key types retained when scrubbing (non-sensitive). +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OutputInsensitive { + Amount = 0x03, + Script = 0x04, +} + +impl TryFrom for OutputInsensitive { + type Error = (); + + fn try_from(v: u8) -> Result { + match v { + x if x == Self::Amount as u8 => Ok(Self::Amount), + x if x == Self::Script as u8 => Ok(Self::Script), + _ => Err(()), + } + } +} + +impl OutputInsensitive { + pub(crate) fn contains(v: u8) -> bool { + Self::try_from(v).is_ok() + } +} + +#[cfg(any(test, feature = "unit-tests"))] +mod tests { + // The `#[test]` functions are stripped from non-test builds, leaving the tables unused. + #![allow(dead_code)] + use super::*; + + /// The retained key types, listed exhaustively so that dropping a filter entry fails + /// here. Over-wide filters are covered from the other side, by the scrub tests that + /// assert sensitive pairs are absent from the output. + const GLOBALS: [GlobalInsensitive; 7] = { + use GlobalInsensitive::*; + [ + UnsignedTx, + TxVersion, + FallbackLocktime, + InputCount, + OutputCount, + TxModifiable, + Version, + ] + }; + + const INPUTS: [InputInsensitive; 15] = { + use InputInsensitive::*; + [ + NonWitnessUtxo, + WitnessUtxo, + SighashType, + RedeemScript, + WitnessScript, + FinalScriptsig, + FinalScriptwitness, + PreviousTxid, + OutputIndex, + Sequence, + RequiredTimeLocktime, + RequiredHeightLocktime, + TapKeySig, + TapScriptSig, + TapLeafScript, + ] + }; + + const OUTPUTS: [OutputInsensitive; 2] = [OutputInsensitive::Amount, OutputInsensitive::Script]; + + #[test] + fn every_insensitive_global_is_accepted() { + for field in GLOBALS { + let type_value = field as u8; + assert!( + GlobalInsensitive::contains(type_value), + "global type {type_value:#04x}" + ); + } + } + + #[test] + fn every_insensitive_input_is_accepted() { + for field in INPUTS { + let type_value = field as u8; + assert!( + InputInsensitive::contains(type_value), + "input type {type_value:#04x}" + ); + } + } + + #[test] + fn every_insensitive_output_is_accepted() { + for field in OUTPUTS { + let type_value = field as u8; + assert!( + OutputInsensitive::contains(type_value), + "output type {type_value:#04x}" + ); + } + } + + /// Sensitive types that must never pass a filter, spelled out by their BIP-174 names. + #[test] + fn known_sensitive_types_are_excluded() { + assert!(!GlobalInsensitive::contains(0x01)); // PSBT_GLOBAL_XPUB + assert!(!InputInsensitive::contains(0x02)); // PSBT_IN_PARTIAL_SIG + assert!(!InputInsensitive::contains(0x06)); // PSBT_IN_BIP32_DERIVATION + assert!(!InputInsensitive::contains(0x16)); // PSBT_IN_TAP_BIP32_DERIVATION + assert!(!OutputInsensitive::contains(0x02)); // PSBT_OUT_BIP32_DERIVATION + assert!(!OutputInsensitive::contains(0xFC)); // PSBT_OUT_PROPRIETARY + } +} diff --git a/crates/scrubber/src/lib.rs b/crates/scrubber/src/lib.rs index 45278f22..be72854c 100644 --- a/crates/scrubber/src/lib.rs +++ b/crates/scrubber/src/lib.rs @@ -1 +1,8 @@ #![forbid(unsafe_code)] +#![allow(unused_features)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + +mod decode; +mod fields; +pub mod scrub; +pub use scrub::{Error, scrub}; diff --git a/crates/scrubber/src/scrub.rs b/crates/scrubber/src/scrub.rs new file mode 100644 index 00000000..ab40a9f3 --- /dev/null +++ b/crates/scrubber/src/scrub.rs @@ -0,0 +1,861 @@ +use psbt_v2::bitcoin::Transaction; +use psbt_v2::bitcoin::consensus::encode::{VarInt, deserialize, serialize}; +use psbt_v2::raw::Pair; + +use crate::decode::PairDecode; +use crate::fields::{GlobalInsensitive, InputInsensitive, OutputInsensitive, PSBT_V2}; + +/// PSBT magic bytes: "psbt\xff" +const MAGIC: [u8; 5] = [0x70, 0x73, 0x62, 0x74, 0xff]; + +/// Scrub a PSBT, retaining only non-sensitive fields safe to share with untrusted peers. +/// +/// Buffers the global map to detect version and input/output counts, then streams +/// the remaining maps applying per-map-type filters. Both PSBT v0 and v2 are supported. +pub fn scrub(psbt: &[u8]) -> Result, Error> { + if psbt.get(..5) != Some(&MAGIC) { + return Err(Error::InvalidMagic); + } + let mut r = &psbt[5..]; + let mut out = Vec::with_capacity(psbt.len()); + out.extend_from_slice(&MAGIC); + + // Buffer the global map to detect version and input/output counts before streaming the rest. + let mut global: Vec = Vec::new(); + while let Some(pair) = Pair::decode(&mut r)? { + global.push(pair); + } + + let (n_inputs, n_outputs) = get_number_of_inputs_and_outputs(&global)?; + for pair in &global { + if GlobalInsensitive::contains(pair.key.type_value) { + encode_pair(&mut out, pair); + } + } + out.push(0x00); + + for _ in 0..n_inputs { + while let Some(pair) = Pair::decode(&mut r)? { + if InputInsensitive::contains(pair.key.type_value) { + encode_pair(&mut out, &pair); + } + } + out.push(0x00); + } + + for _ in 0..n_outputs { + while let Some(pair) = Pair::decode(&mut r)? { + if OutputInsensitive::contains(pair.key.type_value) { + encode_pair(&mut out, &pair); + } + } + out.push(0x00); + } + + if !r.is_empty() { + return Err(Error::UnexpectedTrailingBytes); + } + + Ok(out) +} + +/// Errors that can occur while scrubbing a PSBT. +#[derive(Debug, PartialEq)] +pub enum Error { + InvalidMagic, + UnexpectedEof, + InvalidGlobal, + /// A pair declared a key longer than `MAX_VEC_SIZE`, which would force an + /// oversized allocation before the key could be read. + OversizedKey, + UnexpectedTrailingBytes, +} + +impl std::fmt::Display for Error { + // Excluded from coverage so the prop-test-only run is not required to format every variant; + // the messages are asserted in the unit tests. + #[cfg_attr(coverage_nightly, coverage(off))] + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::InvalidMagic => write!(f, "invalid PSBT magic bytes"), + Error::UnexpectedEof => write!(f, "unexpected end of input"), + Error::InvalidGlobal => write!(f, "invalid or missing global map fields"), + Error::OversizedKey => write!(f, "key exceeds maximum allowed size"), + Error::UnexpectedTrailingBytes => write!(f, "unexpected trailing bytes"), + } + } +} + +impl std::error::Error for Error {} + +fn get_number_of_inputs_and_outputs(global: &[Pair]) -> Result<(u64, u64), Error> { + let is_v2 = global + .iter() + .find(|p| p.key.type_value == GlobalInsensitive::Version as u8 && p.key.key.is_empty()) + .and_then(|p| p.value.first().copied()) + .map(|v| v == PSBT_V2) + .unwrap_or(false); + + if is_v2 { + let n_in = global + .iter() + .find(|p| { + p.key.type_value == GlobalInsensitive::InputCount as u8 && p.key.key.is_empty() + }) + .and_then(|p| deserialize(&p.value).ok()) + .map(|VarInt(n)| n) + .ok_or(Error::InvalidGlobal)?; + let n_out = global + .iter() + .find(|p| { + p.key.type_value == GlobalInsensitive::OutputCount as u8 && p.key.key.is_empty() + }) + .and_then(|p| deserialize(&p.value).ok()) + .map(|VarInt(n)| n) + .ok_or(Error::InvalidGlobal)?; + return Ok((n_in, n_out)); + } + let tx_bytes = global + .iter() + .find(|p| p.key.type_value == GlobalInsensitive::UnsignedTx as u8 && p.key.key.is_empty()) + .map(|p| p.value.as_slice()) + .ok_or(Error::InvalidGlobal)?; + let tx: Transaction = deserialize(tx_bytes).map_err(|_| Error::InvalidGlobal)?; + Ok((tx.input.len() as u64, tx.output.len() as u64)) +} + +fn encode_pair(out: &mut Vec, pair: &Pair) { + out.extend_from_slice(&serialize(&VarInt::from(pair.key.key.len() + 1))); + out.extend_from_slice(&serialize(&pair.key.type_value)); + out.extend_from_slice(&pair.key.key); + out.extend_from_slice(&serialize(&VarInt::from(pair.value.len() as u64))); + out.extend_from_slice(&pair.value); +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "unit-tests")] + mod unit { + #![allow(dead_code)] + use super::super::*; + use psbt_v2::raw::Key; + + /// Longest key a pair may declare before `decode` rejects it outright. + const MAX_KEY_LEN: u64 = psbt_v2::bitcoin::consensus::encode::MAX_VEC_SIZE as u64; + + fn kv(type_value: u8, key_suffix: &[u8], val: &[u8]) -> Vec { + let pair = Pair { + key: Key { + type_value, + key: key_suffix.to_vec(), + }, + value: val.to_vec(), + }; + let mut buf = Vec::new(); + encode_pair(&mut buf, &pair); + buf + } + + fn kv_global(key: GlobalInsensitive, val: &[u8]) -> Vec { + kv(key as u8, &[], val) + } + + fn kv_input(key: InputInsensitive, key_suffix: &[u8], val: &[u8]) -> Vec { + kv(key as u8, key_suffix, val) + } + + fn kv_output(key: OutputInsensitive, val: &[u8]) -> Vec { + kv(key as u8, &[], val) + } + + fn v2_global(input_count: u8, output_count: u8, extra: &[Vec]) -> Vec { + let mut map = Vec::new(); + map.extend(kv_global(GlobalInsensitive::Version, &[2, 0, 0, 0])); + map.extend(kv_global(GlobalInsensitive::TxVersion, &[2, 0, 0, 0])); + map.extend(kv_global(GlobalInsensitive::InputCount, &[input_count])); + map.extend(kv_global(GlobalInsensitive::OutputCount, &[output_count])); + for e in extra { + map.extend(e); + } + map.push(0x00); + map + } + + fn v2_psbt( + input_count: u8, + output_count: u8, + global_extra: &[Vec], + maps: &[Vec], + ) -> Vec { + let mut buf = MAGIC.to_vec(); + buf.extend(v2_global(input_count, output_count, global_extra)); + for m in maps { + buf.extend(m); + } + buf + } + + fn dummy_tx(input_count: u8, output_count: u8) -> Vec { + let mut tx = Vec::new(); + tx.extend_from_slice(&1u32.to_le_bytes()); + tx.push(input_count); + for _ in 0..input_count { + tx.extend_from_slice(&[0u8; 32]); + tx.extend_from_slice(&0u32.to_le_bytes()); + tx.push(0x00); + tx.extend_from_slice(&u32::MAX.to_le_bytes()); + } + tx.push(output_count); + for _ in 0..output_count { + tx.extend_from_slice(&1000u64.to_le_bytes()); + tx.push(0x00); + } + tx.extend_from_slice(&0u32.to_le_bytes()); + tx + } + + #[test] + fn scrub_empty_v2_roundtrip() { + let psbt = v2_psbt(0, 0, &[], &[]); + assert_eq!(scrub(&psbt).unwrap(), psbt); + } + + #[test] + fn invalid_global_v0_invalid_tx() { + // v0 PSBT with invalid transaction data + let mut psbt = MAGIC.to_vec(); + psbt.extend(kv_global(GlobalInsensitive::UnsignedTx, &[0xFF, 0xFF])); + psbt.push(0x00); + assert_eq!(scrub(&psbt), Err(Error::InvalidGlobal)); + } + + /// A PSBT whose first pair declares a key of `key_len` bytes but supplies none of them. + fn psbt_with_declared_key_len(key_len: u64) -> Vec { + let mut psbt = MAGIC.to_vec(); + // A pair's leading VarInt covers the type byte plus the key bytes. + psbt.extend(serialize(&VarInt::from(key_len + 1))); + psbt + } + + #[test] + fn oversized_key_rejected_before_allocating() { + // One byte past the limit: rejected on the declared length alone, without + // reading (or allocating) the key. + let psbt = psbt_with_declared_key_len(MAX_KEY_LEN + 1); + assert_eq!(scrub(&psbt), Err(Error::OversizedKey)); + } + + #[test] + fn key_at_size_limit_is_accepted() { + // Exactly at the limit the length check must pass, so decoding proceeds and + // fails on the missing key bytes instead. + let psbt = psbt_with_declared_key_len(MAX_KEY_LEN); + assert_eq!(scrub(&psbt), Err(Error::UnexpectedEof)); + } + + #[test] + fn truncated_varint_is_not_an_oversized_key() { + // 0xFF introduces an 8-byte VarInt, but only four bytes follow. + let mut psbt = MAGIC.to_vec(); + psbt.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(scrub(&psbt), Err(Error::UnexpectedEof)); + } + + #[test] + fn error_messages() { + assert_eq!(Error::InvalidMagic.to_string(), "invalid PSBT magic bytes"); + assert_eq!(Error::UnexpectedEof.to_string(), "unexpected end of input"); + assert_eq!( + Error::InvalidGlobal.to_string(), + "invalid or missing global map fields" + ); + assert_eq!( + Error::OversizedKey.to_string(), + "key exceeds maximum allowed size" + ); + assert_eq!( + Error::UnexpectedTrailingBytes.to_string(), + "unexpected trailing bytes" + ); + } + + #[test] + fn invalid_pair_value_truncated() { + // Pair with VarInt-encoded value size but missing value data + let mut psbt = MAGIC.to_vec(); + psbt.extend(v2_global(1, 1, &[])); + psbt.push(0x00); // End global + psbt.push(0x05); // VarInt key size + psbt.push(InputInsensitive::WitnessUtxo as u8); + // Missing key data and value should trigger UnexpectedEof + assert_eq!(scrub(&psbt), Err(Error::UnexpectedEof)); + } + + #[test] + fn scrub_input_with_multiple_maps() { + let witness_utxo = kv_input(InputInsensitive::WitnessUtxo, &[], &[0xAA]); + let amount = kv_output( + OutputInsensitive::Amount, + &[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ); + + let mut input = Vec::new(); + for _ in 0..2 { + input.extend(&witness_utxo); + // BIP32_DERIVATION (sensitive) + input.extend(&kv(0x06, &[0x02, 0x03], &[0xFF])); + input.push(0x00); + } + + let mut output = Vec::new(); + for _ in 0..2 { + output.extend(&amount); + // PROPRIETARY (sensitive) + output.extend(&kv(0xFC, &[0x01], &[0xFF])); + output.push(0x00); + } + + let psbt = v2_psbt(2, 2, &[], &[input, output.clone()]); + let result = scrub(&psbt).unwrap(); + + let mut expected_input = Vec::new(); + for _ in 0..2 { + expected_input.extend(&witness_utxo); + expected_input.push(0x00); + } + + let mut expected_output = Vec::new(); + for _ in 0..2 { + expected_output.extend(&amount); + expected_output.push(0x00); + } + + let expected = v2_psbt(2, 2, &[], &[expected_input, expected_output]); + assert_eq!(result, expected); + } + + #[test] + fn scrub_v2_tx_not_modifiable_strips_sensitive_fields() { + // PSBT_GLOBAL_TX_MODIFIABLE = 0x06 set to 0. tx not modifiable, scrubbing still applies. + let tx_not_modifiable = kv_global(GlobalInsensitive::TxModifiable, &[0x00]); + let witness_utxo = kv_input(InputInsensitive::WitnessUtxo, &[], &[0xAA]); + let amount = kv_output( + OutputInsensitive::Amount, + &[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + ); + + // POR_COMMITMENT (sensitive global) + let sensitive_global = kv(0x09, &[], &[0xDE, 0xAD]); + + let mut input_map = Vec::new(); + input_map.extend(&witness_utxo); + // BIP32_DERIVATION (sensitive input) + input_map.extend(&kv(0x06, &[0x02, 0x03], &[0xFF])); + input_map.push(0x00); + + let mut output_map = Vec::new(); + output_map.extend(&amount); + // PROPRIETARY (sensitive output) + output_map.extend(&kv(0xFC, &[0x01], &[0xFF])); + output_map.push(0x00); + + let psbt = v2_psbt( + 1, + 1, + &[sensitive_global, tx_not_modifiable.clone()], + &[input_map, output_map], + ); + let result = scrub(&psbt).unwrap(); + + let mut expected_input = Vec::new(); + expected_input.extend(&witness_utxo); + expected_input.push(0x00); + + let mut expected_output = Vec::new(); + expected_output.extend(&amount); + expected_output.push(0x00); + + let expected = v2_psbt( + 1, + 1, + &[tx_not_modifiable], + &[expected_input, expected_output], + ); + assert_eq!(result, expected); + } + + #[test] + fn scrub_v0() { + let tx = dummy_tx(1, 1); + let unsigned_tx = kv_global(GlobalInsensitive::UnsignedTx, &tx); + // POR_COMMITMENT (sensitive) + let sensitive_global = kv(0x09, &[], &[0xDE, 0xAD]); + + let mut global = Vec::new(); + global.extend(&unsigned_tx); + global.extend(&sensitive_global); + global.push(0x00); + + let witness_utxo = kv_input(InputInsensitive::WitnessUtxo, &[], &[0xAA]); + // TAP_BIP32_DERIVATION (sensitive) + let tap_bip32_input = kv(0x06, &[0x02, 0x03], &[0xFF]); + + let mut input_map = Vec::new(); + input_map.extend(&witness_utxo); + input_map.extend(&tap_bip32_input); + input_map.push(0x00); + + // unknown output key type (sensitive) + let mut output_map = kv(0x17, &[], &[0xCC]); + + output_map.push(0x00); + + let mut psbt = MAGIC.to_vec(); + psbt.extend(&global); + psbt.extend(&input_map); + psbt.extend(&output_map); + + let result = scrub(&psbt).unwrap(); + + let mut expected_global = Vec::new(); + expected_global.extend(&unsigned_tx); + expected_global.push(0x00); + let mut expected_input = Vec::new(); + expected_input.extend(&witness_utxo); + expected_input.push(0x00); + + let mut expected = MAGIC.to_vec(); + expected.extend(&expected_global); + expected.extend(&expected_input); + expected.extend(vec![0x00]); + + assert_eq!(result, expected); + } + + #[test] + fn v2_detected_when_another_field_precedes_version() { + // Version detection must key off the VERSION field specifically, not merely the + // first global pair with an empty key suffix. TX_VERSION is 1 here so mistaking it + // for VERSION would read as v0. + let mut global = Vec::new(); + global.extend(kv_global(GlobalInsensitive::TxVersion, &[1, 0, 0, 0])); + global.extend(kv_global(GlobalInsensitive::Version, &[2, 0, 0, 0])); + global.extend(kv_global(GlobalInsensitive::InputCount, &[0])); + global.extend(kv_global(GlobalInsensitive::OutputCount, &[0])); + global.push(0x00); + + let mut psbt = MAGIC.to_vec(); + psbt.extend(&global); + // Every global here is insensitive, so scrubbing is a no-op. + assert_eq!(scrub(&psbt).unwrap(), psbt); + } + + #[test] + fn v0_unsigned_tx_found_after_another_global() { + // Same for UNSIGNED_TX: an explicit VERSION=0 pair precedes it, and picking that + // pair's value as the transaction would fail to deserialize. + let tx = dummy_tx(1, 1); + let unsigned_tx = kv_global(GlobalInsensitive::UnsignedTx, &tx); + let version = kv_global(GlobalInsensitive::Version, &[0, 0, 0, 0]); + + let mut psbt = MAGIC.to_vec(); + psbt.extend(&version); + psbt.extend(&unsigned_tx); + psbt.push(0x00); + psbt.push(0x00); // empty input map + psbt.push(0x00); // empty output map + + assert_eq!(scrub(&psbt).unwrap(), psbt); + } + + #[test] + fn invalid_magic() { + assert_eq!(scrub(b"not a psbt"), Err(Error::InvalidMagic)); + } + + #[test] + fn unexpected_eof_truncated_after_magic() { + assert_eq!(scrub(&MAGIC), Err(Error::UnexpectedEof)); + } + + #[test] + fn unexpected_eof_truncated_mid_map() { + let mut psbt = MAGIC.to_vec(); + psbt.push(0x05); // key length = 5 but no data follows + assert_eq!(scrub(&psbt), Err(Error::UnexpectedEof)); + } + + #[test] + fn unexpected_eof_trailing_bytes() { + // A complete PSBT followed by leftover bytes must be rejected. + let mut psbt = v2_psbt(0, 0, &[], &[]); + psbt.push(0xFF); + assert_eq!(scrub(&psbt), Err(Error::UnexpectedTrailingBytes)); + } + + #[test] + fn invalid_global_v2_missing_counts() { + // VERSION present and v2, but INPUT_COUNT and OUTPUT_COUNT absent. + let mut psbt = MAGIC.to_vec(); + psbt.extend(kv_global(GlobalInsensitive::Version, &[2, 0, 0, 0])); + psbt.push(0x00); + assert_eq!(scrub(&psbt), Err(Error::InvalidGlobal)); + } + + #[test] + fn invalid_global_v0_missing_unsigned_tx() { + // v0 PSBT with no UNSIGNED_TX field. + let mut psbt = MAGIC.to_vec(); + // POR_COMMITMENT (sensitive), not UNSIGNED_TX + psbt.extend(kv(0x09, &[], &[0xDE, 0xAD])); + psbt.push(0x00); + assert_eq!(scrub(&psbt), Err(Error::InvalidGlobal)); + } + } + + #[cfg(feature = "prop-tests")] + mod prop { + use super::super::*; + use proptest::prelude::*; + use psbt_v2::raw::Key; + + /// Longest key a pair may declare before `decode` rejects it outright. + const MAX_KEY_LEN: u64 = psbt_v2::bitcoin::consensus::encode::MAX_VEC_SIZE as u64; + + fn arb_value() -> impl Strategy> { + proptest::collection::vec(any::(), 0..=64) + } + + fn encoded_pair(type_value: u8, key: Vec, value: Vec) -> Vec { + let pair = Pair { + key: Key { type_value, key }, + value, + }; + let mut buf = Vec::new(); + encode_pair(&mut buf, &pair); + buf + } + + fn encoded_global(key: GlobalInsensitive, value: Vec) -> Vec { + encoded_pair(key as u8, vec![], value) + } + + fn encoded_input(key: InputInsensitive, key_suffix: Vec, value: Vec) -> Vec { + encoded_pair(key as u8, key_suffix, value) + } + + fn encoded_output(key: OutputInsensitive, key_suffix: Vec, value: Vec) -> Vec { + encoded_pair(key as u8, key_suffix, value) + } + + #[derive(Clone, Copy, Debug)] + enum InsensitiveField { + Global(GlobalInsensitive), + Input(InputInsensitive), + Output(OutputInsensitive), + } + + fn arb_global_insensitive() -> impl Strategy { + use GlobalInsensitive::*; + proptest::sample::select( + [ + UnsignedTx, + TxVersion, + FallbackLocktime, + InputCount, + OutputCount, + TxModifiable, + Version, + ] + .to_vec(), + ) + } + + fn arb_input_insensitive() -> impl Strategy { + use InputInsensitive::*; + proptest::sample::select( + [ + NonWitnessUtxo, + WitnessUtxo, + SighashType, + RedeemScript, + WitnessScript, + FinalScriptsig, + FinalScriptwitness, + PreviousTxid, + OutputIndex, + Sequence, + RequiredTimeLocktime, + RequiredHeightLocktime, + TapKeySig, + TapScriptSig, + TapLeafScript, + ] + .to_vec(), + ) + } + + fn arb_output_insensitive() -> impl Strategy { + use OutputInsensitive::*; + proptest::sample::select([Amount, Script].to_vec()) + } + + fn arb_insensitive_field() -> impl Strategy { + prop_oneof![ + arb_global_insensitive().prop_map(InsensitiveField::Global), + arb_input_insensitive().prop_map(InsensitiveField::Input), + arb_output_insensitive().prop_map(InsensitiveField::Output), + ] + } + + fn encode_insensitive(field: InsensitiveField, value: Vec) -> Vec { + match field { + InsensitiveField::Global(key) => encoded_global(key, value), + InsensitiveField::Input(key) => encoded_input(key, vec![], value), + InsensitiveField::Output(key) => encoded_output(key, vec![], value), + } + } + + fn build_psbt_with_insensitive(field: InsensitiveField, pair: &[u8]) -> Vec { + let mut test_psbt = MAGIC.to_vec(); + let (n_inputs, n_outputs) = match field { + InsensitiveField::Global(_) => (0, 0), + InsensitiveField::Input(_) => (1, 0), + InsensitiveField::Output(_) => (0, 1), + }; + let extra_global = match field { + InsensitiveField::Global(_) => &[pair][..], + _ => &[], + }; + append_v2_global_fields(&mut test_psbt, n_inputs, n_outputs, extra_global); + if !matches!(field, InsensitiveField::Global(_)) { + test_psbt.extend_from_slice(pair); + test_psbt.push(0x00); + } + test_psbt + } + + fn arb_pair() -> impl Strategy> { + (any::(), arb_value()).prop_map(|(t, v)| encoded_pair(t, vec![], v)) + } + + fn arb_map() -> impl Strategy> { + proptest::collection::vec(arb_pair(), 0..=4).prop_map(|pairs| { + let mut map: Vec = pairs.into_iter().flatten().collect(); + map.push(0x00); + map + }) + } + + fn append_v2_global_fields( + psbt: &mut Vec, + n_inputs: u8, + n_outputs: u8, + extra: &[&[u8]], + ) { + psbt.extend(encoded_global(GlobalInsensitive::Version, vec![2, 0, 0, 0])); + psbt.extend(encoded_global( + GlobalInsensitive::TxVersion, + vec![2, 0, 0, 0], + )); + psbt.extend(encoded_global( + GlobalInsensitive::InputCount, + vec![n_inputs], + )); + psbt.extend(encoded_global( + GlobalInsensitive::OutputCount, + vec![n_outputs], + )); + for field in extra { + psbt.extend_from_slice(field); + } + psbt.push(0x00); + } + + /// A minimal but consensus-valid transaction with the requested input and output counts, + /// as carried by a v0 PSBT's `UNSIGNED_TX` global. + fn unsigned_tx(n_inputs: u8, n_outputs: u8) -> Vec { + let mut tx = Vec::new(); + tx.extend_from_slice(&1u32.to_le_bytes()); // version + tx.push(n_inputs); + for _ in 0..n_inputs { + tx.extend_from_slice(&[0u8; 32]); // previous txid + tx.extend_from_slice(&0u32.to_le_bytes()); // previous vout + tx.push(0x00); // empty script_sig + tx.extend_from_slice(&u32::MAX.to_le_bytes()); // sequence + } + tx.push(n_outputs); + for _ in 0..n_outputs { + tx.extend_from_slice(&1000u64.to_le_bytes()); // amount + tx.push(0x00); // empty script_pubkey + } + tx.extend_from_slice(&0u32.to_le_bytes()); // locktime + tx + } + + prop_compose! { + /// A v0 PSBT: no VERSION global, so the counts come from `UNSIGNED_TX`. + fn arb_v0_psbt()( + n_inputs in 1u8..=3, + n_outputs in 0u8..=3, + )( + input_maps in proptest::collection::vec(arb_map(), n_inputs as usize), + output_maps in proptest::collection::vec(arb_map(), n_outputs as usize), + n_inputs in Just(n_inputs), + n_outputs in Just(n_outputs), + ) -> Vec { + // No arbitrary extra globals here: a generated VERSION pair would switch the + // PSBT to v2 and change which branch is under test. + let mut psbt = MAGIC.to_vec(); + psbt.extend(encoded_global( + GlobalInsensitive::UnsignedTx, + unsigned_tx(n_inputs, n_outputs), + )); + psbt.push(0x00); + for map in input_maps { psbt.extend(map); } + for map in output_maps { psbt.extend(map); } + psbt + } + } + + prop_compose! { + fn arb_v2_psbt()( + n_inputs in 0u8..=3, + n_outputs in 0u8..=3, + )( + extra_global in arb_map().prop_map(|m| m[..m.len()-1].to_vec()), + input_maps in proptest::collection::vec(arb_map(), n_inputs as usize), + output_maps in proptest::collection::vec(arb_map(), n_outputs as usize), + n_inputs in Just(n_inputs), + n_outputs in Just(n_outputs), + ) -> Vec { + let mut psbt = MAGIC.to_vec(); + append_v2_global_fields( + &mut psbt, + n_inputs, + n_outputs, + &[&extra_global], + ); + for map in input_maps { psbt.extend(map); } + for map in output_maps { psbt.extend(map); } + psbt + } + } + + proptest! { + #[test] + fn idempotent(psbt in arb_v2_psbt()) { + if let Ok(once) = scrub(&psbt) { + let twice = scrub(&once).expect("scrub of scrubbed output must succeed"); + prop_assert_eq!(once, twice); + } + } + + #[test] + fn output_is_valid_psbt(psbt in arb_v2_psbt()) { + if let Ok(scrubbed) = scrub(&psbt) { + prop_assert!(scrub(&scrubbed).is_ok()); + } + } + + #[test] + fn sensitive_fields_absent_from_output( + sensitive_type in proptest::sample::select(vec![ + 0x02u8, // PARTIAL_SIG + 0x06, // BIP32_DERIVATION + 0x16, // TAP_BIP32_DERIVATION + 0x17, // TAP_INTERNAL_KEY + 0xFC, // PROPRIETARY + ]) + ) { + let sensitive_pair = encoded_pair(sensitive_type, vec![0xAA], vec![0xBB]); + + let mut test_psbt = MAGIC.to_vec(); + append_v2_global_fields(&mut test_psbt, 1, 0, &[]); + test_psbt.extend(&sensitive_pair); + test_psbt.push(0x00); + + let result = scrub(&test_psbt).unwrap(); + prop_assert!(!result.windows(sensitive_pair.len()).any(|w| w == sensitive_pair)); + } + + #[test] + fn insensitive_fields_preserved( + value in arb_value(), + field in arb_insensitive_field(), + ) { + let pair = encode_insensitive(field, value); + let test_psbt = build_psbt_with_insensitive(field, &pair); + + let result = scrub(&test_psbt).unwrap(); + prop_assert!(result.windows(pair.len()).any(|w| w == pair)); + } + + /// v0 PSBTs take their input and output counts from `UNSIGNED_TX`, so scrubbing + /// must round-trip them just as it does v2. + #[test] + fn v0_scrub_is_idempotent(psbt in arb_v0_psbt()) { + let once = scrub(&psbt).expect("valid v0 PSBT must scrub"); + let twice = scrub(&once).expect("scrub of scrubbed output must succeed"); + prop_assert_eq!(once, twice); + } + + /// The `UNSIGNED_TX` global is insensitive, so a v0 PSBT keeps it verbatim and the + /// scrubbed output still describes the same number of inputs and outputs. + #[test] + fn v0_unsigned_tx_preserved( + n_inputs in 1u8..=3, + n_outputs in 0u8..=3, + ) { + let pair = encoded_global( + GlobalInsensitive::UnsignedTx, + unsigned_tx(n_inputs, n_outputs), + ); + let mut psbt = MAGIC.to_vec(); + psbt.extend(&pair); + psbt.push(0x00); + // One empty map per declared input and output. + psbt.extend(std::iter::repeat_n( + 0x00, + n_inputs as usize + n_outputs as usize, + )); + + let result = scrub(&psbt).expect("valid v0 PSBT must scrub"); + prop_assert!(result.windows(pair.len()).any(|w| w == pair)); + } + + /// Anything not starting with the magic bytes is rejected before parsing. + #[test] + fn invalid_magic_rejected(psbt in proptest::collection::vec(any::(), 0..=32)) { + prop_assume!(psbt.get(..5) != Some(&MAGIC[..])); + prop_assert_eq!(scrub(&psbt), Err(Error::InvalidMagic)); + } + + /// Bytes left over once every declared map has been consumed are an error, however + /// well-formed the PSBT before them was. + #[test] + fn trailing_bytes_rejected( + psbt in arb_v2_psbt(), + trailing in proptest::collection::vec(any::(), 1..=8), + ) { + prop_assume!(scrub(&psbt).is_ok()); + let mut extended = psbt; + extended.extend(trailing); + prop_assert_eq!(scrub(&extended), Err(Error::UnexpectedTrailingBytes)); + } + + /// Any declared key length above the limit is rejected on the length alone, + /// while the limit itself stays decodable (and here fails on the absent key bytes). + #[test] + fn oversized_key_rejected(excess in 1u64..=u32::MAX as u64) { + let mut psbt = MAGIC.to_vec(); + psbt.extend(serialize(&VarInt::from(MAX_KEY_LEN + excess + 1))); + prop_assert_eq!(scrub(&psbt), Err(Error::OversizedKey)); + + let mut at_limit = MAGIC.to_vec(); + at_limit.extend(serialize(&VarInt::from(MAX_KEY_LEN + 1))); + prop_assert_eq!(scrub(&at_limit), Err(Error::UnexpectedEof)); + } + } + } +}