Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion external/ckcc-protocol
2 changes: 1 addition & 1 deletion external/libngu
44 changes: 42 additions & 2 deletions shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,34 @@ async def interact(self):

ccc_c_xfp = CCCFeature.get_xfp() # can be None
args = self.psbt.consider_inputs(cosign_xfp=ccc_c_xfp)

# Silent Payments: Validate and pre-process Silent Payment outputs
if self.psbt.has_silent_payment_outputs():
if not self.psbt.preview_silent_payment_outputs():
# Coverage incomplete: shares computed but waiting on other signers.
# Skip normal approval flow — prompt user to contribute shares, then save.
del args
ch = await ux_show_story(
"Silent payment ECDH shares will be added to this transaction.\n\n"
"Other signers must contribute their shares before signing can proceed.\n\n"
"Press %s to contribute shares. %s to abort." % (OK, X),
title="CONTRIBUTE SHARES?"
)
if ch != 'y':
self.refused = True
await ux_dramatic_pause("Refused.", 1)
del self.psbt
self.done()
return
try:
await done_signing(self.psbt, self, self.input_method,
self.filename, self.output_encoder,
finalize=False)
self.done()
except BaseException as exc:
return await self.failure("PSBT output failed", exc)
return

self.psbt.consider_outputs(*args, cosign_xfp=ccc_c_xfp)
del args # not needed anymore
# we can properly assess sighash only after we know
Expand Down Expand Up @@ -706,14 +734,20 @@ def output_summary_text(self, msg):
has_change = True
total_change += tx_out.nValue
if len(largest_change) < MAX_VISIBLE_CHANGE:
largest_change.append((tx_out.nValue, self.chain.render_address(tx_out.scriptPubKey)))
addr = self.chain.render_address(tx_out.scriptPubKey)
if outp.sp_v0_info:
addr += '\n' + self.psbt.render_silent_payment_output_string(outp)
largest_change.append((tx_out.nValue, addr))
if len(largest_change) == MAX_VISIBLE_CHANGE:
largest_change = sorted(largest_change, key=lambda x: x[0], reverse=True)
continue

else:
if len(largest_outs) < MAX_VISIBLE_OUTPUTS:
rendered, _ = self.render_output(tx_out)
# render silent payment address
if outp.sp_v0_info:
rendered += self.psbt.render_silent_payment_output_string(outp)
largest_outs.append((tx_out.nValue, rendered))
if len(largest_outs) == MAX_VISIBLE_OUTPUTS:
# descending sort from the biggest value to lowest (sort on out.nValue)
Expand All @@ -732,7 +766,10 @@ def output_summary_text(self, msg):

largest.pop(-1)
if outp.is_change:
ret = (here, self.chain.render_address(tx_out.scriptPubKey))
addr = self.chain.render_address(tx_out.scriptPubKey)
if outp.sp_v0_info:
addr += '\n' + self.psbt.render_silent_payment_output_string(outp)
ret = (here, addr)
else:
rendered, _ = self.render_output(tx_out)
ret = (here, rendered)
Expand Down Expand Up @@ -1733,6 +1770,9 @@ def yield_item(self, offset, end, qr_items, change_idxs):
outp = self.user_auth_action.psbt.outputs[idx]
item = "Output %d%s:\n\n" % (idx, " (change)" if outp.is_change else "")
msg, addr_or_script = self.user_auth_action.render_output(out)
# render silent payment address
if outp.sp_v0_info:
msg += self.user_auth_action.psbt.render_silent_payment_output_string(outp)
item += msg
qr_items.append(addr_or_script)
if outp.is_change:
Expand Down
3 changes: 3 additions & 0 deletions shared/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class ChainsBase:
curve = 'secp256k1'
menu_name = None # use 'name' if this isn't defined
ccc_min_block = 0
sp_hrp = 'sp' # BIP-352 Silent Payment address HRP (default mainnet)

