Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
82 changes: 82 additions & 0 deletions docs/silentpayments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Silent Payments

**COLDCARD<sup>&reg;</sup>** `EDGE` versions support [Silent Payments](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki) from version `TBD`.

COLDCARD implements the following BIPs:

* Silent Payments [BIP-352](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki)
* Sending Silent Payments with PSBTs [BIP-375](https://github.com/bitcoin/bips/blob/master/bip-0375.mediawiki)
* Spending Silent Payment outputs with PSBTs [BIP-376](https://github.com/bitcoin/bips/blob/master/bip-0376.mediawiki)

## Why Silent Payments?

**Automatic privacy by default** — share one static address publicly; each payment produces a unique on-chain output

## How It Works

### Silent Payments Address Generation

* the sender collects the input public key(s) from the transaction being built
* a shared secret is derived via ECDH between the combined input key and the receiver's scan public key (part of the SP Address)
* the final output is a Taproot address derived from tweaking the receiver's spend public key (part of the SP Address) and shared secret
* only the holder of the scan private key can detect incoming payments; only the holder of the spend private key can sign for them

### ECDH Shares & DLEQ Proofs

The shared secret can be computed from either side:

* **sender side**: `shared_secret = (a_1 + a_2 + ...) * B_scan` — sum of input private keys × receiver's scan public key
* **receiver side**: `shared_secret = (A_1 + A_2 + ...) * b_scan` — sum of input public keys x receiver's scan private key

In single-signer flows, COLDCARD performs the full sender-side ECDH internally.

In multi-signer flows (multiple input owners), each signer computes a *partial ECDH share*:

* `share_i = a_i * B_scan` — the signer's input private key × the receiver's scan public key
* the coordinator sums all shares: `sum(share_i) = (a_1 + a_2 + ...) * B_scan => shared_secret`
* each share is accompanied by a **DLEQ proof** (Discrete Log Equality) so the coordinator or signers can verify the shares were computed from the correct input secret key

### SP Output Computation Before Signing

* before any signing round, the coordinator must derive the correct output script from the SP address and the transaction inputs
* this requires the full set of input public keys and the complete shared secret (assembled from partial ECDH shares)
* the computed output is inserted into the PSBT; signers verify that the output in the PSBT matches the expected tweak before signing
* a signer that cannot recompute the expected output MUST refuse to sign

## Limitations

* one of the transaction inputs must be eligible for ["Shared Secret Derivation"](https://github.com/bitcoin/bips/blob/master/bip-0352.mediawiki#user-content-Inputs_For_Shared_Secret_Derivation)
* MuSig2 and FROST are not supported yet

## Example

SP Address encodes two public keys: a *scan key* and a *spend key*
```
sp1qqw5jexmu4358tr090qld3egjxkvwftgnwzg7g2v86wad3gywxkln6qcc0kmh5k03cheul53fd7r7h4lg9y3xkrmz3k00ujulyg2pfcaevu9nurf3
```

### Partial Signing (Collaborative Inputs - Multiple Signers)
Round 1 — ECDH share collection
- Coordinator builds PSBT with inputs from each participant and partial outputs (output script not yet finalized)
- Each input owner contributes their partial ECDH share a_i * B_scan and DLEQ proof into the PSBT
- Last contributor verifies all DLEQ proofs, combines partial shares → computes shared secret → computes final output script, updates PSBT

Round 2 — Sign
- Each signer verifies the output scripts in the PSBT then signs their inputs normally
- Coordinator finalizes and broadcasts

## Development

### start simulator
```
cd unix
% ./simulator.py --q1
```

### execute tests
```
cd testing
pytest test_bip352_vectors.py
pytest test_bip375_vectors.py
pytest test_silentpayments.py
```
2 changes: 1 addition & 1 deletion external/ckcc-protocol
Submodule ckcc-protocol updated 2 files
+118 −0 ckcc/cli.py
+12 −0 ckcc/constants.py
2 changes: 1 addition & 1 deletion external/libngu
Submodule libngu updated 3 files
+19 −1 bech32.patch
+65 −0 ngu/codecs.c
+126 −0 ngu/k1.c
46 changes: 44 additions & 2 deletions shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,38 @@ 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 Payments outputs to make preview useful
with stash.SensitiveValues() as sv:
if self.psbt.has_silent_payment_inputs():
self.psbt.validate_silent_payment_inputs(sv)

if self.psbt.has_silent_payment_outputs():
if not self.psbt.process_silent_payment_outputs(sv):
# 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 +738,19 @@ 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)
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 +769,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 +1773,8 @@ 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)
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
159 changes: 159 additions & 0 deletions shared/dleq.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# (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 public key)
# - C = a*B (ECDH share point)
#
# This proves the ECDH computation is correct without revealing the private key
#
import ngu
from precomp_tag_hash import DLEQ_TAG_AUX_H, DLEQ_TAG_NONCE_H, DLEQ_TAG_CHALLENGE_H

G = ngu.secp256k1.generator()
SECP256K1_ORDER = ngu.secp256k1.curve_order_int()


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


def dleq_challenge(A, B, C, R1, R2, m=None, _G=None):
"""Compute DLEQ challenge using BIP-374 tagged hash"""
if _G is None:
_G = ngu.secp256k1.ec_pubkey_serialize(G, compressed=True)

# BIP-374: e = TaggedHash("BIP0374/challenge", cbytes(A) || cbytes(B) || cbytes(C) || cbytes(G) || cbytes(R1) || cbytes(R2) || m)
challenge_input = A + B + C + _G + R1 + R2
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, B_scan, aux_rand=None, m=None):
"""
Generate DLEQ proof (BIP-374)

Args:
a_sum: Input private key a (32-byte scalar)
B_scan: Scan public key B (33-byte compressed)
aux_rand: Auxiliary randomness r (32-byte or None)
m: Optional message (32-byte or None)

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

Raises:
ValueError: If inputs are invalid
"""
a_sum_int = int.from_bytes(a_sum, "big")
if not (0 < a_sum_int < SECP256K1_ORDER):
raise ValueError("Invalid input private key: not in valid scalar range")

# Compute public key A = a*G
A_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(G, a_sum)

# Compute ECDH share C = a*B
C_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(B_scan, a_sum)

if aux_rand is None:
aux_rand = ngu.random.bytes(32)
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(a_sum, 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") % SECP256K1_ORDER
if k == 0:
raise ValueError("Generated nonce k is zero")

# R1 = k*G, R2 = k*B
k_bytes = k.to_bytes(32, "big")
R1_bytes = ngu.secp256k1.ec_pubkey_tweak_mul(G, 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=G)

# s = (k + e*a) mod n
s = (k + e * a_sum_int) % SECP256K1_ORDER

# 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):
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)

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

Returns:
bool: True if proof is valid, False otherwise
"""
if len(proof) != 64:
return False
if m is not None and len(m) != 32:
return False

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

s = int.from_bytes(s_bytes, "big")
if not (0 < s < SECP256K1_ORDER):
return False

# 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, 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=G)

# Verify e == e'
e = int.from_bytes(e_bytes, "big")
return e == e_check
18 changes: 18 additions & 0 deletions shared/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from charcodes import KEY_NFC, KEY_CANCEL, KEY_QR
from ownership import OWNERSHIP
from exceptions import QRTooBigError
from silentpayments import encode_silent_payment_address, sp_derive_path

async def export_by_qr(body, label, type_code, force_bbqr=False):
# render as QR and show on-screen
Expand Down Expand Up @@ -444,6 +445,23 @@ def generate_generic_export(account_num=0):
node.derive(0, False).derive(0, False)
rv[name]['first'] = chain.address(node, fmt)

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

spscan = encode_silent_payment_address(scan_node.privkey(), spend_node.pubkey())
rv["bip352"] = OrderedDict(
spscan=spscan,
deriv=sp_deriv,
name="p2tr",
xfp=xfp2str(swab32(sp_node.my_fp())),
key_exp="[%s/%s]%s" % (master_xfp_str.lower(),
sp_deriv.replace("m/", ""),
spscan),
)

sig_deriv = "m/44h/{ct}h/{acc}h".format(ct=chain.b44_cointype, acc=account_num) + "/0/0"
return ujson.dumps(rv), sig_deriv, AF_CLASSIC

Expand Down
Loading