Files
otp-import/test_oath.py
Amal Graafstra f8273fc904 Add Apex OTP importer
Import TOTP credentials from the export spreadsheet into a VivoKey Apex
OTP (ykneo-oath) applet over PC/SC. Includes the OATH protocol layer
(SELECT/VALIDATE/LIST/PUT with 61xx reassembly), PBKDF2 password
authentication, pre-flight input validation, ask-per-conflict handling,
optional javacard-memory profiling, and hardware-free unit tests.
2026-07-08 11:32:56 -07:00

319 lines
12 KiB
Python

"""Hardware-free unit tests for apex_otp_import (OATH protocol layer + input)."""
import os
import tempfile
import unittest
from openpyxl import Workbook
from apex_otp_import import (
tlv, parse_tlvs, decode_secret, pad_key, format_cred_id,
build_put_data, build_validate_data,
derive_key, compute_response,
Credential, validate_input, load_credentials,
CardChannel, OathSession, OathError, parse_memory,
TOTP_TYPE, HOTP_TYPE, HMAC_SHA1, HMAC_SHA256, HMAC_SHA512,
)
class FakeConn:
"""Fake pyscard connection: replays a scripted list of (data, sw1, sw2)."""
def __init__(self, script):
self.script = list(script)
self.sent = []
def transmit(self, apdu):
self.sent.append(apdu)
data, sw1, sw2 = self.script.pop(0)
return list(data), sw1, sw2
def cred(issuer="Iss", account="acct", secret="GEZDGNBVGY3TQOJQ", **kw):
return Credential(issuer=issuer, account=account, secret=secret, **kw)
class TestTlv(unittest.TestCase):
def test_short_form_length(self):
self.assertEqual(tlv(0x71, b"AB"), bytes([0x71, 0x02, 0x41, 0x42]))
def test_boundary_127_is_short_form(self):
self.assertEqual(tlv(0x73, b"\x00" * 127)[:2], bytes([0x73, 0x7F]))
def test_extended_form_128(self):
# length 128 uses 0x81 <len>
self.assertEqual(tlv(0x73, b"\x00" * 128)[:3], bytes([0x73, 0x81, 0x80]))
class TestParseTlvs(unittest.TestCase):
def test_parses_ordered_pairs(self):
data = bytes([0x79, 0x03, 0x00, 0x01, 0x02, 0x71, 0x02, 0xAA, 0xBB])
self.assertEqual(
parse_tlvs(data),
[(0x79, b"\x00\x01\x02"), (0x71, b"\xAA\xBB")],
)
def test_preserves_repeated_tags(self):
# LIST returns repeated 0x72 entries
data = tlv(0x72, b"\x21A") + tlv(0x72, b"\x21B")
self.assertEqual(parse_tlvs(data), [(0x72, b"\x21A"), (0x72, b"\x21B")])
def test_round_trips_extended_length(self):
val = bytes(range(256)) * 2 # 512 bytes -> 0x82 form
self.assertEqual(parse_tlvs(tlv(0x73, val)), [(0x73, val)])
class TestSecret(unittest.TestCase):
def test_rfc6238_vector_decodes_to_known_bytes(self):
# RFC 6238 seed "12345678901234567890" == this Base32
self.assertEqual(
decode_secret("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"),
b"12345678901234567890",
)
def test_lowercase_and_spaces_and_missing_padding(self):
self.assertEqual(decode_secret("ge zd gn bv"), decode_secret("GEZDGNBV"))
def test_pad_short_key_to_14_zero_filled(self):
raw = decode_secret("JBSWY3DPEHPK3PXP") # 16 chars -> 10 bytes
self.assertEqual(len(raw), 10)
padded = pad_key(raw)
self.assertEqual(len(padded), 14)
self.assertEqual(padded[:10], raw)
self.assertEqual(padded[10:], b"\x00\x00\x00\x00")
def test_pad_leaves_long_enough_key_unchanged(self):
raw = b"\x01" * 20
self.assertEqual(pad_key(raw), raw)
class TestCredId(unittest.TestCase):
def test_default_period_is_issuer_colon_account(self):
self.assertEqual(format_cred_id("Google", "amal@amal.net", 30),
"Google:amal@amal.net")
def test_non_default_period_prefixed(self):
self.assertEqual(format_cred_id("Steam", "user", 15), "15/Steam:user")
def test_empty_issuer_is_just_account(self):
self.assertEqual(format_cred_id("", "amal", 30), "amal")
class TestPutData(unittest.TestCase):
def test_byte_exact_put_payload(self):
key = b"\x01" * 14
data = build_put_data("AB", TOTP_TYPE | HMAC_SHA1, 6, key)
expected = (
bytes([0x71, 0x02, 0x41, 0x42]) # NAME "AB"
+ bytes([0x73, 0x10, 0x21, 0x06]) + key # KEY: len=2+14, typealgo, digits
)
self.assertEqual(data, expected)
def test_totp_sha1_type_byte_is_0x21(self):
self.assertEqual(TOTP_TYPE | HMAC_SHA1, 0x21)
class TestValidateData(unittest.TestCase):
def test_byte_exact_validate_payload(self):
resp = b"\xAA" * 20
chal = b"\xBB" * 8
data = build_validate_data(resp, chal)
self.assertEqual(data, bytes([0x75, 0x14]) + resp + bytes([0x74, 0x08]) + chal)
class TestPasswordAuth(unittest.TestCase):
SALT = bytes([0x19, 0x83, 0x57, 0x1E, 0xC3, 0xE5, 0xAB, 0xD7])
def test_derive_key_pbkdf2_1000_rounds_16_bytes(self):
key = derive_key("test1234", self.SALT)
self.assertEqual(key.hex(), "aa69a3f71eec1660849d7bb16c313e32")
self.assertEqual(len(key), 16)
def test_compute_response_matches_rfc2202_hmac_sha1(self):
# RFC 2202 HMAC-SHA1 test case 1
resp = compute_response(b"\x0b" * 20, b"Hi There", HMAC_SHA1)
self.assertEqual(resp.hex(), "b617318655057264e28bc0b6fb378c8ef146be00")
class TestValidateInput(unittest.TestCase):
def test_clean_input_has_no_problems(self):
creds = [cred(account="a@x.com"), cred(account="b@x.com")]
self.assertEqual(validate_input(creds), [])
def test_duplicate_cred_id_is_reported(self):
# same issuer+account, different secret -> the SAW/michelle case
creds = [
cred(issuer="SAW", account="m@d.com", secret="GEZDGNBVGY3TQOJQ", source="a.png"),
cred(issuer="SAW", account="m@d.com", secret="MZXW6YTBOI======", source="b.png"),
]
problems = validate_input(creds)
self.assertEqual(len(problems), 1)
self.assertIn("SAW:m@d.com", problems[0])
def test_invalid_base32_is_reported(self):
problems = validate_input([cred(secret="not-base32!")])
self.assertTrue(any("base32" in p.lower() for p in problems))
def test_oversize_name_is_reported(self):
problems = validate_input([cred(issuer="I" * 40, account="a" * 40)])
self.assertTrue(any("64" in p for p in problems))
def test_sha512_algorithm_is_reported(self):
problems = validate_input([cred(algorithm=HMAC_SHA512)])
self.assertTrue(any("sha512" in p.lower() or "algorithm" in p.lower()
for p in problems))
class TestLoadCredentials(unittest.TestCase):
def _make_xlsx(self, rows):
wb = Workbook()
ws = wb.active
ws.title = "OTP Keys"
ws.append(["Issuer", "Account", "Secret (Base32)", "Type", "Algorithm",
"Digits", "Period (s)", "QR Label", "otpauth:// URI", "Source File"])
for r in rows:
ws.append(r)
fd, path = tempfile.mkstemp(suffix=".xlsx")
os.close(fd)
wb.save(path)
return path
def test_maps_columns_and_string_enums(self):
path = self._make_xlsx([
["Google", "a@x.com", "GEZDGNBVGY3TQOJQ", "TOTP", "SHA1", 6, 30, "lbl", "uri", "g.png"],
["Steam", "user", "MZXW6YTBOI======", "TOTP", "SHA256", 8, 15, "lbl", "uri", "s.png"],
])
try:
creds = load_credentials(path)
finally:
os.remove(path)
self.assertEqual(len(creds), 2)
g = creds[0]
self.assertEqual((g.issuer, g.account, g.secret), ("Google", "a@x.com", "GEZDGNBVGY3TQOJQ"))
self.assertEqual((g.oath_type, g.algorithm, g.digits, g.period), (TOTP_TYPE, HMAC_SHA1, 6, 30))
self.assertEqual(g.source, "g.png")
self.assertEqual((creds[1].algorithm, creds[1].digits, creds[1].period), (HMAC_SHA256, 8, 15))
class TestCardChannel(unittest.TestCase):
def test_reassembles_61xx_via_send_remaining(self):
conn = FakeConn([
(b"\x72\x02\x21A", 0x61, 0x04), # first chunk + "4 more"
(b"\x72\x02\x21B", 0x90, 0x00), # remainder + OK
])
ch = CardChannel(conn)
data, sw = ch.send(0x00, 0xA1, 0x00, 0x00, le=0x00) # LIST
self.assertEqual(sw, 0x9000)
self.assertEqual(data, b"\x72\x02\x21A\x72\x02\x21B")
# second APDU must be SEND REMAINING (INS 0xA5)
self.assertEqual(conn.sent[1][:2], [0x00, 0xA5])
def test_single_response_returns_sw(self):
conn = FakeConn([(b"", 0x90, 0x00)])
data, sw = CardChannel(conn).send(0x00, 0x01, 0x00, 0x00, data=b"\x71\x01A")
self.assertEqual(sw, 0x9000)
self.assertEqual(data, b"")
DEV_ID = bytes(range(8)) # 00..07
VERSION_TLV = tlv(0x79, b"\x00\x01\x02")
def select_unlocked():
return VERSION_TLV + tlv(0x71, DEV_ID)
def select_locked(challenge):
return (VERSION_TLV + tlv(0x71, DEV_ID)
+ tlv(0x74, challenge) + tlv(0x7B, bytes([HMAC_SHA1])))
class TestOathSessionSelect(unittest.TestCase):
def test_parses_unlocked_select(self):
s = OathSession(CardChannel(FakeConn([(select_unlocked(), 0x90, 0x00)])))
s.select()
self.assertFalse(s.locked)
self.assertEqual(s.device_id, DEV_ID)
self.assertEqual(s.version, (0, 1, 2))
def test_parses_locked_select(self):
chal = b"\xC1" * 8
s = OathSession(CardChannel(FakeConn([(select_locked(chal), 0x90, 0x00)])))
s.select()
self.assertTrue(s.locked)
self.assertEqual(s.challenge, chal)
self.assertEqual(s.algorithm, HMAC_SHA1)
def test_falls_back_to_next_aid_on_failure(self):
conn = FakeConn([(b"", 0x6A, 0x82), # first AID: not found
(select_unlocked(), 0x90, 0x00)]) # second AID: ok
s = OathSession(CardChannel(conn))
s.select()
self.assertFalse(s.locked)
self.assertEqual(len(conn.sent), 2) # tried two AIDs
class TestOathSessionList(unittest.TestCase):
def test_list_names_parses_entries(self):
listing = (tlv(0x72, bytes([0x21]) + b"Google:a@x.com")
+ tlv(0x72, bytes([0x21]) + b"AWS:b"))
conn = FakeConn([(select_unlocked(), 0x90, 0x00), (listing, 0x90, 0x00)])
s = OathSession(CardChannel(conn)); s.select()
self.assertEqual(s.list_names(), ["Google:a@x.com", "AWS:b"])
class TestOathSessionPut(unittest.TestCase):
def _cred(self):
return Credential("Iss", "acct", "GEZDGNBVGY3TQOJQ")
def test_put_success_sends_put_apdu(self):
conn = FakeConn([(select_unlocked(), 0x90, 0x00), (b"", 0x90, 0x00)])
s = OathSession(CardChannel(conn)); s.select()
s.put(self._cred())
self.assertEqual(conn.sent[1][1], 0x01) # INS PUT
self.assertEqual(bytes(conn.sent[1][5:]), self._cred().put_data())
def test_put_full_raises_with_sw(self):
conn = FakeConn([(select_unlocked(), 0x90, 0x00), (b"", 0x6A, 0x84)])
s = OathSession(CardChannel(conn)); s.select()
with self.assertRaises(OathError) as ctx:
s.put(self._cred())
self.assertEqual(ctx.exception.sw, 0x6A84)
class TestParseMemory(unittest.TestCase):
def test_parses_available_and_total(self):
# avail=84000, total=84336 (0x00014970), tRes=0x0100, tDes=0x0080
data = (84000).to_bytes(4, "big") + bytes.fromhex("00014970") \
+ bytes.fromhex("0100") + bytes.fromhex("0080")
m = parse_memory(data)
self.assertEqual(m["available_persistent"], 84000)
self.assertEqual(m["total_persistent"], 84336)
class TestOathSessionValidate(unittest.TestCase):
def test_validate_success_sends_correct_response(self):
from apex_otp_import import derive_key, compute_response
chal = b"\xC1" * 8
client = b"\xD2" * 8
key = derive_key("hunter2", DEV_ID)
device_reply = tlv(0x75, compute_response(key, client))
conn = FakeConn([(select_locked(chal), 0x90, 0x00),
(device_reply, 0x90, 0x00)])
s = OathSession(CardChannel(conn), rng=lambda n: client)
s.select()
s.validate("hunter2") # must not raise
# our response to the device must be HMAC(key, device challenge)
sent = bytes(conn.sent[1])
self.assertIn(compute_response(key, chal), sent)
def test_validate_wrong_password_raises(self):
conn = FakeConn([(select_locked(b"\xC1" * 8), 0x90, 0x00),
(b"", 0x6A, 0x80)])
s = OathSession(CardChannel(conn), rng=lambda n: b"\xD2" * 8)
s.select()
with self.assertRaises(OathError):
s.validate("wrong")
if __name__ == "__main__":
unittest.main()