Skip to content

Commit 847108c

Browse files
committed
SP: improve sp spend without sp outputs and change detection
Convert validate_bip376_spend to validate_silent_payment_inputs Use accounts to validate sp spend material and mark output.is_change Introduce reusable sp_derive_path function Fix change detection issue raised by previous test hygiene commit - Signing was restored for test_sp_spend_labeled_change_detected Fix psbt exception in consider_outputs with sp change - Add apply_label_to_spend_key primitive - Match spend public key to labeled spend public key for change New tests: sp spend to non sp output sp spend rejection tests - unknown account, incorrect sp spend derivation path sp change output with foreign keys is allowed but displays warning Changed test: sp spend to label change - fix label spend key derivation, use sp input
1 parent 3371646 commit 847108c

8 files changed

Lines changed: 392 additions & 76 deletions

File tree

shared/auth.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,12 @@ async def interact(self):
432432
args = self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)
433433

434434
# Silent Payments: Validate and pre-process Silent Payments outputs to make preview useful
435-
if self.psbt.has_silent_payment_outputs():
436-
with stash.SensitiveValues() as sv:
437-
if not self.psbt.process_silent_payments(sv):
435+
with stash.SensitiveValues() as sv:
436+
if self.psbt.has_silent_payment_inputs():
437+
self.psbt.validate_silent_payment_inputs(sv)
438+
439+
if self.psbt.has_silent_payment_outputs():
440+
if not self.psbt.process_silent_payment_outputs(sv):
438441
# Coverage incomplete: shares computed but waiting on other signers
439442
# Skip normal approval flow — prompt user to contribute shares, then save
440443
del args

shared/export.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR
1414
from ownership import OWNERSHIP
1515
from exceptions import QRTooBigError
16-
from silentpayments import encode_silent_payment_address
16+
from silentpayments import encode_silent_payment_address, sp_derive_path
1717

1818
async def export_by_qr(body, label, type_code, force_bbqr=False):
1919
# render as QR and show on-screen
@@ -446,7 +446,7 @@ def generate_generic_export(account_num=0):
446446
rv[name]['first'] = chain.address(node, fmt)
447447

448448
# Silent Payments: scan-priv + spend-pub packed as a single bech32m "spscan" string
449-
sp_deriv = "m/352h/%dh/%dh" % (chain.b44_cointype, account_num)
449+
sp_deriv = sp_derive_path(chain.b44_cointype, account_num)
450450
sp_node = sv.derive_path(sp_deriv)
451451
scan_node = sv.derive_path(sp_deriv + "/1h/0")
452452
spend_node = sv.derive_path(sp_deriv + "/0h/0")

shared/psbt.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
AF_P2WSH, AF_P2WSH_P2SH, AF_P2SH, AF_P2TR, AF_P2WPKH, AF_CLASSIC, AF_P2WPKH_P2SH,
4949
AFC_SEGWIT, AF_BARE_PK
5050
)
51-
from silentpayments import SilentPaymentsMixin, compute_silent_payment_spending_privkey, validate_bip376_spend
51+
from silentpayments import SilentPaymentsMixin, compute_silent_payment_spending_privkey
5252

5353
psbt_tmp256 = bytearray(256)
5454