# b44_cointype comes from
# <https://github.com/satoshilabs/slips/blob/master/slip-0044.md>
Expand Down Expand Up @@ -341,6 +342,7 @@ class BitcoinTestnet(ChainsBase):
}

bech32_hrp = 'tb'
sp_hrp = 'tsp' # BIP-352 Silent Payment testnet HRP

b58_addr = bytes([111])
b58_script = bytes([196])
Expand All @@ -353,6 +355,7 @@ class BitcoinRegtest(BitcoinTestnet):
ctype = 'XRT'
name = 'Bitcoin Regtest'
bech32_hrp = 'bcrt'
sp_hrp = 'tsp' # BIP-352 Silent Payment regtest HRP


def get_chain(short_name):
Expand Down
213 changes: 213 additions & 0 deletions shared/dleq.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# (c) Copyright 2024 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# dleq.py - DLEQ (Discrete Logarithm Equality) proofs for BIP-374
#
# Implements non-interactive zero-knowledge proofs that prove:
# log_G(A) = log_B(C)
# where:
# - G is the secp256k1 generator
# - A = a*G (sender's public key)
# - B = (recipient's scan key)
# - C = a*B (ECDH share)
#
# This proves the ECDH computation is correct without revealing the private key.
#
import ckcc
import ngu
from precomp_tag_hash import DLEQ_TAG_AUX_H, DLEQ_TAG_NONCE_H, DLEQ_TAG_CHALLENGE_H


def xor_bytes(a, b):
"""XOR two byte strings of equal length"""
assert len(a) == len(b), "Byte strings must be equal length"
return bytes(x ^ y for x, y in zip(a, b))


def dleq_challenge(A_bytes, B_bytes, C_bytes, R1_bytes, R2_bytes, m=None, G_bytes=None):
"""
Compute DLEQ challenge using BIP-374 tagged hash

Args:
A_bytes: A_sum, A = a*G (33 bytes compressed)
B_bytes: B_scan, B = scan public key (33 bytes compressed)
C_bytes: C_ecdh, C = a*B (33 bytes compressed)
R1_bytes: Commitment R1 = k*G (33 bytes compressed)
R2_bytes: Commitment R2 = k*B (33 bytes compressed)
m: Optional message (32 bytes or None)
G_bytes: Generator point G (33 bytes compressed)

Returns:
int: Challenge value e
"""
# BIP-374: e = TaggedHash("BIP0374/challenge", cbytes(A) || cbytes(B) || cbytes(C) || cbytes(G) || cbytes(R1) || cbytes(R2) || m)
challenge_input = A_bytes + B_bytes + C_bytes + G_bytes + R1_bytes + R2_bytes

# Append message if provided
if m is not None:
challenge_input += m

challenge_hash = ngu.hash.sha256t(DLEQ_TAG_CHALLENGE_H, challenge_input, True)
return int.from_bytes(challenge_hash, 'big')


def generate_dleq_proof(a_sum_scalar, B_scan, aux_rand=None, m=None):
"""
Generate DLEQ proof (BIP-374)

Args:
a_sum_scalar: Input private key a (scalar)
B_scan: Scan public key B (33 bytes compressed)
aux_rand: Auxiliary randomness r (32 bytes), if None uses hardware RNG
m: Optional message (32 bytes or None)

Returns:
bytes: DLEQ proof (64 bytes: e || s)

Raises:
ValueError: If inputs are invalid
"""
# Validate inputs
if not isinstance(a_sum_scalar, int) or a_sum_scalar <= 0:
raise ValueError("Invalid private key")

if len(B_scan) != 33:
raise ValueError("Invalid scan pubkey length")

if m is not None and len(m) != 32:
raise ValueError("Message must be 32 bytes")

# Validate scan_pubkey is a valid point
try:
ngu.secp256k1.pubkey(B_scan)
except Exception:
raise ValueError("Invalid elliptic curve point")

# Validate privkey is in valid range
if a_sum_scalar >= ngu.secp256k1.curve_order_int():
raise ValueError("Private key must be less than curve order")

