Add --reset and post-import --set-password; fix algorithm nibble parsing

Add two admin operations on the OATH protocol layer: --reset (RESET
0x04/0xDEAD) and --set-password, which sets an access password after a
successful import only if the applet has none. Fix _parse_select to mask
the SELECT ALGORITHM tag to its low nibble (the applet reports the full
type|algo byte, e.g. 0x21), which previously broke validate on a locked
applet. Add a clean top-level OathError handler.
This commit is contained in:
2026-07-08 11:38:50 -07:00
parent f8273fc904
commit 602bbc13f7
4 changed files with 130 additions and 7 deletions

View File

@@ -7,7 +7,7 @@ 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,
build_put_data, build_validate_data, build_set_code_data,
derive_key, compute_response,
Credential, validate_input, load_credentials,
CardChannel, OathSession, OathError, parse_memory,
@@ -119,6 +119,18 @@ class TestValidateData(unittest.TestCase):
self.assertEqual(data, bytes([0x75, 0x14]) + resp + bytes([0x74, 0x08]) + chal)
class TestSetCodeData(unittest.TestCase):
def test_byte_exact_set_code_payload(self):
key = b"\x11" * 16
chal = b"\x22" * 8
data = build_set_code_data(key, chal)
resp = compute_response(key, chal, HMAC_SHA1)
expected = (tlv(0x73, bytes([0x21]) + key) # KEY: TOTP|SHA1 + 16-byte key
+ tlv(0x74, chal) # CHALLENGE
+ tlv(0x75, resp)) # RESPONSE = HMAC(key, challenge)
self.assertEqual(data, expected)
class TestPasswordAuth(unittest.TestCase):
SALT = bytes([0x19, 0x83, 0x57, 0x1E, 0xC3, 0xE5, 0xAB, 0xD7])
@@ -222,8 +234,9 @@ def select_unlocked():
def select_locked(challenge):
# real applet reports the full type|algo byte (0x21 = TOTP|SHA1), not 0x01
return (VERSION_TLV + tlv(0x71, DEV_ID)
+ tlv(0x74, challenge) + tlv(0x7B, bytes([HMAC_SHA1])))
+ tlv(0x74, challenge) + tlv(0x7B, bytes([TOTP_TYPE | HMAC_SHA1])))
class TestOathSessionSelect(unittest.TestCase):
@@ -314,5 +327,24 @@ class TestOathSessionValidate(unittest.TestCase):
s.validate("wrong")
class TestOathSessionAdmin(unittest.TestCase):
def test_set_password_sends_set_code(self):
from apex_otp_import import derive_key
client = b"\x22" * 8
conn = FakeConn([(select_unlocked(), 0x90, 0x00), (b"", 0x90, 0x00)])
s = OathSession(CardChannel(conn), rng=lambda n: client)
s.select()
s.set_password("test1234")
self.assertEqual(conn.sent[1][1], 0x03) # INS SET CODE
key = derive_key("test1234", DEV_ID)
self.assertEqual(bytes(conn.sent[1][5:]), build_set_code_data(key, client))
def test_reset_sends_reset_with_dead(self):
conn = FakeConn([(select_unlocked(), 0x90, 0x00), (b"", 0x90, 0x00)])
s = OathSession(CardChannel(conn)); s.select()
s.reset()
self.assertEqual(conn.sent[1][:4], [0x00, 0x04, 0xDE, 0xAD])
if __name__ == "__main__":
unittest.main()