@@ -1030,7 +1030,7 @@ def determine_my_signing_key(self, my_idx, addr_or_pubkey, my_xfp, psbt, parsed_
10301030

10311031
elif self.af == AF_P2TR:
10321032
if self.is_sp_spend:
1033-
validate_bip376_spend(self, addr_or_pubkey, my_xfp, psbt)
1033+
pass # Silent Payments inputs are handled in validate_silent_payment_inputs
10341034
elif len(parsed_subpaths) == 1:
10351035
# keyspend without a script path
10361036
assert self.taproot_merkle_root is None, "merkle_root should not be defined for simple keyspend"
@@ -2006,7 +2006,8 @@ def consider_outputs(self, len_pths, hard_p, prefix_pths, idx_max, cosign_xfp=No
20062006
self.num_change_outputs += 1
20072007
total_change += txo.nValue
20082008

2009-
if validate_inp_pths:
2009+
# Silent Payments: SP change is validated by _detect_sp_change_outputs, skip additional checks here
2010+
if validate_inp_pths and not output.sp_v0_info:
20102011
# Enforce some policy on change outputs:
20112012
# - need to "look like" they are going to same wallet as inputs came from
20122013
# - range limit last two path components (numerically)
@@ -2797,7 +2798,11 @@ def sign_it(self, alternate_secret=None, my_xfp=None):
27972798
# Double-check the change outputs are right. This is slow, but critical because
27982799
# it detects bad actors, not bugs or mistakes.
27992800
# - equivalent check already done for p2sh outputs when we re-built the redeem script
2800-
change_outs = [n for n,o in enumerate(self.outputs) if o.is_change]
2801+
2802+
# SP change is verified at preview time by _detect_sp_change_outputs;
2803+
# the subpath/check_pubkey_at_path machinery below doesn't apply to it.
2804+
change_outs = [n for n, o in enumerate(self.outputs)
2805+
if o.is_change and not o.sp_v0_info]
28012806
if change_outs:
28022807
dis.fullscreen('Change Check...')
28032808

@@ -2840,8 +2845,10 @@ def sign_it(self, alternate_secret=None, my_xfp=None):
28402845
"BIP-32 path doesn't match actual address.")
28412846

28422847
# Silent Payment Processing
2848+
if self.has_silent_payment_inputs():
2849+
self.validate_silent_payment_inputs(sv)
28432850
if self.has_silent_payment_outputs():
2844-
if not self.process_silent_payments(sv):
2851+
if not self.process_silent_payment_outputs(sv):
28452852
# Silent Payments: must not sign if output scripts not set for all signers
28462853
# Defensive re-check - ApproveTransaction::interact should handle this case before reaching signing
28472854
raise FatalPSBTIssue("Silent Payments: Signing cannot proceed until all signers contribute their shares")

shared/silentpayments.py

Lines changed: 139 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import chains
88
import ngu
99
import stash
10+
from glob import settings
1011
from dleq import generate_dleq_proof, verify_dleq_proof
11-
from exceptions import FatalPSBTIssue
12+
from exceptions import FatalPSBTIssue, FraudulentChangeOutput
1213
from precomp_tag_hash import (
14+
BIP352_LABEL_TAG_H,
1315
BIP352_SHARED_SECRET_TAG_H,
1416
BIP352_INPUTS_TAG_H,
1517
TAP_TWEAK_H,
@@ -46,41 +48,34 @@ def encode_silent_payment_address(scan_key, spend_key, version=0):
4648
return ngu.codecs.bip352_encode(hrp, scan_key, spend_key, version)
4749

4850

49-
def validate_bip376_spend(input, output_xonly, my_xfp=None, parent=None):
50-
"""Validate silent payment spend using PSBT input data
51-
52-
TODO: replace with test vectors once available
53-
54-
Raises FatalPSBTIssue if any SP spend validation checks fail
55-
"""
56-
B_spend_coords, val_coords = input.sp_spend_bip32_derivation
57-
xfp_path = input.parse_xfp_path(val_coords)
58-
if my_xfp is not None:
59-
xfp_path = input.handle_zero_xfp(xfp_path, my_xfp, parent)
60-
61-
sp_path = xfp_path[1:]
62-
if len(sp_path) != 5:
63-
raise FatalPSBTIssue("SP spend path must have 5 components")
64-
if sp_path[0] != (352 | 0x80000000):
65-
raise FatalPSBTIssue("SP spend key purpose must use 352h path")
66-
67-
expected_coin = chains.current_chain().b44_cointype | 0x80000000
68-
if sp_path[1] != expected_coin:
69-
raise FatalPSBTIssue("SP spend path coin type does not match network")
70-
if not (sp_path[2] & 0x80000000):
71-
raise FatalPSBTIssue("SP spend path account must be hardened")
72-
if sp_path[3] != 0x80000000:
73-
raise FatalPSBTIssue("SP spend path key type must be 0h")
74-
75-
B_spend = input.get(B_spend_coords)
76-
if _compute_silent_payment_spending_xonly(B_spend, input.sp_tweak) != output_xonly:
77-
raise FatalPSBTIssue("SP_TWEAK does not match UTXO output key")
51+
def sp_derive_path(coin_type, account):
52+
"""BIP-352 base derivation path for a given coin type and account number"""
53+
return "m/352h/%dh/%dh" % (coin_type, account)
7854

7955

8056
# -----------------------------------------------------------------------------
8157
# Silent Payments Cryptographic Primitives
8258
# -----------------------------------------------------------------------------
8359

60+
def _apply_label_to_spend_key(B_spend_bytes, b_scan_bytes, label):
61+
"""
62+
Apply BIP-352 label tweak to spend key
63+
64+
Formula: B_m = B_spend + hash_BIP0352/Label(b_scan || m)*G
65+
66+
Args:
67+
B_spend_bytes: Base spend public key (33-byte compressed)
68+
b_scan_bytes: Scan private key (32-byte scalar)
69+
label: Label integer (0 for change)
70+
71+
Returns:
72+
bytes: Labeled spend public key (33-byte compressed)
73+
"""
74+
msg = b_scan_bytes + label.to_bytes(4, 'big')
75+
label_tweak_bytes = ngu.hash.sha256t(BIP352_LABEL_TAG_H, msg, True)
76+
label_tweak_point = ngu.secp256k1.ec_pubkey_tweak_mul(G, label_tweak_bytes)
77+
return ngu.secp256k1.ec_pubkey_combine([B_spend_bytes, label_tweak_point])
78+
8479

8580
def _combine_pubkeys(pubkeys):
8681
"""
@@ -294,21 +289,19 @@ class SilentPaymentsMixin:
294289
This class assumes it is mixed into psbtObject and has access to psbt as self
295290
"""
296291

297-
def process_silent_payments(self, sv):
292+
def process_silent_payment_outputs(self, sv):
298293
"""
299294
Core SP workflow: validate, compute shares, compute scripts
300295
301296
Notes:
302-
- This function is intended to be called during the preview phase, before signing
297+
- This function is intended to be called during the preview phase and again during signing
303298
- Single-signer should be able to generate output and preview immediately
304299
- Multi-signer should generate shares and prompt user to collect all shares if not complete
305300
306301
Returns:
307302
bool: True if output scripts were computed and are ready for preview/signing
308-
False if we generated shares but are waiting on others, or if we don't have necessary info to proceed
303+
False if we generated shares but are waiting on others
309304
"""
310-
if not self.has_silent_payment_outputs():
311-
return False
312305
self._validate_psbt_structure()
313306
self._validate_input_eligibility()
314307
self._validate_ecdh_coverage()
@@ -319,7 +312,7 @@ def process_silent_payments(self, sv):
319312
if self._is_ecdh_coverage_complete():
320313
# Computes scripts, or validates existing ones against recomputed values
321314
self._compute_silent_payment_output_scripts()
322-
self._detect_sp_change_outputs()
315+
self._detect_sp_change_outputs(sv)
323316
return self._all_sp_outputs_have_scripts()
324317
return False
325318

@@ -341,6 +334,63 @@ def render_silent_payment_output_string(self, output):
341334

342335
return " - silent payments address -\n%s\n" % encode_silent_payment_address(scan_key, spend_key)
343336

337+
def validate_silent_payment_inputs(self, sv):
338+
"""
339+
Validate inputs spending silent payments (BIP-376)
340+
341+
Raises FatalPSBTIssue on any failure.
342+
"""
343+
coin_type = chains.current_chain().b44_cointype
344+
expected_coin = coin_type | 0x80000000
345+
346+
candidate_accounts = self._get_candidate_sp_accounts()
347+
348+
for i, inp in enumerate(self.inputs):
349+
if not inp.is_sp_spend:
350+
continue
351+
352+
# Validate path shape and extract B_spend for both our and foreign inputs
353+
B_spend_coords, val_coords = inp.sp_spend_bip32_derivation
354+
xfp_path = inp.parse_xfp_path(val_coords)
355+
xfp_path = inp.handle_zero_xfp(xfp_path, self.my_xfp, self)
356+
357+
sp_path = xfp_path[1:]
358+
if len(sp_path) != 5:
359+
raise FatalPSBTIssue("Input #%d: SP spend path must have 5 components" % i)
360+
if sp_path[0] != (352 | 0x80000000):
361+
raise FatalPSBTIssue("Input #%d: SP spend key purpose must use 352h path" % i)
362+
if sp_path[1] != expected_coin:
363+
raise FatalPSBTIssue("Input #%d: SP spend path coin type does not match network" % i)
364+
if not (sp_path[2] & 0x80000000):
365+
raise FatalPSBTIssue("Input #%d: SP spend path account must be hardened" % i)
366+
if sp_path[3] != 0x80000000:
367+
raise FatalPSBTIssue("Input #%d: SP spend path key type must be 0h" % i)
368+
if sp_path[4] != 0:
369+
raise FatalPSBTIssue("Input #%d: SP spend path index must be 0" % i)
370+
371+
B_spend = inp.get(B_spend_coords)
372+
373+
# For our inputs, validate account is known and B_spend matches derivation from this device
374+
if xfp_path[0] == self.my_xfp:
375+
account = sp_path[2] & ~0x80000000
376+
if account not in candidate_accounts:
377+
raise FatalPSBTIssue(
378+
"Input #%d: SP spend account not recognized on this device" % i)
379+
380+
spend_node = sv.derive_path(
381+
sp_derive_path(coin_type, account) + "/0h/0", register=False)
382+
derived = spend_node.pubkey()
383+
del spend_node
384+
if derived != B_spend:
385+
raise FatalPSBTIssue("Input #%d: SP spend pubkey mismatch" % i)
386+
387+
spk = inp.utxo_spk
388+
if not spk or not _is_p2tr(spk):
389+
raise FatalPSBTIssue("Input #%d: SP spend UTXO is not P2TR" % i)
390+
utxo_xonly = spk[2:34]
391+
if _compute_silent_payment_spending_xonly(B_spend, inp.sp_tweak) != utxo_xonly:
392+
raise FatalPSBTIssue("Input #%d: SP_TWEAK does not match UTXO output key" % i)
393+
344394
# -----------------------------------------------------------------------------
345395
# Input Helper Functions
346396
# -----------------------------------------------------------------------------
@@ -613,9 +663,6 @@ def _compute_and_store_ecdh_shares(self, sv):
613663
"""
614664
Compute ECDH shares and DLEQ proofs for our inputs, store in PSBT fields
615665
616-
Notes:
617-
Sets self.sp_all_inputs_ours for callers
618-
619666
Returns:
620667
bool: True if shares were computed
621668
False if we have no signable inputs
@@ -644,14 +691,12 @@ def _compute_and_store_ecdh_shares(self, sv):
644691
raise FatalPSBTIssue("No eligible inputs for ECDH computation")
645692
return False
646693

647-
self.sp_all_inputs_ours = not has_foreign
648-
649694
scan_keys = self._get_silent_payment_scan_keys()
650695
if not scan_keys:
651696
return False
652697

653698
for scan_key in scan_keys:
654-
if self.sp_all_inputs_ours:
699+
if not has_foreign:
655700
# Single-signer: combine all input private keys, one global ECDH share and DLEQ proofs
656701
combined_sk = _sum_privkeys(sk for _, sk in input_material)
657702
ecdh_share = _compute_ecdh_share(combined_sk, scan_key)
@@ -714,6 +759,10 @@ def _compute_silent_payment_output_scripts(self):
714759
# Utility Functions
715760
# -----------------------------------------------------------------------------
716761

762+
def has_silent_payment_inputs(self):
763+
"""Check if PSBT contains any silent payment inputs"""
764+
return any(inp.is_sp_spend for inp in self.inputs)
765+
717766
def has_silent_payment_outputs(self):
718767
"""
719768
Check if PSBT contains any silent payment outputs
@@ -733,17 +782,59 @@ def _all_sp_outputs_have_scripts(self):
733782
return False
734783
return True
735784

736-
def _detect_sp_change_outputs(self):
785+
def _detect_sp_change_outputs(self, sv):
737786
"""
738-
Mark SP outputs as change if sp_v0_label is present and equals 0 (BIP-352 change convention)
787+
Mark SP outputs as change if they match our accounts
739788
740-
No return value; modifies self.outputs 'is_change' in place
789+
Raises FraudulentChangeOutput if all SP-eligible inputs are ours (single-signer);
790+
multi-signer, a non-matching label=0 output is skipped (may belong to a co-signer).
741791
"""
742-
for outp in self.outputs:
792+
has_foreign = False
793+
for inp in self.inputs:
794+
if not self._is_input_eligible(inp):
795+
continue
796+
if inp.sp_idxs:
797+
if self._derive_input_privkey(inp, sv) is None:
798+
has_foreign = True
799+
break
800+
elif self._pubkey_from_input(inp):
801+
has_foreign = True
802+
break
803+
804+
coin_type = chains.current_chain().b44_cointype
805+
candidate_accounts = self._get_candidate_sp_accounts()
806+
807+
for out_idx, outp in enumerate(self.outputs):
743808
if not outp.sp_v0_info or not outp.sp_v0_label:
744809
continue
745-
if int.from_bytes(outp.sp_v0_label, "little") == 0:
746-
outp.is_change = True
810+
if int.from_bytes(outp.sp_v0_label, "little") != 0:
811+
continue
812+
813+
scan_pub = outp.sp_v0_info[:33]
814+
spend_pub = outp.sp_v0_info[33:66]
815+
816+
matched = False
817+
for account in candidate_accounts:
818+
base = sp_derive_path(coin_type, account)
819+
scan_node = sv.derive_path(base + "/1h/0", register=False)
820+
spend_node = sv.derive_path(base + "/0h/0", register=False)
821+
label_spend_pub = _apply_label_to_spend_key(spend_node.pubkey(), scan_node.privkey(), 0)
822+
matched = (scan_node.pubkey() == scan_pub) and (label_spend_pub == spend_pub)
823+
del scan_node, spend_node
824+
if matched:
825+
outp.is_change = True
826+
break
827+
828+
if not matched and not has_foreign:
829+
raise FraudulentChangeOutput(out_idx, "SP change output keys do not match this device")
830+
831+
def _get_candidate_sp_accounts(self):
832+
"""Get all known account numbers from device settings, always include account 0"""
833+
seen = {0}
834+
for _af, acct_num in settings.get('accts', []):
835+
if acct_num:
836+
seen.add(acct_num)
837+
return sorted(seen)
747838

748839
def _get_outpoints(self):
749840
"""

testing/devtest/unit_silentpayments.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,6 @@ def _make_sp_output(scan_key):
458458
(MY_XFP, 44, 0, 0, 1): TEST_PRIVKEY2,
459459
}
460460
)
461-
assert psbt.sp_all_inputs_ours is True
462-
463461
# Foreign input
464462
psbt = MockPSBT()
465463
inp_ours = _make_eligible_input(pk_ours, TEST_DERIV_OURS, b"\x01" * 32, b"\x00" * 4)
@@ -469,8 +467,6 @@ def _make_sp_output(scan_key):
469467
outp.sp_v0_info = TEST_SCAN_KEY + TEST_SPEND_KEY
470468
psbt.outputs = [outp]
471469
psbt._compute_and_store_ecdh_shares({TEST_DERIV_OURS: TEST_PRIVKEY})
472-
assert psbt.sp_all_inputs_ours is False
473-
474470
# No signable inputs
475471
psbt = MockPSBT()
476472
inp = _make_eligible_input(b"\x02" + b"\xbb" * 32, (FOREIGN_XFP, 44, 0, 0, 0), b"\x01" * 32, b"\x00" * 4)

0 commit comments

Comments
 (0)