firmware/testing/teleport_protocol.py
scgbckbone e209980630 improve taptree parser
remove msas (always allowed)
remove unsort_ms (always allowed)
rework fake_txn

# Conflicts:
#	cli/signit.py
#	releases/ChangeLog.md
#	releases/History-Mk4.md
#	releases/Next-ChangeLog.md
#	releases/signatures.txt
#	shared/actions.py
#	shared/address_explorer.py
#	shared/auth.py
#	shared/backups.py
#	shared/chains.py
#	shared/decoders.py
#	shared/descriptor.py
#	shared/display.py
#	shared/export.py
#	shared/flow.py
#	shared/lcd_display.py
#	shared/multisig.py
#	shared/nfc.py
#	shared/notes.py
#	shared/nvstore.py
#	shared/ownership.py
#	shared/paper.py
#	shared/psbt.py
#	shared/qrs.py
#	shared/seed.py
#	shared/serializations.py
#	shared/utils.py
#	shared/ux.py
#	shared/ux_mk4.py
#	shared/ux_q1.py
#	shared/version.py
#	shared/wallet.py
#	shared/xor_seed.py
#	stm32/COLDCARD_MK4/file_time.c
#	stm32/COLDCARD_Q1/file_time.c
#	stm32/MK4-Makefile
#	stm32/Q1-Makefile
#	testing/conftest.py
#	testing/helpers.py
#	testing/test_address_explorer.py
#	testing/test_backup.py
#	testing/test_bbqr.py
#	testing/test_export.py
#	testing/test_msg.py
#	testing/test_multisig.py
#	testing/test_notes.py
#	testing/test_ownership.py
#	testing/test_sign.py
#	testing/test_unit.py
#	testing/txn.py
2025-06-11 15:58:04 +02:00

257 lines
8.0 KiB
Python

