2fa link encryption, tests
This commit is contained in:
parent
44dae36141
commit
0849e538b5
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user