From 6a91f88030f5d7daefcf8ceaf5c19838f9c36277 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:12:50 -0500 Subject: [PATCH] BIP-375: add ecdh coverage validation Add deps/dleq.py (Adapted from bip-0374/reference.py) Extract pubkey from PSBT inputs - PSBT_IN_BIP32_DERIVATION - PSBT_IN_WITNESS_UTXO for P2TR Add script type helpers - bip352 input eligibility helpers --- bip-0375/deps/dleq.py | 86 +++++++++++++++ bip-0375/validator/inputs.py | 158 ++++++++++++++++++++++++++++ bip-0375/validator/validate_psbt.py | 129 ++++++++++++++++++++++- 3 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 bip-0375/deps/dleq.py create mode 100644 bip-0375/validator/inputs.py diff --git a/bip-0375/deps/dleq.py b/bip-0375/deps/dleq.py new file mode 100644 index 00000000..81540356 --- /dev/null +++ b/bip-0375/deps/dleq.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Handle DLEQ proof generation and verification + +Adapted from bip-0374 reference.py +""" + +from secp256k1lab.secp256k1 import G, GE +from secp256k1lab.util import tagged_hash, xor_bytes + + +DLEQ_TAG_AUX = "BIP0374/aux" +DLEQ_TAG_NONCE = "BIP0374/nonce" +DLEQ_TAG_CHALLENGE = "BIP0374/challenge" + + +def dleq_challenge( + A: GE, B: GE, C: GE, R1: GE, R2: GE, m: bytes | None, G: GE, +) -> int: + if m is not None: + assert len(m) == 32 + m = bytes([]) if m is None else m + return int.from_bytes( + tagged_hash( + DLEQ_TAG_CHALLENGE, + A.to_bytes_compressed() + + B.to_bytes_compressed() + + C.to_bytes_compressed() + + G.to_bytes_compressed() + + R1.to_bytes_compressed() + + R2.to_bytes_compressed() + + m, + ), + "big", + ) + + +def dleq_generate_proof( + a: int, B: GE, r: bytes, G: GE = G, m: bytes | None = None +) -> bytes | None: + assert len(r) == 32 + if not (0 < a < GE.ORDER): + return None + if B.infinity: + return None + if m is not None: + assert len(m) == 32 + A = a * G + C = a * B + t = xor_bytes(a.to_bytes(32, "big"), tagged_hash(DLEQ_TAG_AUX, r)) + m_prime = bytes([]) if m is None else m + rand = tagged_hash( + DLEQ_TAG_NONCE, t + A.to_bytes_compressed() + C.to_bytes_compressed() + m_prime + ) + k = int.from_bytes(rand, "big") % GE.ORDER + if k == 0: + return None + R1 = k * G + R2 = k * B + e = dleq_challenge(A, B, C, R1, R2, m, G) + s = (k + e * a) % GE.ORDER + proof = e.to_bytes(32, "big") + s.to_bytes(32, "big") + if not dleq_verify_proof(A, B, C, proof, G=G, m=m): + return None + return proof + + +def dleq_verify_proof( + A: GE, B: GE, C: GE, proof: bytes, G: GE = G, m: bytes | None = None +) -> bool: + if A.infinity or B.infinity or C.infinity or G.infinity: + return False + assert len(proof) == 64 + e = int.from_bytes(proof[:32], "big") + s = int.from_bytes(proof[32:], "big") + if s >= GE.ORDER: + return False + R1 = s * G - e * A + if R1.infinity: + return False + R2 = s * B - e * C + if R2.infinity: + return False + if e != dleq_challenge(A, B, C, R1, R2, m, G): + return False + return True diff --git a/bip-0375/validator/inputs.py b/bip-0375/validator/inputs.py new file mode 100644 index 00000000..ec24cef9 --- /dev/null +++ b/bip-0375/validator/inputs.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +PSBT input utility functions +""" + +import struct +from typing import Optional, Tuple + +from deps.bitcoin_test.messages import CTransaction, CTxOut, from_binary +from deps.bitcoin_test.psbt import ( + PSBT_IN_BIP32_DERIVATION, + PSBT_IN_NON_WITNESS_UTXO, + PSBT_IN_OUTPUT_INDEX, + PSBT_IN_REDEEM_SCRIPT, + PSBT_IN_TAP_INTERNAL_KEY, + PSBT_IN_WITNESS_UTXO, +) +from secp256k1lab.secp256k1 import GE + +from .psbt_bip375 import BIP375PSBTMap + + +def pubkey_from_eligible_input(input_map: BIP375PSBTMap) -> Optional[GE]: + """ + Extract the public key from a PSBT input map if eligible for silent payments + + Returns a GE point (public key), or None if not found + """ + if not is_input_eligible(input_map)[0]: + return None + + # Try BIP32 derivation first (key_data is the pubkey) + derivations = input_map.get_all_by_type(PSBT_IN_BIP32_DERIVATION) + if derivations: + pubkey, _ = derivations[0] + if len(pubkey) == 33: + return GE.from_bytes(pubkey) + + # Try PSBT_IN_WITNESS_UTXO for P2TR inputs + spk = parse_witness_utxo(input_map[PSBT_IN_WITNESS_UTXO]) + if spk and _is_p2tr(spk): + return GE.from_bytes(bytes([0x02]) + spk[2:34]) + return None + + +# ============================================================================ +# scriptPubKey helpers +# ============================================================================ + + +def _script_pubkey_from_psbt_input(input_map: BIP375PSBTMap) -> Optional[bytes]: + """Extract scriptPubKey from PSBT input fields""" + script_pubkey = None + + # Try WITNESS_UTXO first + if PSBT_IN_WITNESS_UTXO in input_map: + script_pubkey = parse_witness_utxo(input_map[PSBT_IN_WITNESS_UTXO]) + + # Try NON_WITNESS_UTXO for legacy inputs + elif PSBT_IN_NON_WITNESS_UTXO in input_map: + non_witness_utxo = input_map[PSBT_IN_NON_WITNESS_UTXO] + # Get the output index from PSBT_IN_OUTPUT_INDEX field + if PSBT_IN_OUTPUT_INDEX in input_map: + output_index_bytes = input_map[PSBT_IN_OUTPUT_INDEX] + if len(output_index_bytes) == 4: + output_index = struct.unpack(" bytes: + """Extract scriptPubKey from witness_utxo""" + utxo = from_binary(CTxOut, witness_utxo) + return utxo.scriptPubKey + + +def _parse_non_witness_utxo(non_witness_utxo: bytes, output_index: int) -> bytes: + """Extract scriptPubKey from non_witness_utxo""" + tx = from_binary(CTransaction, non_witness_utxo) + assert output_index < len(tx.vout), "Invalid output index" + return tx.vout[output_index].scriptPubKey + + +# ============================================================================ +# Input eligibility helpers +# ============================================================================ + + +def is_input_eligible(input_map: BIP375PSBTMap) -> Tuple[bool, str]: + """Check if input is eligible for silent payments""" + script_pubkey = _script_pubkey_from_psbt_input(input_map) + assert script_pubkey is not None, ( + "scriptPubKey could not be extracted from PSBT input" + ) + + if not _has_eligible_script_type(script_pubkey): + return False, "ineligible input type" + + NUMS_H = bytes.fromhex( + "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + ) + if _is_p2tr(script_pubkey): + tap_internal_key = input_map.get(PSBT_IN_TAP_INTERNAL_KEY) + if tap_internal_key == NUMS_H: + return False, "P2TR uses NUMS point H as internal key" + + if _is_p2sh(script_pubkey): + if PSBT_IN_REDEEM_SCRIPT in input_map: + redeem_script = input_map[PSBT_IN_REDEEM_SCRIPT] + if not _is_p2wpkh(redeem_script): + return False, "P2SH is not P2SH-P2WPKH" + else: + assert False, "P2SH input missing PSBT_IN_REDEEM_SCRIPT" + return True, None + + +def _has_eligible_script_type(script_pubkey: bytes) -> bool: + """True if scriptPubKey is eligible for silent payments""" + return ( + _is_p2pkh(script_pubkey) + or _is_p2wpkh(script_pubkey) + or _is_p2tr(script_pubkey) + or _is_p2sh(script_pubkey) + ) + + +def _is_p2tr(spk: bytes) -> bool: + if len(spk) != 34: + return False + # OP_1 OP_PUSHBYTES_32 <32 bytes> + return (spk[0] == 0x51) & (spk[1] == 0x20) + + +def _is_p2wpkh(spk: bytes) -> bool: + if len(spk) != 22: + return False + # OP_0 OP_PUSHBYTES_20 <20 bytes> + return (spk[0] == 0x00) & (spk[1] == 0x14) + + +def _is_p2sh(spk: bytes) -> bool: + if len(spk) != 23: + return False + # OP_HASH160 OP_PUSHBYTES_20 <20 bytes> OP_EQUAL + return (spk[0] == 0xA9) & (spk[1] == 0x14) & (spk[-1] == 0x87) + + +def _is_p2pkh(spk: bytes) -> bool: + if len(spk) != 25: + return False + # OP_DUP OP_HASH160 OP_PUSHBYTES_20 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG + return ( + (spk[0] == 0x76) + & (spk[1] == 0xA9) + & (spk[2] == 0x14) + & (spk[-2] == 0x88) + & (spk[-1] == 0xAC) + ) diff --git a/bip-0375/validator/validate_psbt.py b/bip-0375/validator/validate_psbt.py index 64152e0b..3f7794bb 100644 --- a/bip-0375/validator/validate_psbt.py +++ b/bip-0375/validator/validate_psbt.py @@ -13,7 +13,10 @@ from deps.bitcoin_test.psbt import ( PSBT_GLOBAL_TX_MODIFIABLE, PSBT_OUT_SCRIPT, ) +from deps.dleq import dleq_verify_proof +from secp256k1lab.secp256k1 import GE +from .inputs import is_input_eligible, pubkey_from_eligible_input from .psbt_bip375 import ( PSBT_GLOBAL_SP_ECDH_SHARE, PSBT_GLOBAL_SP_DLEQ, @@ -116,7 +119,131 @@ def validate_psbt_structure(psbt: PSBT) -> Tuple[bool, str]: def validate_ecdh_coverage(psbt: PSBT) -> Tuple[bool, str]: - return False, "ECDH coverage check not implemented yet" + """ + Validate ECDH share coverage and DLEQ proof correctness + + Checks: + - Verify ECDH share coverage for each scan key associated with SP outputs + - Every ECDH share must have a corresponding DLEQ proof + - If PSBT_OUT_SCRIPT is set, all eligible inputs must have ECDH coverage + - DLEQ proofs must verify correctly + """ + # Collect unique scan keys from SP outputs + scan_keys = set() + for output_map in psbt.o: + if PSBT_OUT_SP_V0_INFO in output_map: + sp_info = output_map[PSBT_OUT_SP_V0_INFO] + scan_keys.add(sp_info[:33]) + + if not scan_keys: + return True, None # No SP outputs, nothing to check + + # For each scan key, verify ECDH share coverage and DLEQ proofs + for scan_key in scan_keys: + has_global_ecdh = psbt.g.get_by_key(PSBT_GLOBAL_SP_ECDH_SHARE, scan_key) + has_input_ecdh = any( + input_map.get_by_key(PSBT_IN_SP_ECDH_SHARE, scan_key) + for input_map in psbt.i + ) + + scan_key_has_computed_output = any( + PSBT_OUT_SP_V0_INFO in om + and om[PSBT_OUT_SP_V0_INFO][:33] == scan_key + and PSBT_OUT_SCRIPT in om + for om in psbt.o + ) + if scan_key_has_computed_output and not has_global_ecdh and not has_input_ecdh: + return False, "Silent payment output present but no ECDH share for scan key" + + # Verify global DLEQ proof if global ECDH present + if has_global_ecdh: + ecdh_share = psbt.g.get_by_key(PSBT_GLOBAL_SP_ECDH_SHARE, scan_key) + dleq_proof = psbt.g.get_by_key(PSBT_GLOBAL_SP_DLEQ, scan_key) + if not dleq_proof: + return False, "Global ECDH share missing DLEQ proof" + + # Compute A_sum "input public keys" for global verification + A_sum = None + for input_map in psbt.i: + pubkey = pubkey_from_eligible_input(input_map) + if pubkey is not None: + A_sum = pubkey if A_sum is None else A_sum + pubkey + assert A_sum is not None, "No public keys found for inputs" + + valid, msg = validate_dleq_proof(A_sum, scan_key, ecdh_share, dleq_proof) + if not valid: + return False, f"Global DLEQ proof invalid: {msg}" + + # Verify per-input coverage for eligible inputs + if scan_key_has_computed_output and not has_global_ecdh: + for i, input_map in enumerate(psbt.i): + is_eligible, _ = is_input_eligible(input_map) + ecdh_share = input_map.get_by_key(PSBT_IN_SP_ECDH_SHARE, scan_key) + if not is_eligible and ecdh_share: + return ( + False, + f"Input {i} has ECDH share but is ineligible for silent payments", + ) + if is_eligible and not ecdh_share: + return ( + False, + f"Output script set but eligible input {i} missing ECDH share", + ) + if ecdh_share: + # Verify per-input DLEQ proofs + dleq_proof = input_map.get_by_key(PSBT_IN_SP_DLEQ, scan_key) + if not dleq_proof: + return False, f"Input {i} ECDH share missing DLEQ proof" + + # Get input public key A + A = pubkey_from_eligible_input(input_map) + if A is None: + return ( + False, + f"Input {i} missing public key for DLEQ verification", + ) + + valid, msg = validate_dleq_proof( + A, scan_key, ecdh_share, dleq_proof + ) + if not valid: + return False, f"Input {i} DLEQ proof invalid: {msg}" + return True, None + + +def validate_dleq_proof( + A: GE, + scan_key: bytes, + ecdh_share: bytes, + dleq_proof: bytes, +) -> Tuple[bool, str]: + """ + Verify a DLEQ proof for silent payments + + Checks: + - ECDH share and DLEQ proof lengths + - Verify DLEQ proof correctness + """ + if len(ecdh_share) != 33: + return ( + False, + f"Invalid ECDH share length: {len(ecdh_share)} bytes (expected 33)", + ) + + if len(dleq_proof) != 64: + return ( + False, + f"Invalid DLEQ proof length: {len(dleq_proof)} bytes (expected 64)", + ) + + B_scan = GE.from_bytes(scan_key) + C_ecdh = GE.from_bytes(ecdh_share) + + # Verify DLEQ proof using BIP-374 reference + result = dleq_verify_proof(A, B_scan, C_ecdh, dleq_proof) + if not result: + return False, "DLEQ proof verification failed" + return True, None def validate_input_eligibility(psbt: PSBT) -> Tuple[bool, str]: