2fa link encryption, tests

This commit is contained in:
Peter D. Gray 2024-10-16 11:06:51 -04:00 committed by scgbckbone
parent 44dae36141
commit 0849e538b5
3 changed files with 73 additions and 29 deletions

View File

@ -3,12 +3,15 @@
# web2fa.py -- Bounce a shared secret off a Coinkite server to allow mobile app 2FA.
#
#
import ngu, ndef
import ngu, ndef, aes256ctr
from ustruct import pack, unpack
from utils import b2a_base64url, url_quote, B2A
from version import has_qr
from ux import show_qr_code, ux_show_story, X, OK
# only Coinkite server knows private key for this
SERVER_PUBKEY = b'\x03\x6d\x0f\x95\xc3\xaa\xf5\xcd\x3e\x8b\xe5\x61\xb0\x78\x14\xfb\xb1\xc9\xee\x21\x71\xed\x30\x18\x28\x15\x19\x75\x41\x14\x72\xa2\xfd'
def encrypt_details(qs):
# encryption and base64 here
# - pick single-use ephemeral secp256k1 keypair
@ -16,8 +19,16 @@ def encrypt_details(qs):
# - AES-256-CTR encryption based on that
# - base64url encode result
# TODO
return qs
# 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):
@ -51,8 +62,6 @@ async def perform_web2fa(label, shared_secret):
return False # pressed cancel
# only one legal response possible, and already validated above
print("data = %r" % data)
print("expect = %r" % expect)
return (data == prefix+expect)
else:
@ -112,7 +121,6 @@ async def web2fa_enroll(label, ss=None):
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")
@ -120,23 +128,17 @@ async def web2fa_enroll(label, ss=None):
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)
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
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
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
@ -144,7 +146,7 @@ async def nfc_share_2fa_link(wallet_name, shared_secret):
# data for a QR
nonce = B2A(ngu.random.bytes(32)).upper()
else:
# 8 digits
# 8 digits for human entry
nonce = '%08d' % ngu.random.uniform(1_0000_0000)
# compose URL
@ -153,8 +155,20 @@ async def nfc_share_2fa_link(wallet_name, shared_secret):
# 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(prefix + qs, https=True)
n.add_url(url, https=True)
aborted = await NFC.share_start(n, prompt="Tap for 2FA Authentication",
line2="Wallet: " + wallet_name)

View File

@ -31,6 +31,8 @@ def pytest_addoption(parser):
default=False, help="run on real dev")
parser.addoption("--sim", action="store_true",
default=True, help="run on simulator")
parser.addoption("--localhost", action="store_true",
default=False, help="test web stuff against coldcard.com code running on localhost:5070")
parser.addoption("--manual", action="store_true",
default=False, help="operator must press keys on real CC")

View File

@ -57,11 +57,13 @@ def make_session_key(his_pubkey=None):
@pytest.fixture
def make_2fa_url():
def make_2fa_url(request):
def doit(shared_secret=b'A'*16, nonce='12345678',
wallet='Example wallet name', is_q=0, prod=True, encrypted=False):
wallet='Example wallet name', is_q=0, encrypted=False):
base = 'http://127.0.0.1:5070/2fa?' if not prod else 'https://coldcard.com/2fa?'
lh = request.config.getoption("--localhost")
base = 'http://127.0.0.1:5070/2fa?' if lh else 'https://coldcard.com/2fa?'
assert is_q in {0, 1}
assert len(shared_secret) == 16 # base32
@ -82,7 +84,7 @@ def make_2fa_url():
import pyaes
enc = pyaes.AESModeOfOperationCTR(ses_key, pyaes.Counter(0)).encrypt
qs = urlsafe_b64encode(pubkey + enc(qs.encode('ascii')))
qs = urlsafe_b64encode(pubkey + enc(qs.encode('ascii'))).rstrip(b'=')
return base + qs.decode('ascii')
@ -98,9 +100,11 @@ def roundtrip_2fa():
# avoid end of time period
time.sleep(3)
# build right TOTP answer
answer = '%06d' % get_totp(shared_secret)
assert len(answer) == 6
# send both request and answer at same time (we know it works that way)
resp = requests.post(url, data=dict(answer=answer))
# server HTML will have this line in response for our use
@ -125,10 +129,9 @@ def test_2fa_server(shared_secret, q_mode, make_2fa_url, enc, roundtrip_2fa):
nonce = prandom(32).hex() if q_mode else str(random.randint(1000_0000, 9999_9999))
# TODO command line flag to select local coldcard.com or production version
url = make_2fa_url(shared_secret, nonce, is_q=int(q_mode), encrypted=enc, prod=True)
# NOTE: use '--localhost' command line flag to select local coldcard.com or production
url = make_2fa_url(shared_secret, nonce, is_q=int(q_mode), encrypted=enc)
#print(url)
ans = roundtrip_2fa(url, shared_secret)
@ -138,6 +141,31 @@ def test_2fa_server(shared_secret, q_mode, make_2fa_url, enc, roundtrip_2fa):
# NOTE: cannot re-start same test until next 30-second period because of rate limiting
# check on server side.
@pytest.mark.parametrize('shared_secret', [ '6SPAJXWD3XJTUQWO'])
@pytest.mark.parametrize('label_len', [ 10] + list(range(20,25)))
@pytest.mark.parametrize('q_mode', [ True, False] )
def test_2fa_links(shared_secret, label_len, q_mode, roundtrip_2fa, sim_exec, request):
# Unit test for embedded encryption and padding of special links
# NOTE: use '--localhost' command line flag to select local coldcard.com vs. production
lh = request.config.getoption("--localhost")
label = 'Z' * label_len
z= sim_exec(f'from web2fa import make_web2fa_url; RV.write(repr(make_web2fa_url({label!r}, {shared_secret!r})))')
nonce, url = eval(z)
assert '/2fa' in url
assert url.startswith('coldcard.com') # protocol would be added by NDEF
if lh:
url = url.replace('coldcard.com', 'http://127.0.0.1:5070')
else:
url = 'https://' + url
# test the server would work on this
ans = roundtrip_2fa(url, shared_secret)
assert ans == f'CCC-AUTH:{nonce}'.upper() if q_mode else nonce
@pytest.fixture
def get_last_violation(sim_exec):
def doit():
@ -165,7 +193,7 @@ def setup_ccc(goto_home, pick_menu_item, cap_story, press_select, pass_word_quiz
assert "(2)" in story
if c_words is None:
nwords = 12 # always 12 words if generate by us
nwords = 12 # always 12 words if generated by us
press_select()
time.sleep(.1)
title, story = cap_story()
@ -304,8 +332,8 @@ def enter_enabled_ccc(goto_home, pick_menu_item, cap_story, press_select, is_q1,
time.sleep(.1)
title, story = cap_story()
assert title == "CCC Enabled"
assert "policy cannot be viewed, changed nor disabled while on the road" in story
assert "if you have the seed words (for key C) you may proceed" in story
assert "policy cannot be viewed, changed" in story
assert "if you have the seed words" in story
press_select()
time.sleep(.1)
word_menu_entry(c_words)