# Compute public key A = a*G
G_bytes = ngu.secp256k1.generator()
privkey_bytes = a_sum_scalar.to_bytes(32, 'big')
A_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(G_bytes, privkey_bytes)

# Compute ECDH share C = a*B (this is what we're proving knowledge of)
C_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(B_scan, privkey_bytes)

# Generate aux_rand if not provided
if aux_rand is None:
aux_rand = bytearray(32)
ckcc.rng_bytes(aux_rand)
aux_rand = bytes(aux_rand)
else:
if len(aux_rand) != 32:
raise ValueError("aux_rand must be 32 bytes")

# t = a XOR TaggedHash("BIP0374/aux", r)
aux_hash = ngu.hash.sha256t(DLEQ_TAG_AUX_H, aux_rand, True)
del aux_rand
t = xor_bytes(privkey_bytes, aux_hash)

# rand = TaggedHash("BIP0374/nonce", t || A || C || m)
nonce_input = t + A_bytes + C_bytes
if m is not None:
nonce_input += m
rand = ngu.hash.sha256t(DLEQ_TAG_NONCE_H, nonce_input, True)

# k = int(rand) mod n
k = int.from_bytes(rand, 'big') % ngu.secp256k1.curve_order_int()
if k == 0:
raise ValueError("Generated nonce k is zero (extremely unlikely)")

# R1 = k*G, R2 = k*B
k_bytes = k.to_bytes(32, 'big')
R1_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(G_bytes, k_bytes)
R2_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(B_scan, k_bytes)

# e = TaggedHash("BIP0374/challenge", A || B || C || G || R1 || R2 || m)
e = dleq_challenge(A_bytes, B_scan, C_bytes, R1_bytes, R2_bytes, m, G_bytes)

# s = (k + e*a) mod n
s = (k + e * a_sum_scalar) % ngu.secp256k1.curve_order_int()

# proof = e || s
proof = e.to_bytes(32, 'big') + s.to_bytes(32, 'big')

# Verify the proof before returning (sanity check)
if not verify_dleq_proof(A_bytes, B_scan, C_bytes, proof, m=m):
raise ValueError("Generated proof failed verification (internal error)")
return proof


def verify_dleq_proof(A_sum_bytes, B_scan_bytes, ecdh_share_bytes, proof, m=None):
"""
Verify DLEQ proof (BIP-374)

Verifies that the prover knows a value a such that:
- A = a * G (pubkey)
- C = a * B (ecdh_share)
without revealing a (the private key).

Args:
A_sum_bytes: Input public key A (33 bytes compressed)
B_scan_bytes: Scan public key B (33 bytes compressed)
ecdh_share_bytes: ECDH share C (33 bytes compressed)
proof: DLEQ proof (64 bytes: e || s)
m: Optional message (32 bytes or None)

Returns:
bool: True if proof is valid, False otherwise
"""
# Validate proof length
if len(proof) != 64:
return False

if m is not None and len(m) != 32:
return False

# Parse proof
e_bytes = proof[:32]
s_bytes = proof[32:]

try:
# Validate scalars are in valid range
s = int.from_bytes(s_bytes, 'big')
if s >= ngu.secp256k1.curve_order_int():
return False
# Note: e can be >= n since it's a hash output reduced mod n

# Validate points
ngu.secp256k1.pubkey(A_sum_bytes)
ngu.secp256k1.pubkey(B_scan_bytes)
ngu.secp256k1.pubkey(ecdh_share_bytes)
except Exception:
# Invalid points
return False

# Get generator point
G_bytes = ngu.secp256k1.generator()

