mirror of
https://github.com/bitcoin/bips.git
synced 2026-04-20 16:28:39 +00:00
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
This commit is contained in:
86
bip-0375/deps/dleq.py
Normal file
86
bip-0375/deps/dleq.py
Normal file
@@ -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
|
||||
158
bip-0375/validator/inputs.py
Normal file
158
bip-0375/validator/inputs.py
Normal file
@@ -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("<I", output_index_bytes)[0]
|
||||
script_pubkey = _parse_non_witness_utxo(non_witness_utxo, output_index)
|
||||
return script_pubkey
|
||||
|
||||
|
||||
def parse_witness_utxo(witness_utxo: bytes) -> 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)
|
||||
)
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user