From 9d5ec8bb3e55ac70db60b0240f8457b3c5d8a006 Mon Sep 17 00:00:00 2001 From: Ferdinando Ametrano Date: Thu, 13 Aug 2026 18:24:15 +0200 Subject: [PATCH] Recompute a silent payment's output scripts, rather than trust them btclib.psbt.silent_payments is BIP375's other half: the fields were the codec, and these are the two roles that make carrying them worth anything. A silent payment output script is derived and not signed, so a wrong one is consensus-valid -- it confirms, and the money is gone. The Transaction Extractor is the last party that can notice, which is why the recomputation lives here and not in a wallet. The Signer writes: set_input_share for one input's ECDH share and its BIP374 proof, set_global_share for the single pair that stands for every eligible input, set_output_scripts for what the recipients are paid -- which clears the two modifiable flags with it, the scripts being a function of the input set. Both writers refuse a key that is not the one they would prove against, an error being cheapest before it is published. The Extractor reads: assert_as_valid is the four checks BIP375's own validator publishes, in its order, each naming what failed. input_pub_key is what they all stand on and was the piece missing until now -- btclib.silent_payments.pub_key_from_input reads a signed input's key out of the witness or the scriptSig, where an unsigned one has neither and BIP375 asks an Updater for PSBT_IN_BIP32_DERIVATION instead. bip375_test_vectors.json is now answered in full: all 22 invalid psbts refused and all 19 valid ones accepted, where the codec alone refused five. Each case is held to the check its own category names, so a psbt refused for the wrong reason fails rather than counting as a pass. One rule where the BIP and its own vectors disagree, and the vectors win. BIP375 says the codes of one scan key are sorted lexicographically to order k; the vectors' scripts are the ones output index order derives. The deciding case is published as valid -- "two sp outputs - output 0 uses label=3 / output 1 uses label=1" -- and its spend keys are in descending order, so the two rules assign k the other way round and only index order reproduces the file. Neither reading of "the codes" rescues the prose: the info fields and the bech32m address strings sort that pair the same wrong way, and upstream's own validator walks index order too. Both directions are asserted, so a revision settling it otherwise fails here rather than passing quietly. The two invalid vectors named after ordering turn out not to decide it: their candidate orderings agree, and their scripts match no assignment at all. btclib.silent_payments gains output_key in passing, the last step of BIP352's derivation: the psbt path reaches it from an ECDH share rather than from a private key, so it is what the two paths share instead of output_keys. Closes #760 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 54 +++ README.md | 5 +- btclib/psbt/__init__.py | 8 +- btclib/psbt/silent_payments.py | 600 +++++++++++++++++++++++++++++ btclib/silent_payments.py | 25 +- docs/source/btclib.psbt.rst | 7 + tests/_data/README.md | 34 +- tests/all_test.py | 3 +- tests/psbt/silent_payments_test.py | 566 +++++++++++++++++++++++++++ 9 files changed, 1282 insertions(+), 20 deletions(-) create mode 100644 btclib/psbt/silent_payments.py create mode 100644 tests/psbt/silent_payments_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9e112ee..a50894fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -710,6 +710,60 @@ documented at release-notes length in the first place, and are still in ### Transactions, blocks and PSBT +- **BIP375's Signer and Transaction Extractor** (#760, following #641). + `btclib.psbt.silent_payments` is the new module, and it is the half of + BIP375 that makes the fields worth carrying: a silent payment output + script is *derived* rather than signed, so getting it wrong produces a + consensus-valid transaction that pays a script nobody scans for -- and + the Extractor is the last party that can notice. + + The Signer's side: `set_input_share` writes the ECDH share and its + BIP374 proof for one input, `set_global_share` the single pair that + stands for every eligible input, and `set_output_scripts` derives what + the recipients are paid and clears the two modifiable flags with it -- + the scripts depend on the input set, so a psbt that publishes one and + still invites inputs invites its own scripts to become wrong. Both + writers refuse a key that is not the one they would be proving against, + which is where such an error is cheap. + + The Extractor's side is `assert_as_valid`: the four checks BIP375's own + validator publishes, in its order, each naming what failed -- + `assert_shares_as_valid` for the proofs, `assert_eligibility_as_valid` + for the inputs a silent payment forbids (a witness version above 1, and + any sighash type but `SIGHASH_ALL`), `assert_output_scripts_as_valid` + for the derivation. `input_pub_key` is what all of it stands on, and is + the piece that was missing: `btclib.silent_payments.pub_key_from_input` + reads a *signed* input's key out of the witness or the scriptSig, where + an unsigned one has neither and BIP375 asks an Updater for + `PSBT_IN_BIP32_DERIVATION` instead. + + With that, `bip375_test_vectors.json` is answered in full: all 22 + invalid psbts refused and all 19 valid ones accepted, where the codec + alone refused five. Each case is held to the check its own category + names, so a psbt refused for the wrong reason is a failure rather than a + pass. + + **One rule where the BIP and its own vectors disagree**, and it is + load-bearing rather than cosmetic. BIP375 says the codes of one scan key + are sorted lexicographically to determine the ordering of `k`; the + vectors' scripts are the ones *output index* order derives. The case + that decides it is published as valid -- "two sp outputs - output 0 uses + label=3 / output 1 uses label=1" -- and its spend keys are in descending + order, so the two rules assign `k` the other way round and only index + order reproduces the file. Neither reading of "the codes" rescues the + prose: the 66-byte info fields and the bech32m address strings sort that + pair the same wrong way. Upstream's own validator walks index order too, + so index order is what interoperates and what is implemented; both + directions are asserted, so a revision settling it the other way fails + here rather than passing quietly. The two invalid vectors named after + ordering turn out not to decide it -- their candidate orderings all + agree, and their scripts match no assignment at all. + + `btclib.silent_payments` gains `output_key` in passing: the last step of + BIP352's derivation, which the psbt path reaches from an ECDH share + rather than from a private key, so it is what the two paths share + instead of `output_keys`. + - **A psbt can be read a map at a time, out of a stream** (#647). `btclib.psbt.PsbtView` is that reader, beside `Psbt` and not instead of it: `Psbt.parse` reads every map before anything can be inspected or diff --git a/README.md b/README.md index 6b0cc5cc1..af55fba82 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,10 @@ Included features are: - [BIP375](https://github.com/bitcoin/bips/blob/master/bip-0375.mediawiki) silent payments in a PSBT: the six fields that carry an ECDH share, its BIP374 proof and the address being paid, the output script that may not - exist yet, and the identifier that reads the address in its place + exist yet, and the identifier that reads the address in its place — with + both roles the BIP adds, the Signer that writes the shares and derives + the scripts and the Transaction Extractor that recomputes every one of + them before the transaction goes out - [BIP21](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki) `bitcoin:` payment URIs - fee rates carrying their unit (sat/kvB, sat/vB, and the BTC/kvB Bitcoin diff --git a/btclib/psbt/__init__.py b/btclib/psbt/__init__.py index 78df57af9..bd6e0d261 100644 --- a/btclib/psbt/__init__.py +++ b/btclib/psbt/__init__.py @@ -9,7 +9,10 @@ an unsigned psbt spends, the two messages a Signer signs and `sign` which plays the role over a `KeyManager`'s answers, and the size estimation a fee rate is applied to. `musig2` is named as a module, being -the BIP373 role rather than one function, the way btclib.ecc names dsa. +the BIP373 role rather than one function, the way btclib.ecc names dsa; so +is `silent_payments`, BIP375's two roles over the fields BIP375 adds -- +what a Signer writes into a psbt paying a silent payment address, and what +a Transaction Extractor has to recompute before it hands the bytes over. `PsbtView` is the same psbt read a map at a time out of a stream, for a signer with less memory than the psbt takes (issue #647). It is beside @@ -52,7 +55,7 @@ half of that module from already. """ -from btclib.psbt import musig2 +from btclib.psbt import musig2, silent_payments from btclib.psbt.psbt import ( InputSolver, KeyManager, @@ -94,5 +97,6 @@ "new_signers", "prevouts", "sign", + "silent_payments", "taproot_sig_hash", ] diff --git a/btclib/psbt/silent_payments.py b/btclib/psbt/silent_payments.py new file mode 100644 index 000000000..ccb27221a --- /dev/null +++ b/btclib/psbt/silent_payments.py @@ -0,0 +1,600 @@ +# Copyright (c) The btclib developers +# Distributed under the MIT software license, see the accompanying +# LICENSE file or https://opensource.org/license/mit for the full text. + +"""The BIP375 roles: sending a silent payment through a psbt. + +https://github.com/bitcoin/bips/blob/master/bip-0375.mediawiki + +`btclib.psbt.psbt_in` and `btclib.psbt.psbt_out` carry BIP375's six +fields; this is what the two roles BIP375 adds *do* with them, and it is +a module rather than a function for the reason `btclib.psbt.musig2` is: +a role is a sequence of steps different parties take at different times. + +**Why it matters more than a signature does.** A silent payment output +script is derived, not signed: get it wrong and the transaction is still +consensus-valid, so it confirms and the money is gone. The shares and the +BIP374 proofs are what make that derivation checkable by somebody holding +none of the keys -- and the Transaction Extractor is where the check has +to happen, being the last party before the bytes go on the wire. + +The Signer's side, in the order BIP375 puts it: + +- `set_input_share` writes an ECDH share and its proof for one input the + Signer holds the key of; `set_global_share` writes the one pair that + stands for every eligible input, which a Signer holding all the keys + may do instead. +- `assert_shares_as_valid` is what a Signer does with the shares it did + *not* write: every proof verified against the key of the input it + covers, or against the sum of them for a global one. +- `set_output_scripts` computes what the recipients are paid, once every + eligible input is covered, and clears the two modifiable flags -- the + scripts depend on the input set, so nothing may be added afterwards. + +`assert_as_valid` is the Extractor's, and it is the four checks BIP375's +own validator publishes, in its order: the fields, then the share +coverage and the proofs, then which inputs are allowed to be there at +all, then the output scripts recomputed and compared. + +**What a psbt input's public key is, and why it needs its own reader.** +`btclib.silent_payments.pub_key_from_input` reads it off a *signed* +input, from the witness or the scriptSig. An unsigned input has neither, +and BIP375 says where to look instead: the Updater "should add a +PSBT_IN_BIP32_DERIVATION for any p2wpkh, p2sh-p2wpkh, or p2pkh input so +the public key is available for creating the ecdh_shared_secret when the +private key is not known". `input_pub_key` is that reader, and it answers +None for an input BIP352 does not count -- a taproot NUMS internal key, a +p2sh wrapping anything but p2wpkh, a script type off the list. + +**The share is not the shared secret**, which is the thing in BIP375 +easiest to get wrong: `a*B_scan` carries no input hash, and BIP352's +shared secret is `input_hash*a*B_scan`. So the Extractor multiplies the +share by the input hash, and the input hash needs the *sum of the public +keys* of the eligible inputs -- which is why reading those keys is the +first thing here and not an aside. + +**The k of an output** is BIP375's own rule and not BIP352's ordering: the +codes sharing one scan key are sorted lexicographically by spend key, and +a subgroup sharing both keys by output index. `_ordered_sp_outputs` is +that sort, and `tests/psbt/silent_payments_test.py` says which of the +vectors pins it. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from btclib import silent_payments as sp +from btclib.alias import Octets, Point +from btclib.curves import bytes_from_point, mult, secp256k1 +from btclib.ecc import dleq +from btclib.ecc.ssa import point_from_bip340pub_key +from btclib.exceptions import BTClibValueError +from btclib.psbt.psbt import INPUTS_MODIFIABLE, OUTPUTS_MODIFIABLE, Psbt, _prev_out +from btclib.psbt.psbt_in import PsbtIn +from btclib.psbt.psbt_out import PsbtOut +from btclib.psbt.psbt_utils import SP_SCAN_KEY_SIZE +from btclib.script import serialize +from btclib.script.script_pub_key import ( + is_p2pkh, + is_p2sh, + is_p2tr, + is_p2wpkh, +) +from btclib.script.sig_hash import ALL +from btclib.to_prv_key import PrvKey, int_from_prv_key +from btclib.to_pub_key import point_from_pub_key + +__all__ = [ + "assert_as_valid", + "assert_eligibility_as_valid", + "assert_output_scripts_as_valid", + "assert_shares_as_valid", + "eligible_pub_keys", + "input_pub_key", + "output_scripts", + "set_global_share", + "set_input_share", + "set_output_scripts", + "shared_secret_from_share", +] + +# the highest witness version an input may spend while a silent payment +# output is present. Above it the spent script is one this protocol +# version has no rule for, and BIP352 skips such a transaction rather +# than guess -- so a sender must not build one +_MAX_WITNESS_VERSION = 1 + +# OP_1, which a version 1 witness program starts with: the versions run +# OP_1..OP_16 as 0x51..0x60, so a first byte above this is a version above 1 +_OP_1 = 0x51 + + +def _witness_version(script: bytes) -> int | None: + """Return the witness version of a program, or None if it is none. + + The shape and not the length, which is deliberate: what this answers + is whether a version is above 1, and a v2-or-later program of a length + no BIP defines is still an input BIP352 has no rule for. + """ + if len(script) < 2 or script[1] != len(script) - 2: + return None + if script[0] == 0: + return 0 + if _OP_1 <= script[0] <= _OP_1 + 15: + return script[0] - _OP_1 + 1 + return None + + +def _script_pub_key(psbt_in: PsbtIn) -> bytes: + """Return the script_pub_key of the output an input spends, or b"".""" + prev_out = _prev_out(psbt_in) + return b"" if prev_out is None else prev_out.script_pub_key.script + + +def _is_eligible(psbt_in: PsbtIn) -> bool: + """Answer whether BIP352 counts this input, reading the psbt fields. + + The four eligible script types, minus the two exclusions that are not + the script type: a taproot input whose internal key is BIP341's NUMS + point has no key path to derive from, and a p2sh input is eligible + only for the one redeem script BIP352 lists, p2wpkh. + + Both come from a field rather than from a witness, which is where a + psbt reader differs from `btclib.silent_payments`: an input that has + not been signed has no witness to read either out of. + """ + script = _script_pub_key(psbt_in) + if is_p2tr(script): + return psbt_in.taproot_internal_key != sp.NUMS_H + if is_p2sh(script): + return is_p2wpkh(psbt_in.redeem_script) + return is_p2pkh(script) or is_p2wpkh(script) + + +def input_pub_key(psbt_in: PsbtIn) -> Point | None: + """Return the public key of one psbt input, or None if it does not count. + + A taproot input's key is the output key the script_pub_key carries: + it is what the recipient sums, script path or not, and the psbt need + not say anything for it to be readable. Every other eligible kind + keeps its key in the key data of PSBT_IN_BIP32_DERIVATION, which is + what BIP375 asks an Updater to add for exactly this. + + The lowest such key where there is more than one, rather than an + arbitrary first: a dict's order is the order the psbt happened to be + parsed in, and an answer that depends on it is an answer two readers + of one psbt could disagree about. An eligible input has one key + anyway -- p2pkh, p2wpkh and p2sh-p2wpkh each commit to a single hash + -- so this decides nothing that a correct psbt leaves open. + """ + if not _is_eligible(psbt_in): + return None + script = _script_pub_key(psbt_in) + if is_p2tr(script): + return point_from_bip340pub_key(script[2:34], secp256k1) + keys = sorted(k for k in psbt_in.hd_key_paths if len(k) == SP_SCAN_KEY_SIZE) + if not keys: + return None + return point_from_pub_key(keys[0]) + + +def eligible_pub_keys(psbt: Psbt) -> dict[int, Point]: + """Return the public key of every input BIP352 counts, by index. + + The index is kept because the per-input shares are filed per input: a + coverage rule that answered "how many" rather than "which" could not + name the input whose share is missing. + """ + keys = {} + for i, psbt_in in enumerate(psbt.inputs): + pub_key = input_pub_key(psbt_in) + if pub_key is not None: + keys[i] = pub_key + return keys + + +def _scan_keys(psbt: Psbt) -> list[bytes]: + """Return the scan key of every silent payment output, deduplicated.""" + seen: dict[bytes, None] = {} + for psbt_out in psbt.outputs: + if psbt_out.sp_v0_info: + seen[psbt_out.sp_v0_info[:SP_SCAN_KEY_SIZE]] = None + return list(seen) + + +def _share_and_sum(psbt: Psbt, scan_key: bytes) -> tuple[bytes, Point] | None: + """Return the share standing for every eligible input, and their sum. + + The global share where there is one, else the sum of the per-input + shares of the eligible inputs -- which is what makes the two + interchangeable downstream: `a_1*B + a_2*B` is `(a_1 + a_2)*B`, so a + transaction whose signers each contributed one share derives the same + outputs as one whose single signer contributed the lot. + + None when there is nothing to derive from: no share at all, or no + eligible input to take a public key from. + """ + pub_keys = eligible_pub_keys(psbt) + if not pub_keys: + return None + A_sum = sp.pub_key_sum(list(pub_keys.values())) + + share = psbt.sp_ecdh_shares.get(scan_key) + if share is not None: + return share, A_sum + + shares = [ + psbt.inputs[i].sp_ecdh_shares[scan_key] + for i in pub_keys + if scan_key in psbt.inputs[i].sp_ecdh_shares + ] + if not shares: + return None + total = sp.pub_key_sum(shares) + return bytes_from_point(total, secp256k1), A_sum + + +def shared_secret_from_share(psbt: Psbt, share: Octets, A_sum: Point) -> Point: + """Return BIP352's shared secret from a BIP375 share. + + The step the two BIPs do not share a name for, and the one worth + spelling out: the psbt carries `a*B_scan`, with no input hash in it, + where BIP352's secret is `input_hash*a*B_scan`. So the share is + multiplied by the input hash here, and the input hash is what binds + the derivation to this transaction's smallest outpoint -- which is why + the psbt is an argument and the share alone would not do. + """ + outpoints = [psbt_in.prev_out for psbt_in in psbt.inputs] + return mult(sp.input_hash(outpoints, A_sum), point_from_pub_key(share)) + + +def _ordered_sp_outputs(psbt: Psbt) -> list[tuple[int, PsbtOut]]: + """Return the silent payment outputs in the order their k follows. + + Output index order, per scan key -- which is **not** what BIP375's + prose says, and the discrepancy is upstream's rather than a choice + made here. The BIP says: "If there are multiple silent payment codes + with the same scan key, sort the codes lexicographically in ascending + order to determine the ordering of the k value." + + Measured against `bip375_test_vectors.json`, that sort produces the + wrong scripts. Its "two sp outputs - output 0 uses label=3 / output 1 + uses label=1" case is published as *valid* and its two spend keys are + in descending order, so a lexicographic sort assigns k = 0 to output 1 + -- and the scripts the file carries are the ones index order derives. + Neither reading of "the codes" rescues the prose: sorting the 66-byte + info fields and sorting the bech32m address strings both order that + pair the other way round. + + So two of upstream's three artefacts agree on index order -- the + vectors and `bip-0375/validator/validate_psbt.py`, which tracks k per + scan key while walking the outputs in index order -- and only the + prose dissents. Index order is therefore what interoperates, and the + two "output scripts" invalid vectors named after ordering are refused + under it anyway: their scripts match no assignment at all. + `tests/psbt/silent_payments_test.py` pins each of those facts, so a + later revision of the BIP that settles it the other way fails here + rather than passing quietly. + """ + return [(i, o) for i, o in enumerate(psbt.outputs) if o.sp_v0_info] + + +def output_scripts(psbt: Psbt) -> dict[int, bytes]: + """Return the script every silent payment output should pay, by index. + + An output whose scan key has no share is absent from the answer rather + than raising: a psbt under construction is allowed to have one, which + is the "in progress" half of BIP375's own vectors, and what refuses + the ones that are not allowed is `assert_output_scripts_as_valid`. + """ + scripts: dict[int, bytes] = {} + counters: dict[bytes, int] = {} + for i, psbt_out in _ordered_sp_outputs(psbt): + scan_key = psbt_out.sp_v0_info[:SP_SCAN_KEY_SIZE] + found = _share_and_sum(psbt, scan_key) + if found is None: + continue + share, A_sum = found + k = counters.get(scan_key, 0) + counters[scan_key] = k + 1 + secret = shared_secret_from_share(psbt, share, A_sum) + B_m = psbt_out.sp_v0_info[SP_SCAN_KEY_SIZE:] + # `serialize(["OP_1", key])` and not `ScriptPubKey.p2tr(key)`: that + # classmethod takes an *internal* key and applies BIP341's tweak, + # where what BIP352 derives is already the output key. Tweaking it + # a second time is a script no recipient scans for + scripts[i] = serialize(["OP_1", sp.output_key(secret, B_m, k)]) + return scripts + + +def _assert_pair( + shares: dict[bytes, bytes], proofs: dict[bytes, bytes], what: str +) -> None: + """Raise unless the shares and the proofs name the same scan keys. + + Each half is useless without the other: a share nobody can hold its + writer to is what carrying a proof exists to prevent, and a proof of a + share that is not there proves nothing at all. + """ + for scan_key in shares: + if scan_key not in proofs: + err_msg = f"{what} ECDH share with no proof beside it: scan key " + err_msg += scan_key.hex() + raise BTClibValueError(err_msg) + for scan_key in proofs: + if scan_key not in shares: + err_msg = f"{what} DLEQ proof with no share to prove: scan key " + err_msg += scan_key.hex() + raise BTClibValueError(err_msg) + + +def _assert_global_shares(psbt: Psbt, A_sum: Point | None) -> None: + """Raise unless the global shares are proved against the input sum. + + A global share stands for every eligible input at once, so what proves + it is the sum of their public keys -- and a psbt carrying one with no + eligible input to sum is a psbt whose share nothing can be checked + against, which is a different failure from a proof that does not + verify. + """ + _assert_pair(psbt.sp_ecdh_shares, psbt.sp_dleq_proofs, "global") + for scan_key, share in psbt.sp_ecdh_shares.items(): + if A_sum is None: + err_msg = "global ECDH share with no eligible input to prove it against" + raise BTClibValueError(err_msg) + if not dleq.verify_proof(A_sum, scan_key, share, psbt.sp_dleq_proofs[scan_key]): + raise BTClibValueError(f"invalid global DLEQ proof for {scan_key.hex()}") + + +def _assert_input_shares(psbt: Psbt, pub_keys: dict[int, Point]) -> None: + """Raise unless every per-input share is proved against its own key. + + A share on an input BIP352 does not count is passed over rather than + refused: `_share_and_sum` gives it no weight either, and one of + BIP375's valid vectors carries exactly that. An input that *is* + counted and carries no public key is the opposite case and is refused + -- BIP375 asks an Updater for PSBT_IN_BIP32_DERIVATION so that there + is one, and one of its invalid vectors is that field missing. + """ + for i, psbt_in in enumerate(psbt.inputs): + if psbt_in.sp_ecdh_shares and not _is_eligible(psbt_in): + continue + _assert_pair(psbt_in.sp_ecdh_shares, psbt_in.sp_dleq_proofs, f"input {i}") + for scan_key, share in psbt_in.sp_ecdh_shares.items(): + A = pub_keys.get(i) + if A is None: + err_msg = f"input {i}: ECDH share on an input with no public key to " + err_msg += "prove it against; BIP375 asks an Updater for " + err_msg += "PSBT_IN_BIP32_DERIVATION so that there is one" + raise BTClibValueError(err_msg) + proof = psbt_in.sp_dleq_proofs[scan_key] + if not dleq.verify_proof(A, scan_key, share, proof): + err_msg = f"input {i}: invalid DLEQ proof for {scan_key.hex()}" + raise BTClibValueError(err_msg) + + +def assert_shares_as_valid(psbt: Psbt) -> None: + """Raise unless every ECDH share the psbt carries is proved. + + BIP375's second check, and the one that makes a share worth reading: a + proof is verified against the public key of what it covers -- the sum + of the eligible inputs' keys for a global share, that one input's key + for a per-input one -- so a share can be trusted by a party holding + none of the private keys. + """ + pub_keys = eligible_pub_keys(psbt) + A_sum = sp.pub_key_sum(list(pub_keys.values())) if pub_keys else None + _assert_global_shares(psbt, A_sum) + _assert_input_shares(psbt, pub_keys) + + +def _assert_covered(psbt: Psbt, scan_key: bytes) -> None: + """Raise unless every eligible input contributes to this scan key. + + Asked only of a scan key whose output script is already set: before + that the psbt is under construction, and a share that has not arrived + is a signer that has not signed. Once the script is there it is a + claim about every eligible input, so a missing share means the script + was derived from fewer keys than the recipient will sum -- and the + recipient would find nothing. + """ + if scan_key in psbt.sp_ecdh_shares: + return + for i in eligible_pub_keys(psbt): + if scan_key not in psbt.inputs[i].sp_ecdh_shares: + err_msg = f"input {i}: no ECDH share for scan key {scan_key.hex()}, " + err_msg += "whose output script is already set" + raise BTClibValueError(err_msg) + + +def assert_eligibility_as_valid(psbt: Psbt) -> None: + """Raise unless every input may be there at all, silent payments present. + + BIP375's third check, and the two rules are BIP352's reasons in a + psbt's terms. An input spending a witness program above version 1 is + one this protocol version has no derivation rule for, so BIP352 skips + the whole transaction -- which makes building one a way to pay an + address nobody will scan. And a sighash type other than SIGHASH_ALL + lets the inputs or the outputs change after the scripts were derived + from them: BIP352 permits NONE and SINGLE, BIP375 does not, because + here the scripts are computed from the number and the position of the + codes. + """ + if not _scan_keys(psbt): + return + for i, psbt_in in enumerate(psbt.inputs): + version = _witness_version(_script_pub_key(psbt_in)) + if version is not None and version > _MAX_WITNESS_VERSION: + err_msg = f"input {i}: spends witness version {version}, which a psbt " + err_msg += "with a silent payment output must not" + raise BTClibValueError(err_msg) + if psbt_in.sig_hash_type is not None and psbt_in.sig_hash_type != ALL: + err_msg = f"input {i}: sig hash type {psbt_in.sig_hash_type}, where a " + err_msg += "psbt with a silent payment output requires SIGHASH_ALL" + raise BTClibValueError(err_msg) + + +def assert_output_scripts_as_valid(psbt: Psbt) -> None: + """Raise unless every silent payment script is the one derived. + + BIP375's fourth check and the Extractor's reason to exist: this is the + error a signature cannot catch, a wrong output script being + consensus-valid. An output that carries no script yet is passed over + -- that is a psbt still being built -- and one that carries a script + without the shares to derive it is not. + """ + derived = output_scripts(psbt) + for i, psbt_out in enumerate(psbt.outputs): + if not psbt_out.sp_v0_info: + continue + script = psbt_out.script_pub_key + scan_key = psbt_out.sp_v0_info[:SP_SCAN_KEY_SIZE] + if not script: + continue + _assert_covered(psbt, scan_key) + if i not in derived: + err_msg = f"output {i}: PSBT_OUT_SCRIPT with no ECDH share to derive it " + err_msg += f"from, for scan key {scan_key.hex()}" + raise BTClibValueError(err_msg) + if script != derived[i]: + err_msg = f"output {i}: PSBT_OUT_SCRIPT is not the silent payment script " + err_msg += f"its address derives: {script.hex()} instead of " + err_msg += derived[i].hex() + raise BTClibValueError(err_msg) + + +def _assert_modifiable_cleared(psbt: Psbt) -> None: + """Raise if a derived output script may still have its inputs changed. + + BIP375: a Signer that sets a missing PSBT_OUT_SCRIPT "must set the + Inputs Modifiable and Outputs Modifiable flags to False". The script + is a function of the input set and of the position of the codes, so a + psbt that publishes one and still invites changes publishes a script + that the next Constructor invalidates. + """ + if psbt.tx_modifiable is None: + return + if not any(o.sp_v0_info and o.script_pub_key for o in psbt.outputs): + return + if psbt.tx_modifiable & (INPUTS_MODIFIABLE | OUTPUTS_MODIFIABLE): + err_msg = "PSBT_GLOBAL_TX_MODIFIABLE still invites changes, with a silent " + err_msg += "payment output script already derived from what it would change" + raise BTClibValueError(err_msg) + + +def assert_as_valid(psbt: Psbt) -> None: + """Raise unless the psbt satisfies BIP375, the roles included. + + `Psbt.assert_valid` is the format; this is the protocol on top of it, + and it is what a Transaction Extractor owes a silent payment before it + hands the bytes over. The four checks in BIP375's own order, each + naming what failed: the fields, the shares and their proofs, which + inputs may be present, and the output scripts. + + A psbt with no silent payment output passes everything here, there + being nothing to derive. + """ + psbt.assert_valid() + _assert_modifiable_cleared(psbt) + assert_shares_as_valid(psbt) + assert_eligibility_as_valid(psbt) + assert_output_scripts_as_valid(psbt) + + +def _share_for(a: int, scan_key: bytes, aux: Octets | None) -> tuple[bytes, bytes]: + """Return the ECDH share for one scalar and scan key, and its proof.""" + B_scan = point_from_pub_key(scan_key) + share = bytes_from_point(mult(a, B_scan), secp256k1) + return share, dleq.generate_proof(a, B_scan, aux) + + +def set_input_share( + psbt: Psbt, vin_i: int, prv_key: PrvKey, aux: Octets | None = None +) -> None: + """Write the ECDH share and proof of one input, for every recipient. + + What a Signer holding one input's key does: one share per scan key the + psbt pays, each with the BIP374 proof that it was computed with the + private key of *this* input's public key -- which is what lets the + other signers check it without holding that key. + + The input must be one BIP352 counts, and the key must be its own: a + share proved against a public key the input does not have is a share + every verifier rejects, so it is refused here instead of written. + """ + psbt_in = psbt.inputs[vin_i] + A = input_pub_key(psbt_in) + if A is None: + err_msg = f"input {vin_i}: no public key, so no share BIP352 would count" + raise BTClibValueError(err_msg) + a = int_from_prv_key(prv_key) + if mult(a) != A: + err_msg = f"input {vin_i}: the private key is not the one of its public key" + raise BTClibValueError(err_msg) + for scan_key in _scan_keys(psbt): + share, proof = _share_for(a, scan_key, aux) + psbt_in.sp_ecdh_shares[scan_key] = share + psbt_in.sp_dleq_proofs[scan_key] = proof + + +def set_global_share( + psbt: Psbt, prv_keys: Sequence[PrvKey], aux: Octets | None = None +) -> None: + """Write the one ECDH share standing for every eligible input. + + What a Signer holding *every* eligible input's key may do instead of + one share each: the sum of those keys, once, with one proof against + the sum of their public keys. Fewer bytes in the psbt and one + verification for every reader of it. + + The keys are given in the order of the eligible inputs, and the sum is + checked against the sum of their public keys before anything is + written: a global share proved against the wrong sum is a share that + fails for every recipient at once. + """ + pub_keys = eligible_pub_keys(psbt) + if len(prv_keys) != len(pub_keys): + err_msg = f"{len(prv_keys)} private keys for {len(pub_keys)} eligible inputs" + raise BTClibValueError(err_msg) + a = 0 + for prv_key in prv_keys: + a = (a + int_from_prv_key(prv_key)) % secp256k1.n + if a == 0: + raise BTClibValueError("input private keys sum to zero") + if mult(a) != sp.pub_key_sum(list(pub_keys.values())): + err_msg = "the private keys do not sum to the eligible inputs' public keys" + raise BTClibValueError(err_msg) + for scan_key in _scan_keys(psbt): + share, proof = _share_for(a, scan_key, aux) + psbt.sp_ecdh_shares[scan_key] = share + psbt.sp_dleq_proofs[scan_key] = proof + + +def set_output_scripts(psbt: Psbt) -> None: + """Derive every silent payment output script, and freeze the psbt. + + The Signer's last step before it signs: BIP375 forbids a signature + while an output has no script, and requires the two modifiable flags + cleared once one is written -- the script is a function of the input + set, so a psbt that still invites inputs invites its own scripts to + become wrong. + + Every silent payment output must be derivable, or nothing is written: + a psbt half-derived is one whose recipients each need the other's + signer to have finished. + """ + scripts = output_scripts(psbt) + missing = [ + i for i, o in enumerate(psbt.outputs) if o.sp_v0_info and i not in scripts + ] + if missing: + err_msg = f"no ECDH share to derive the script of output(s) {missing}" + raise BTClibValueError(err_msg) + if not scripts: + return + for i, script in scripts.items(): + psbt.outputs[i].script_pub_key = script + psbt.tx_modifiable = (psbt.tx_modifiable or 0) & ~( + INPUTS_MODIFIABLE | OUTPUTS_MODIFIABLE + ) diff --git a/btclib/silent_payments.py b/btclib/silent_payments.py index 5427d25f7..ae049d6e7 100644 --- a/btclib/silent_payments.py +++ b/btclib/silent_payments.py @@ -89,6 +89,7 @@ "label_lookup", "label_tweak", "labeled_address_from_keys", + "output_key", "output_keys", "prv_key_from_tweak", "prv_key_sum", @@ -523,6 +524,24 @@ def _output_tweak(secret: Point, k: int) -> int: return _scalar(tagged_hash(_SHARED_SECRET_TAG, hash_input), f"tweak for k={k}") +def output_key(secret: PubKey, B_m: PubKey, k: int) -> bytes: + """Return the x-only taproot output key of one recipient of a group. + + The last step of BIP352's derivation, and the one a caller that + already holds the shared secret needs on its own: + `btclib.psbt.silent_payments` reaches this point from an ECDH share a + psbt carries rather than from a private key, so what the two paths + share is this and not `output_keys`. + + `k` is the recipient's position in its group, which is what stops two + payments to one scan key landing on one output. + """ + P = secp256k1.add( + point_from_pub_key(B_m), mult(_output_tweak(point_from_pub_key(secret), k)) + ) + return _x_only(P) + + def output_keys( prv_keys: Sequence[tuple[PrvKey, Octets]], outpoints: Sequence[OutPoint], @@ -570,12 +589,10 @@ def output_keys( err_msg += f" > K_MAX ({K_MAX})" raise BTClibValueError(err_msg) - keys = [] + keys: list[bytes] = [] for B_scan, B_m_values in groups.items(): secret = shared_secret((h * a) % secp256k1.n, B_scan) - for k, B_m in enumerate(B_m_values): - P = secp256k1.add(B_m, mult(_output_tweak(secret, k))) - keys.append(_x_only(P)) + keys.extend(output_key(secret, B_m, k) for k, B_m in enumerate(B_m_values)) return keys diff --git a/docs/source/btclib.psbt.rst b/docs/source/btclib.psbt.rst index e2bab9919..945512959 100644 --- a/docs/source/btclib.psbt.rst +++ b/docs/source/btclib.psbt.rst @@ -4,6 +4,13 @@ btclib.psbt package Submodules ---------- +btclib.psbt.silent\_payments module +----------------------------------- + +.. automodule:: btclib.psbt.silent_payments + :members: + :show-inheritance: + btclib.psbt.psbt module ----------------------- diff --git a/tests/_data/README.md b/tests/_data/README.md index a52c4d16a..be309a7a5 100644 --- a/tests/_data/README.md +++ b/tests/_data/README.md @@ -721,18 +721,28 @@ level up: the maps read out of upstream's bytes and the maps read out of btclib's hold the same set of pairs, and btclib's own bytes are stable under a second parse. Measured on all 36 psbts that parse. -Five of the 22 invalid psbts are refused. They are five of BIP375's six -"PSBT Structure" cases, which are the ones a codec can answer: a label -without the info field beside it, and four wrong lengths. The sixth is -not a field's shape -- "PSBT_GLOBAL_TX_MODIFIABLE field is non-zero when -PSBT_OUT_SCRIPT set for sp output" is an obligation on the Signer that -computed that script -- and neither are the other sixteen, whose -categories are ECDH coverage, input eligibility and output script -derivation. Those are the Signer's and the Transaction Extractor's roles, -which btclib does not play: each needs every input's public key, which -for an unsigned input comes from PSBT_IN_BIP32_DERIVATION rather than -from the input, and one of the invalid cases is that field missing. The -test module says so where a reader of it would ask. +All 22 invalid psbts are refused and all 19 valid ones pass, and it takes +two test modules to say so: `tests/psbt/bip375_test.py` holds the codec to +the file -- the field shapes, which is five of the six "PSBT Structure" +cases -- and `tests/psbt/silent_payments_test.py` holds the two roles to +it, which is the other seventeen. Each case's category is read off its own +description, so a psbt refused by the wrong check fails there rather than +counting as a pass. + +**The file and the BIP disagree about one rule, and the file wins here.** +BIP375 says the codes of one scan key are sorted lexicographically to +determine the ordering of `k`; the vectors' output scripts are the ones +*output index* order derives. The case that decides it is published as +valid -- "two sp outputs - output 0 uses label=3 / output 1 uses label=1" +-- and its two spend keys are in descending order, so the two rules assign +`k` the other way round and only one of them reproduces the scripts the +file carries. Neither reading of "the codes" rescues the prose: sorting +the 66-byte info fields and sorting the bech32m address strings both order +that pair the same wrong way. Upstream's own +`bip-0375/validator/validate_psbt.py` walks index order too, so two of its +three artefacts agree and the prose is the outlier. +`test_the_k_ordering_is_the_output_index` pins that in both directions, so +a revision settling it the other way fails rather than passing quietly. ### `tests/script/_data/bip67_test_vectors.json` diff --git a/tests/all_test.py b/tests/all_test.py index 192394738..73a4cc790 100644 --- a/tests/all_test.py +++ b/tests/all_test.py @@ -188,7 +188,7 @@ "unpublished": [], }, "btclib.psbt": { - "groups": ["musig2"], + "groups": ["musig2", "silent_payments"], "unpublished": [ "psbt", "psbt_in", @@ -527,6 +527,7 @@ def test_psbt_exports_the_format_not_its_plumbing() -> None: "new_signers", "prevouts", "sign", + "silent_payments", "taproot_sig_hash", ] diff --git a/tests/psbt/silent_payments_test.py b/tests/psbt/silent_payments_test.py new file mode 100644 index 000000000..0ea7c7520 --- /dev/null +++ b/tests/psbt/silent_payments_test.py @@ -0,0 +1,566 @@ +# Copyright (c) The btclib developers +# Distributed under the MIT software license, see the accompanying +# LICENSE file or https://opensource.org/license/mit for the full text. + +"""Tests for the `btclib.psbt.silent_payments` module, BIP375's roles. + +The vectors are BIP375's own `bip375_test_vectors.json`, already vendored +under `tests/psbt/_data/` for `bip375_test.py`, which holds the codec to +them; this module holds the two roles to the same file, and where that one +had to say that seventeen of the invalid psbts are accepted, here **all 22 +are refused and all 19 valid ones pass**. + +The `checks` field of a case names which of BIP375's four checks it is +about, so each is asserted against the check that should refuse it rather +than against "something raised": a psbt refused for the wrong reason is a +psbt this module got right by accident. + +**The k ordering is measured here, not assumed**, because BIP375's prose +and its own vectors disagree and the disagreement is load-bearing. The +prose says to sort the codes of one scan key lexicographically; the +vectors' scripts are the ones output-index order derives, and one case +published as *valid* has its two spend keys in descending order, so the +two readings differ on it. `test_the_k_ordering_is_the_output_index` pins +that, so a revision settling it the other way fails here rather than +passing quietly. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +import pytest + +from btclib import silent_payments as sp +from btclib.curves import bytes_from_point, mult, secp256k1 +from btclib.ecc import dleq +from btclib.exceptions import BTClibValueError +from btclib.psbt import Psbt +from btclib.psbt import silent_payments as role +from btclib.script import serialize +from tests import load, vector_id + +_VECTORS = load("psbt", "_data", "bip375_test_vectors.json", encoding="utf-8") + +# which of BIP375's four checks each invalid case is about, read off the +# description's own prefix: the file groups them that way, and the +# categories are the ones its README lists +_CATEGORIES = ("psbt structure", "ecdh coverage", "input eligibility", "output scripts") + + +def _params(group: str) -> tuple[list[dict[str, Any]], list[str]]: + vectors = _VECTORS[group] + ids = [vector_id(i, v["description"]) for i, v in enumerate(vectors)] + return vectors, ids + + +_VALID, _VALID_IDS = _params("valid") +_INVALID, _INVALID_IDS = _params("invalid") + + +def _category(description: str) -> str: + """Return which of the four checks a case is about.""" + for category in _CATEGORIES: + if description.startswith(category): + return category + msg = f"vector in no category: {description}" + raise AssertionError(msg) + + +@pytest.mark.parametrize("vector", _VALID, ids=_VALID_IDS) +def test_every_valid_psbt_passes_every_check(vector: dict[str, Any]) -> None: + """The whole file's valid half, both roles applied. + + Including the "in progress" ones, which is the half that says the + checks know what a psbt under construction looks like: an output whose + script is not derived yet is not an output whose script is wrong. + """ + psbt = Psbt.b64decode(vector["psbt"]) + role.assert_as_valid(psbt) + # and each check on its own, so that a pass is not one check masking + # another's opinion + role.assert_shares_as_valid(psbt) + role.assert_eligibility_as_valid(psbt) + role.assert_output_scripts_as_valid(psbt) + + +@pytest.mark.parametrize("vector", _INVALID, ids=_INVALID_IDS) +def test_every_invalid_psbt_is_refused(vector: dict[str, Any]) -> None: + """All 22, where the codec alone refused five. + + The other seventeen are what this module adds, and each is refused by + the check its own category names -- `Psbt.parse` and `assert_valid` + for the structural ones, and one of the three role checks otherwise. + """ + category = _category(vector["description"]) + if category == "psbt structure": + # the codec's own, five of the six: parse refuses four for a + # length and one for a missing script, and the sixth is the + # modifiable flags, which is a Signer's obligation + with pytest.raises(BTClibValueError): + role.assert_as_valid(Psbt.b64decode(vector["psbt"])) + return + + psbt = Psbt.b64decode(vector["psbt"]) + checks = { + "ecdh coverage": ( + role.assert_shares_as_valid, + role.assert_output_scripts_as_valid, + ), + "input eligibility": (role.assert_eligibility_as_valid,), + "output scripts": (role.assert_output_scripts_as_valid,), + }[category] + # its own category's check refuses it, and not merely the whole + with pytest.raises(BTClibValueError): + for check in checks: + check(psbt) + with pytest.raises(BTClibValueError): + role.assert_as_valid(psbt) + + +def _vector(group: str, prefix: str) -> dict[str, Any]: + """Return the one case whose description starts with the prefix.""" + return next(v for v in _VECTORS[group] if v["description"].startswith(prefix)) + + +def test_the_k_ordering_is_the_output_index() -> None: + """BIP375's prose and BIP375's vectors disagree; the vectors win. + + "If there are multiple silent payment codes with the same scan key, + sort the codes lexicographically in ascending order to determine the + ordering of the k value" -- and the case below is published as valid, + shares one scan key across two outputs, and has its spend keys in + *descending* order. So the lexicographic rule would give output 1 the + k of 0, and the scripts the file carries are the ones output index + order derives. + + Asserted rather than described, in both directions: index order + reproduces both scripts, and the byte order and the address order -- + the two readings of "the codes" -- reproduce neither. Upstream's own + validator walks index order too, so the prose is the outlier. + """ + vector = _vector("valid", "can finalize: two sp outputs - output 0 uses label=3") + psbt = Psbt.b64decode(vector["psbt"]) + outputs = [(i, o) for i, o in enumerate(psbt.outputs) if o.sp_v0_info] + assert len(outputs) == 2 + scan_key = outputs[0][1].sp_v0_info[: sp._PK_SIZE] + # one scan key, and the spend keys the other way round + assert all(o.sp_v0_info[: sp._PK_SIZE] == scan_key for _, o in outputs) + assert outputs[0][1].sp_v0_info > outputs[1][1].sp_v0_info + + found = role._share_and_sum(psbt, scan_key) + assert found is not None + share, A_sum = found + secret = role.shared_secret_from_share(psbt, share, A_sum) + + def script(psbt_out: Any, k: int) -> bytes: + B_m = psbt_out.sp_v0_info[sp._PK_SIZE :] + return serialize(["OP_1", sp.output_key(secret, B_m, k)]) + + # index order: k is the position among the silent payment outputs + for k, (_, psbt_out) in enumerate(outputs): + assert psbt_out.script_pub_key == script(psbt_out, k) + # the lexicographic order would swap them, and neither script matches + for k, (_, psbt_out) in enumerate(reversed(outputs)): + assert psbt_out.script_pub_key != script(psbt_out, k) + + # and what the module derives is what the file carries + assert role.output_scripts(psbt) == {i: o.script_pub_key for i, o in outputs} + + +def test_the_two_ordering_vectors_are_refused_whatever_the_order() -> None: + """The invalid cases named after ordering are not about ordering. + + Both have all three candidate orderings agree -- their spend keys are + already ascending, or identical -- so what makes them invalid is that + their scripts match no k assignment at all. Worth pinning: a reader of + the descriptions would expect them to be the vectors that decide the + ordering question, and they are not; the valid case above is. + """ + for prefix in ( + "output scripts: two sp outputs (same scan / different spend keys)", + "output scripts: k values assigned to wrong output indices", + ): + psbt = Psbt.b64decode(_vector("invalid", prefix)["psbt"]) + outputs = [(i, o) for i, o in enumerate(psbt.outputs) if o.sp_v0_info] + by_index = [i for i, _ in outputs] + by_bytes = [ + i for i, _ in sorted(outputs, key=lambda p: (p[1].sp_v0_info, p[0])) + ] + assert by_index == by_bytes + with pytest.raises(BTClibValueError, match="not the silent payment script"): + role.assert_output_scripts_as_valid(psbt) + + +def test_an_input_pub_key_comes_from_the_derivation_or_the_script() -> None: + """Where BIP375 says to look, and what a psbt not saying costs. + + A taproot input's key is in the script_pub_key, so it is readable + whatever else the psbt carries. Every other eligible kind keeps it in + PSBT_IN_BIP32_DERIVATION, which BIP375 asks an Updater to add for + exactly this -- an unsigned input has no witness and no scriptSig to + read one out of, which is why `btclib.silent_payments`'s reader cannot + serve here. + """ + psbt = Psbt.b64decode(_vector("valid", "can finalize: one P2PKH input")["psbt"]) + psbt_in = psbt.inputs[0] + pub_key = role.input_pub_key(psbt_in) + assert pub_key is not None + assert bytes_from_point(pub_key) in psbt_in.hd_key_paths + + # the field gone, the key is gone with it, and a share that named it + # is then a share nothing can prove + stripped = deepcopy(psbt) + stripped.inputs[0].hd_key_paths = {} + assert role.input_pub_key(stripped.inputs[0]) is None + assert role.eligible_pub_keys(stripped) == {} + with pytest.raises(BTClibValueError, match="no public key to prove it against"): + role.assert_shares_as_valid(stripped) + + # a taproot input needs no field: the output key is the script's + taproot = Psbt.b64decode(_vector("valid", "in progress: two P2TR inputs")["psbt"]) + for taproot_in in taproot.inputs: + assert role.input_pub_key(taproot_in) is not None + + +def test_an_ineligible_input_is_passed_over_rather_than_refused() -> None: + """A share on an input BIP352 does not count proves nothing. + + And is not an error: one of BIP375's valid vectors carries exactly + that, a p2sh multisig input beside the eligible ones. It contributes + to no sum and is held to no proof -- which is a different thing from + an input that *is* counted and carries no public key, the case above, + and one of the invalid vectors. + """ + psbt = Psbt.b64decode( + _vector("valid", "can finalize: two inputs using per-input ECDH")["psbt"] + ) + eligible = role.eligible_pub_keys(psbt) + assert len(eligible) < len(psbt.inputs) + ineligible = next(i for i in range(len(psbt.inputs)) if i not in eligible) + assert role.input_pub_key(psbt.inputs[ineligible]) is None + role.assert_as_valid(psbt) + + +def test_a_share_needs_its_proof_and_a_proof_its_share() -> None: + """Each half of the pair is useless alone, so neither stands alone.""" + psbt = Psbt.b64decode( + _vector("valid", "can finalize: two inputs single-signer using global")["psbt"] + ) + scan_key = next(iter(psbt.sp_ecdh_shares)) + + no_proof = deepcopy(psbt) + no_proof.sp_dleq_proofs = {} + with pytest.raises( + BTClibValueError, match="global ECDH share with no proof beside it" + ): + role.assert_shares_as_valid(no_proof) + + no_share = deepcopy(psbt) + no_share.sp_ecdh_shares = {} + with pytest.raises( + BTClibValueError, match="global DLEQ proof with no share to prove" + ): + role.assert_shares_as_valid(no_share) + + # and the same for the per-input pair + per_input = Psbt.b64decode( + _vector("valid", "can finalize: two inputs single-signer using per")["psbt"] + ) + damaged = deepcopy(per_input) + damaged.inputs[0].sp_dleq_proofs = {} + with pytest.raises( + BTClibValueError, match="input 0 ECDH share with no proof beside it" + ): + role.assert_shares_as_valid(damaged) + damaged = deepcopy(per_input) + damaged.inputs[0].sp_ecdh_shares = {} + with pytest.raises( + BTClibValueError, match="input 0 DLEQ proof with no share to prove" + ): + role.assert_shares_as_valid(damaged) + + # a proof of the right shape that proves the wrong thing is refused by + # the verification and not by a length, which is the point of carrying + # one at all + forged = deepcopy(psbt) + forged.sp_ecdh_shares[scan_key] = bytes_from_point(mult(2)) + with pytest.raises(BTClibValueError, match="invalid global DLEQ proof"): + role.assert_shares_as_valid(forged) + + +def test_the_share_is_not_the_shared_secret() -> None: + """`a*B_scan` carries no input hash; BIP352's secret does. + + The step the two BIPs give no shared name, and the one an + implementation gets wrong silently: skip the input hash and every + output script comes out different, with nothing else to say so. + """ + psbt = Psbt.b64decode( + _vector("valid", "can finalize: two inputs single-signer using global")["psbt"] + ) + scan_key, share = next(iter(psbt.sp_ecdh_shares.items())) + A_sum = sp.pub_key_sum(list(role.eligible_pub_keys(psbt).values())) + secret = role.shared_secret_from_share(psbt, share, A_sum) + + outpoints = [psbt_in.prev_out for psbt_in in psbt.inputs] + h = sp.input_hash(outpoints, A_sum) + assert secret == mult(h, sp.pub_key_sum([share])) + # the share alone is a different point, and would derive different + # scripts with nothing to report + assert secret != sp.pub_key_sum([share]) + assert bytes_from_point(secret) != share + # and the share is what a DLEQ proof is about, the secret is not + assert dleq.verify_proof(A_sum, scan_key, share, psbt.sp_dleq_proofs[scan_key]) + + +def test_a_signer_writes_the_shares_it_can_prove() -> None: + """The Signer's side, held to the Extractor's. + + A psbt stripped of its shares is written again from the private keys + the vector publishes, and what says the two agree is not a byte + comparison but the checks themselves: the proofs verify, the scripts + derive to what the file already carried, and `assert_as_valid` passes + over the result. + """ + vector = _vector("valid", "can finalize: two inputs single-signer using per") + psbt = Psbt.b64decode(vector["psbt"]) + prv_keys = { + i["input_index"]: i["private_key"] + for i in vector["supplementary"]["inputs"] + if i["private_key"] + } + expected = {i: o.script_pub_key for i, o in enumerate(psbt.outputs) if o.sp_v0_info} + + stripped = deepcopy(psbt) + for psbt_in in stripped.inputs: + psbt_in.sp_ecdh_shares = {} + psbt_in.sp_dleq_proofs = {} + for i in role.eligible_pub_keys(stripped): + role.set_input_share(stripped, i, prv_keys[i], aux=bytes(32)) + role.assert_shares_as_valid(stripped) + assert role.output_scripts(stripped) == expected + role.assert_as_valid(stripped) + + # the same psbt with one global share instead, which is what a signer + # holding every key may write: a different psbt, the same outputs + global_psbt = deepcopy(stripped) + for psbt_in in global_psbt.inputs: + psbt_in.sp_ecdh_shares = {} + psbt_in.sp_dleq_proofs = {} + eligible = list(role.eligible_pub_keys(global_psbt)) + role.set_global_share(global_psbt, [prv_keys[i] for i in eligible], aux=bytes(32)) + role.assert_shares_as_valid(global_psbt) + assert role.output_scripts(global_psbt) == expected + role.assert_as_valid(global_psbt) + + +def test_a_signer_is_held_to_the_key_of_the_input_it_writes_for() -> None: + """A share proved against the wrong key is refused before it is written. + + Which is worth doing at the writing end: every reader of the psbt + would reject it, and the signer would have published a proof of its + own error. + """ + vector = _vector("valid", "can finalize: two inputs single-signer using per") + psbt = Psbt.b64decode(vector["psbt"]) + prv_keys = { + i["input_index"]: i["private_key"] + for i in vector["supplementary"]["inputs"] + if i["private_key"] + } + eligible = list(role.eligible_pub_keys(psbt)) + other = prv_keys[eligible[1]] + with pytest.raises(BTClibValueError, match="not the one of its public key"): + role.set_input_share(psbt, eligible[0], other) + + # and the ineligible-input refusal, which needs a psbt that has one: + # the P2SH multisig case, where a share would contribute to no sum + excluded = Psbt.b64decode( + _vector("valid", "can finalize: two inputs using per-input ECDH")["psbt"] + ) + excluded_eligible = role.eligible_pub_keys(excluded) + ineligible = next( + i for i in range(len(excluded.inputs)) if i not in excluded_eligible + ) + with pytest.raises(BTClibValueError, match="no share BIP352 would count"): + role.set_input_share(excluded, ineligible, next(iter(prv_keys.values()))) + + with pytest.raises(BTClibValueError, match="private keys for"): + role.set_global_share(psbt, [next(iter(prv_keys.values()))]) + with pytest.raises(BTClibValueError, match="do not sum to"): + role.set_global_share(psbt, [other] * len(eligible)) + + +def test_a_signer_derives_the_scripts_and_freezes_the_psbt() -> None: + """BIP375: the scripts written, and nothing left modifiable. + + The scripts are a function of the input set and of where the codes + sit, so a psbt that publishes one and still invites inputs invites its + own scripts to become wrong. The flags are cleared in the same step + that writes them, and `assert_as_valid` refuses the state where they + are not. + """ + vector = _vector("valid", "in progress: one P2TR input / one sp output") + psbt = Psbt.b64decode(vector["psbt"]) + psbt_out = next(o for o in psbt.outputs if o.sp_v0_info) + assert not psbt_out.script_pub_key + + # no share yet: nothing to derive from, and it says so rather than + # writing half a transaction + with pytest.raises(BTClibValueError, match="no ECDH share to derive"): + role.set_output_scripts(psbt) + + signed = Psbt.b64decode( + _vector("valid", "can finalize: two inputs single-signer using global")["psbt"] + ) + expected = { + i: o.script_pub_key for i, o in enumerate(signed.outputs) if o.sp_v0_info + } + stripped = deepcopy(signed) + for i in expected: + stripped.outputs[i].script_pub_key = b"" + stripped.tx_modifiable = 0xFF + + role.set_output_scripts(stripped) + assert { + i: o.script_pub_key for i, o in enumerate(stripped.outputs) if o.sp_v0_info + } == expected + assert stripped.tx_modifiable is not None + assert not stripped.tx_modifiable & 0b11 + # the five bits BIP370 leaves undefined are untouched: dropping a flag + # somebody set is the change with consequences + assert stripped.tx_modifiable == 0xFF & ~0b11 + role.assert_as_valid(stripped) + + +def test_a_psbt_with_no_silent_payment_output_passes() -> None: + """There is nothing to derive, so there is nothing to refuse. + + The BIP371 taproot psbts of `bip371_test_vectors.json` are what say + so: none of them carries a BIP375 field, and every role check has to + be a no-op over them rather than an opinion. + """ + checked = 0 + for case in load("psbt", "_data", "bip371_test_vectors.json")["valid psbts"]: + psbt = Psbt.b64decode(case["encoded psbt"]) + assert not any(o.sp_v0_info for o in psbt.outputs), case["description"] + role.assert_shares_as_valid(psbt) + role.assert_eligibility_as_valid(psbt) + role.assert_output_scripts_as_valid(psbt) + assert role.output_scripts(psbt) == {} + # and the whole check, which for a version 0 psbt is also what + # reaches the modifiable rule with no field to read: BIP370's flags + # do not exist there, and no silent payment script depends on them + role.assert_as_valid(psbt) + assert psbt.tx_modifiable is None + checked += 1 + assert checked + + +def test_the_categories_cover_every_invalid_vector() -> None: + """The dispatch above is only as good as the prefixes it knows. + + A description in none of the four would otherwise pick a check by + accident, so it raises -- and this is what says the raise is reachable + rather than decoration, which is the failure mode of every guard + written in the negative. + """ + for vector in _VECTORS["invalid"]: + assert _category(vector["description"]) in _CATEGORIES + with pytest.raises(AssertionError, match="vector in no category"): + _category("something upstream has not grouped yet") + + +def test_a_witness_version_is_read_off_the_shape() -> None: + """What `assert_eligibility_as_valid` asks of every input's script. + + None where the script is no witness program at all, which is most + scripts: a p2pkh, and a p2pk whose push length happens to fit the + shape a program has. The versions above 1 are the ones refused, and + v0 and v1 are the ones every silent payment is made of. + """ + p2wpkh = bytes.fromhex("0014" + "11" * 20) + p2tr = bytes.fromhex("5120" + "11" * 32) + v2 = bytes.fromhex("5220" + "11" * 32) + v16 = bytes.fromhex("6020" + "11" * 32) + # the shape of a program -- one opcode, then a push of the rest -- with + # an opcode that is no witness version: OP_NOP, which is what says the + # shape alone does not make a program + shaped = bytes.fromhex("6102" + "1111") + p2pkh = bytes.fromhex("76a914" + "11" * 20 + "88ac") + assert role._witness_version(p2wpkh) == 0 + assert role._witness_version(p2tr) == 1 + assert role._witness_version(v2) == 2 + assert role._witness_version(v16) == 16 + assert role._witness_version(shaped) is None + # the shape does not fit + assert role._witness_version(p2pkh) is None + assert role._witness_version(b"") is None + + +def test_a_global_share_with_no_input_to_prove_it_against() -> None: + """A share is a claim about the inputs, so it needs one. + + Not a psbt any signer writes -- it would have had a key to write the + share with -- but a psbt a reader can be handed, and "no eligible + input" is then a different failure from "the proof does not verify": + there is nothing to verify it against. + """ + psbt = Psbt.b64decode( + _vector("valid", "can finalize: two inputs single-signer using global")["psbt"] + ) + # every input made ineligible, the shares left in place + for psbt_in in psbt.inputs: + psbt_in.hd_key_paths = {} + psbt_in.sp_ecdh_shares = {} + psbt_in.sp_dleq_proofs = {} + assert role.eligible_pub_keys(psbt) == {} + with pytest.raises(BTClibValueError, match="no eligible input to prove it against"): + role.assert_shares_as_valid(psbt) + + +def test_the_modifiable_flags_are_asked_about_only_once_a_script_is_there() -> None: + """Before that the psbt is under construction and may still change. + + Which is what makes the check about the derivation rather than about + tidiness: the flags matter from the moment a script depends on the + input set, and not one step earlier. + """ + psbt = Psbt.b64decode( + _vector("valid", "in progress: one P2TR input / one sp output")["psbt"] + ) + assert not any(o.sp_v0_info and o.script_pub_key for o in psbt.outputs) + psbt.tx_modifiable = 0b11 + role.assert_as_valid(psbt) + + +def test_deriving_nothing_leaves_the_psbt_alone() -> None: + """A psbt with no silent payment output has no script to derive. + + `set_output_scripts` is then a no-op rather than an error, and in + particular does not clear the modifiable flags: there is no derived + script for them to protect, and a Constructor still has work to do. + """ + case = load("psbt", "_data", "bip371_test_vectors.json")["valid psbts"][0] + psbt = Psbt.b64decode(case["encoded psbt"]) + before = psbt.serialize() + role.set_output_scripts(psbt) + assert psbt.serialize() == before + + +def test_private_keys_summing_to_zero_write_no_global_share() -> None: + """The sum is the scalar the share is computed with, and zero is none. + + BIP352 fails on it for the sending side; here it is the same fact one + step earlier, and refusing it is what stops a share of the point at + infinity being written and proved. + """ + vector = _vector("valid", "can finalize: two inputs single-signer using per") + psbt = Psbt.b64decode(vector["psbt"]) + eligible = list(role.eligible_pub_keys(psbt)) + assert len(eligible) == 2 + a = 0x0F694E068028A717F8AF6B9411F9A133DD3565258714CC226594B34DB90C1F2C + with pytest.raises(BTClibValueError, match="sum to zero"): + role.set_global_share(psbt, [a, secp256k1.n - a])