# Reconstruct R1 = s*G - e*A
# We compute this as s*G + (-e)*A using point negation
sG = ngu.secp256k1.ec_pubkey_tweak_mul(G_bytes, s_bytes)
eA = ngu.secp256k1.ec_pubkey_tweak_mul(A_sum_bytes, e_bytes)
# Negate eA by flipping the y-coordinate (change 02<->03 prefix)
eA_neg = bytearray(eA)
eA_neg[0] = 0x03 if eA[0] == 0x02 else 0x02
R1_bytes = ngu.secp256k1.ec_pubkey_combine(sG, bytes(eA_neg))

# Reconstruct R2 = s*B - e*C
sB = ngu.secp256k1.ec_pubkey_tweak_mul(B_scan_bytes, s_bytes)
eC = ngu.secp256k1.ec_pubkey_tweak_mul(ecdh_share_bytes, e_bytes)
# Negate eC
eC_neg = bytearray(eC)
eC_neg[0] = 0x03 if eC[0] == 0x02 else 0x02
R2_bytes = ngu.secp256k1.ec_pubkey_combine(sB, bytes(eC_neg))

# Recompute challenge e'
e_check = dleq_challenge(A_sum_bytes, B_scan_bytes, ecdh_share_bytes,
R1_bytes, R2_bytes, m, G_bytes)

# Verify e == e'
e = int.from_bytes(e_bytes, 'big')
return e == e_check
18 changes: 17 additions & 1 deletion shared/precomp_tag_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,20 @@
# SHA256(TapTweak)
TAP_TWEAK_H = b'\xe8\x0f\xe1c\x9c\x9c\xa0P\xe3\xaf\x1b9\xc1C\xc6>B\x9c\xbc\xeb\x15\xd9@\xfb\xb5\xc5\xa1\xf4\xafW\xc5\xe9'
# SHA256(TapSighash)
TAP_SIGHASH_H = b'\xf4\nH\xdfK*p\xc8\xb4\x92K\xf2eFa\xed=\x95\xfdf\xa3\x13\xeb\x87#u\x97\xc6(\xe4\xa01'
TAP_SIGHASH_H = b'\xf4\nH\xdfK*p\xc8\xb4\x92K\xf2eFa\xed=\x95\xfdf\xa3\x13\xeb\x87#u\x97\xc6(\xe4\xa01'

# BIP-352 tag hashes
# SHA256(BIP0352/SharedSecret)
BIP352_SHARED_SECRET_TAG_H = b'\x9fm\x80\x11X\x1e\xb6-r\xe6\x13`L3\r\xca*\x0b\xd3I\xe2JF\xd9\xa2\xef$\xb9\xa9\x8fA\xbd'
# SHA256(BIP0352/Inputs)
BIP352_INPUTS_TAG_H = b'\x1e{\x96\xeb\x16\nh\x81\x9f\x97vKC\xd5\xd7~fY\xd7Xw\x9dC\xa8\xa7u_[\xe4Z~3'
# SHA256(BIP0352/Label)
BIP352_LABEL_TAG_H = b'\x03I\x19F5\xc2\xd0>b\xd4\x13\xba\x8c\xcdQ\x98\x91\x90\x17\xa1\xe9\x9c\xbei\x1fZ4\xa9\x93w\xe0\x95'

# BIP-374 DLEQ proof tag hashes
# SHA256(BIP0374/aux)
DLEQ_TAG_AUX_H = b'\xfc\xe2u<M\x0e\xd6\x08\x19\x8e"\x97\x149=\x84\x05\x02$g\xa5g\xafNL\xc1L9Q_q\xba'
# SHA256(BIP0374/nonce)
DLEQ_TAG_NONCE_H = b'O\x9e\x00\xe8\xed"#[\xb0\x1b\x1f\x10o\xba(K\xd4\xcb\xb4\x1c\x81]\x17]\x93SBS\x9dH<\x1d'
# SHA256(BIP0374/challenge)
DLEQ_TAG_CHALLENGE_H = b'\xd0\x96\xc8\x00\ns\x1a\x07%\x96\xfe\xadw)\xa6\x14:\x80\x1d\xd7?\xcc\xa1\xccT\xdc:\xbcf\xbc\x1d\xad'
Loading