firmware/shared/web2fa.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

178 lines
5.8 KiB
Python

# (c) Copyright 2021 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# web2fa.py -- Bounce a shared secret off a Coinkite server to allow mobile app 2FA.
#
#
import ngu, ndef, aes256ctr
from utils import b2a_base64url, url_quote, B2A
from version import has_qr
from ux import show_qr_code, ux_show_story, X
# Only Coldcard.com server knows private key for this pubkey. It protects
# the privacy of the values we send to the server.
#
# = 0231301ec4acec08c1c7d0181f4ffb8be70d693acccc86cccb8f00bf2e00fcabfd
SERVER_PUBKEY = b'\x02\x31\x30\x1e\xc4\xac\xec\x08\xc1\xc7\xd0\x18\x1f\x4f\xfb\x8b\xe7\x0d\x69\x3a\xcc\xcc\x86\xcc\xcb\x8f\x00\xbf\x2e\x00\xfc\xab\xfd'
def encrypt_details(qs):
# encryption and base64 here
# - pick single-use ephemeral secp256k1 keypair
# - do ECDH to generate a shared secret based on known pubkey of server
# - AES-256-CTR encryption based on that
# - base64url encode result
# pick a random key pair, just for this session
pair = ngu.secp256k1.keypair()
my_pubkey = pair.pubkey().to_bytes(False) # compressed format
session_key = pair.ecdh_multiply(SERVER_PUBKEY)
del pair
enc = aes256ctr.new(session_key).cipher
return b2a_base64url(my_pubkey + enc(qs.encode('ascii')))
async def perform_web2fa(label, shared_secret):
# send them to web, prompt for valid response. Return True if it all worked.
expect = await nfc_share_2fa_link(label, shared_secret)
if not expect:
# aborted at NFC step
return False
if has_qr:
# Make them scan the result, for example:
#
# CCC-AUTH:E902B3DAF2D98040F3A5F556D7CCC7C22BF3D455C146C4D4C0F7CF8B7937C530
#
from ux_q1 import QRScannerInteraction
from exceptions import QRDecodeExplained
prefix = 'CCC-AUTH:'
scanner = QRScannerInteraction()
def validate(got):
if not got.startswith(prefix):
raise QRDecodeExplained("QR isn't from our site")
if got != prefix+expect:
# probably attempted replay
raise QRDecodeExplained("Incorrect code?")
return got
data = await scanner.scan_general('Scan QR shown from Web', validate)
if not data:
return False # pressed cancel
# only one legal response possible, and already validated above
return data == (prefix+expect)
else:
#
# Mk4 and other devices w/o QR scanner, require user to enter 8 digits
#
from ux_mk4 import ux_input_digits
while 1:
got = await ux_input_digits('', maxlen=8,
prompt="8-digits From Web")
if not got:
# abort if empty entry
return False
if got == expect:
# good match
return True
ch = await ux_show_story("You entered an incorrect code. You must"
" enter the digits shown after the correct"
" 2FA code is provided to the website."
" Try again or %s to stop." % X)
if ch == 'x':
return False
# not reached
return False
async def web2fa_enroll(label, ss=None):
#
# Enroll: Pick a secret and test they have loaded it into their phone.
#
# must have NFC tho
from flow import feature_requires_nfc
if not await feature_requires_nfc():
# they don't want to proceed
return None
# Pick a shared secret; 10 bytes, so encodes to 16 base32 chars
ss = ss or ngu.codecs.b32_encode(ngu.random.bytes(10))
# show a QR that app know how to use
# - problem: on Mk4, not really enough space:
# - can only show up to 42 chars, and secret is 16, required overhead is 23 => 39 min
# - can't fit any metadata, like username or our serial # in there
# - better on Q1 where no limitations for this size of QR
qr = 'otpauth://totp/{nm}?secret={ss}'.format(ss=ss,
nm=url_quote(label if has_qr else label[0:4]))
while 1:
# show QR for enroll
await show_qr_code(qr, is_alnum=False, msg="Import into 2FA Mobile App",
force_msg=True)
# important: force them to prove they store it correctly
ok = await perform_web2fa('Enroll: ' + label, ss)
if ok: break
ch = await ux_show_story("That isn't correct. Please re-import and/or "
"try again or %s to give up." % X)
if ch == 'x':
# mk4 only?
return None
return ss
def make_web2fa_url(wallet_name, shared_secret):
# Build complex URL into our server w/ encrypted data
# - picking a nonce in the process
prefix = 'coldcard.com/2fa?'
# random nonce: if we get this back, then server approves of TOTP answer
if has_qr:
# data for a QR
nonce = B2A(ngu.random.bytes(32)).upper()
else:
# 8 digits for human entry
nonce = '%08d' % ngu.random.uniform(1_0000_0000)
# compose URL
qs = 'g=%s&ss=%s&nm=%s&q=%d' % (nonce, shared_secret, url_quote(wallet_name), has_qr)
# encrypt that
qs = encrypt_details(qs)
return nonce, prefix + qs
async def nfc_share_2fa_link(wallet_name, shared_secret):
#
# Share complex NFC deeplink into 2fa backend; returns expected response-code.
# Next step is to prompt for that 8-digit code (mk4) or scan QR (Q)
#
from glob import NFC
assert NFC
nonce, url = make_web2fa_url(wallet_name, shared_secret)
n = ndef.ndefMaker()
n.add_url(url, https=True)
aborted = await NFC.share_start(n, prompt="Tap for 2FA Authentication",
line2="Wallet: " + wallet_name)
return None if aborted else nonce
# EOF