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.
This commit is contained in:
318
test_oath.py
Normal file
318
test_oath.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user