# (c) Copyright 2025 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Key Teleport protocol re-implementation.
#
import os, pyaes, hashlib, base64
from bip32 import BIP32Node, PrvKeyNode
from mnemonic import Mnemonic
from pysecp256k1 import ec_seckey_verify, ec_pubkey_serialize, ec_pubkey_parse
from pysecp256k1.extrakeys import keypair_create, keypair_sec, keypair_pub
from pysecp256k1.ecdh import ecdh, ECDH_HASHFP_CLS
wordlist = Mnemonic('english').wordlist
def py_ckcc_hashfp(output, x, y, data=None):
try:
m = hashlib.sha256()
m.update(x.contents.raw)
m.update(y.contents.raw)
output.contents.raw = m.digest()
return 1
except:
return 0
ckcc_hashfp = ECDH_HASHFP_CLS(py_ckcc_hashfp)
def txt_grouper(txt):
# split into 2-char groups and add spaces -- to make it easier to read/remember
return ' '.join(txt[n:n+2] for n in range(0, len(txt), 2))
def stash_encode_secret(words=None, xprv=None):
nv = bytearray(72)
if words:
wlen = len(words.split(" "))
assert wlen in [12, 18, 24]
entropy = Mnemonic('english').to_entropy(words)
nv[0] = (0x80 | ((len(entropy) // 8) - 2))
nv[1:1 + wlen] = entropy
elif xprv:
node = BIP32Node.from_wallet_key(xprv)
nv[0] = 0x01
nv[1:33] = node.chain_code()
nv[33:65] = node.privkey()
# trim zeros
while nv[-1] == 0:
nv = nv[0:-1]
return nv
def stash_decode_secret(secret_bytes):
marker = secret_bytes[0]
if marker == 0x01:
ch, pk = secret_bytes[1:33], secret_bytes[33:65]
n = PrvKeyNode(pk, ch)
node = BIP32Node(netcode='BTC', node=n)
return "xprv", node.hwif(as_private=True)
elif marker & 0x80:
# seed phrase
ll = ((marker & 0x3) + 2) * 8
assert ll in [16, 24, 32]
# make master secret, using the memonic words, and passphrase (or empty string)
seed_bits = secret_bytes[1:1 + ll]
return "words", Mnemonic('english').to_mnemonic(seed_bits)
def generate_rx_code(kp):
# Receiver-side password: given a pubkey (33 bytes, compressed format)
# - construct an 8-digit decimal "password"
# - it's an AES key, but only 26 bits worth
pubkey = bytearray(ec_pubkey_serialize(keypair_pub(kp), compressed=True))
# - want the code to be deterministic, but I also don't want to save it
# - double sha256 TODO why ? single sha is imo enough and twice as fast
nk = hashlib.sha256(hashlib.sha256(keypair_sec(kp) + b'COLCARD4EVER').digest()).digest()
# first byte will be 0x02 or 0x03 (Y coord) -- remove those known 7 bits
pubkey[0] ^= nk[20] & 0xfe
num = '%08d' % (int.from_bytes(nk[4:8], 'big') % 1_0000_0000)
# encryption after baby key stretch
kk = hashlib.sha256(num.encode()).digest()
enc = pyaes.AESModeOfOperationCTR(kk, pyaes.Counter(0)).encrypt
ciphertext = enc(bytes(pubkey))
return num, ciphertext
def decrypt_rx_pubkey(code, payload):
# given an 8-digit numeric code, make the key and then decrypt/checksum check
# - every value works, there is no fail.
kk = hashlib.sha256(code.encode()).digest()
dec = pyaes.AESModeOfOperationCTR(kk, pyaes.Counter(0)).decrypt
rx_pubkey = bytearray(dec(payload))
# first byte will be 0x02 or 0x03 but other 7 bits are noise
rx_pubkey[0] &= 0x01
rx_pubkey[0] |= 0x02
pubkey = bytes(rx_pubkey)
# validate that it's on the curve... otherwise the code is wrong
try:
ec_pubkey_parse(pubkey)
return pubkey
except:
return None
def pick_noid_key():
# pick an 40 bit password, shown as base32
# - on rx, libngu base32 decoder will convert '018' into 'OLB'
# - but a little tempted to removed vowels here?
# TODO what about base64.b32encode
k = os.urandom(5)
txt = base64.b32encode(k).decode()
return k, txt
def noid_stretch(session_key, noid_key):
return hashlib.pbkdf2_hmac('sha512', session_key, noid_key, 5000)[0:32]
def encode_payload(my_keypair, his_pubkey, noid_key, body, for_psbt=False):
assert len(his_pubkey) == 33
assert len(noid_key) == 5
session_key = ecdh(keypair_sec(my_keypair), ec_pubkey_parse(his_pubkey), hashfp=ckcc_hashfp)
# stretch noid key out -- will be slow
pk = noid_stretch(session_key, noid_key)
enc = pyaes.AESModeOfOperationCTR(pk, pyaes.Counter(0)).encrypt
b1 = enc(body)
b1 += hashlib.sha256(body).digest()[-2:]
enc = pyaes.AESModeOfOperationCTR(session_key, pyaes.Counter(0)).encrypt
b2 = enc(b1)
b2 += hashlib.sha256(b1).digest()[-2:]
if for_psbt:
# no need to share pubkey for PSBT files
return b2
return ec_pubkey_serialize(keypair_pub(my_keypair)) + b2
def decode_step1(my_keypair, his_pubkey, body):
# Do ECDH and remove top layer of encryption
try:
session_key = ecdh(keypair_sec(my_keypair), ec_pubkey_parse(his_pubkey), hashfp=ckcc_hashfp)
dec = pyaes.AESModeOfOperationCTR(session_key, pyaes.Counter(0)).decrypt
rv = dec(body[:-2])
chk = hashlib.sha256(rv).digest()[-2:]
assert chk == body[-2:] # likely means wrong rx key, or truncation
except:
return None, None
return session_key, rv
def decode_step2(session_key, noid_key, body):
assert len(noid_key) == 5
pk = noid_stretch(session_key, noid_key)
dec = pyaes.AESModeOfOperationCTR(pk, pyaes.Counter(0)).decrypt
msg = dec(body[:-2])
chk = hashlib.sha256(msg).digest()[-2:]
return msg if chk == body[-2:] else None
def receiver_step1(secret=None):
if secret is None:
secret = os.urandom(32)
ec_seckey_verify(secret)
kpr = keypair_create(secret)
num, payload = generate_rx_code(kpr)
return num, payload, kpr
def sender_step1(num_pwd, encrypted_pubkey, to_send, secret=None):
pkr = decrypt_rx_pubkey(num_pwd, encrypted_pubkey)
# Pick and show noid key to sender
noid_key, noid_txt = pick_noid_key()
if secret is None:
secret = os.urandom(32)
ec_seckey_verify(secret)
kps = keypair_create(secret)
# "to_send" has to be properly encoded (dtype + what)
payload = encode_payload(kps, pkr, noid_key, to_send)
return noid_txt, payload, kps, pkr
def receiver_step2(teleport_pwd, payload, keypair):
assert len(teleport_pwd) == 8
noid_key = base64.b32decode(teleport_pwd)
his_pubkey = payload[0:33]
body = payload[33:]
session_key, body = decode_step1(keypair, his_pubkey, body)
final = decode_step2(session_key, noid_key, body)
if final:
return chr(final[0]), final[1:]
else:
return None, None
def selftest():
# WORDS
# RECEIVER INIT
number_pass, enc_pubkey, kp_receiver = receiver_step1()
# SENDER
# what are we sending ?
words = "talk retire wisdom poet actress hood goose case amateur zebra analyst radar"
cleartext = b"s" + stash_encode_secret(words=words)
noid_txt, encrypted_payload, kp_sender, pk_rec = sender_step1(number_pass, enc_pubkey, cleartext)
# check we properly decrypted receiver pubkey
assert pk_rec == ec_pubkey_serialize(keypair_pub(kp_receiver))
# RECEIVER STEP2
_, received = receiver_step2(noid_txt, encrypted_payload, kp_receiver)
assert words == stash_decode_secret(received)[1]
# ===
# XPRV
# RECEIVER INIT
number_pass, enc_pubkey, kp_receiver = receiver_step1()
# SENDER
# what are we sending ?
xprv = "xprv9s21ZrQH143K4BwRCYKSEPwcAMYweWkfKLURabnnv2GLNhJN1LSCgDQyGWyNcat72najQKwyshCBXWfHHVbcdxPAZPqByMyWDbWp5SjCfEa"
cleartext = b"s" + stash_encode_secret(xprv=xprv)
noid_txt, encrypted_payload, kp_sender, pk_rec = sender_step1(number_pass, enc_pubkey, cleartext)
# check we properly decrypted receiver pubkey
assert pk_rec == ec_pubkey_serialize(keypair_pub(kp_receiver))
# RECEIVER STEP2
_, received = receiver_step2(noid_txt, encrypted_payload, kp_receiver)
assert xprv == stash_decode_secret(received)[1]
# ===
print("Selftest passed.")
if __name__ == "__main__":
selftest()
# EOF