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
257 lines
8.0 KiB
Python
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
|