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

@@ -45,8 +45,19 @@ python apex_otp_import.py --input ... --skip-existing
# Target a specific reader, supply a password, and profile memory
python apex_otp_import.py --input ... --reader VivoKey --password '...' --profile-memory
# Import, then set an access password (only if the applet has none yet)
python apex_otp_import.py --input ... --set-password 'yourpassword'
# Reset: wipe all credentials and any password (standalone; no input needed)
python apex_otp_import.py --reset
```
Authenticating to a password-protected applet uses the standard YKOATH scheme
(PBKDF2-HMAC-SHA1 of the password with the device id as salt, then mutual
challenge-response). `--set-password` only sets a password on an applet that has
none; it does not change or remove an existing one. `--reset` clears everything.
The input is validated before any reader is touched. Duplicate credential names,
invalid Base32, over-long names/keys, and unsupported algorithms halt the run
with a report so they can be fixed in the source data.

View File

@@ -38,6 +38,7 @@ TAG_ALGORITHM = 0x7B
HOTP_TYPE = 0x10
TOTP_TYPE = 0x20
HMAC_MASK = 0x0F # low nibble of a type|algo byte selects the HMAC algorithm
HMAC_SHA1 = 0x01
HMAC_SHA256 = 0x02
HMAC_SHA512 = 0x03 # rejected by the applet; used only to flag bad input
@@ -253,6 +254,17 @@ def build_validate_data(response: bytes, client_challenge: bytes) -> bytes:
return tlv(TAG_RESPONSE, response) + tlv(TAG_CHALLENGE, client_challenge)
def build_set_code_data(key: bytes, challenge: bytes, algo: int = HMAC_SHA1) -> bytes:
"""SET CODE payload: KEY(0x73, [type|algo, 16-byte key]) + CHALLENGE(0x74) +
RESPONSE(0x75, HMAC(key, challenge)) so the applet can confirm the host
derives the same key."""
return (
tlv(TAG_KEY, bytes([TOTP_TYPE | algo]) + key)
+ tlv(TAG_CHALLENGE, challenge)
+ tlv(TAG_RESPONSE, compute_response(key, challenge, algo))
)
INS_SEND_REMAINING = 0xA5
@@ -320,6 +332,8 @@ OATH_AIDS = [
INS_SELECT = 0xA4
INS_PUT = 0x01
INS_SET_CODE = 0x03
INS_RESET = 0x04
INS_LIST = 0xA1
INS_VALIDATE = 0xA3
@@ -378,7 +392,8 @@ class OathSession:
elif tag == TAG_CHALLENGE:
self.challenge = val
elif tag == TAG_ALGORITHM:
self.algorithm = val[0] if val else HMAC_SHA1
# applet reports the full type|algo byte; keep only the algo nibble
self.algorithm = (val[0] & HMAC_MASK) if val else HMAC_SHA1
@property
def locked(self) -> bool:
@@ -418,6 +433,25 @@ class OathSession:
raise OathError(sw)
return
def set_password(self, password: str):
"""SET CODE: install an access password (SHA1) on the applet. Requires
the applet be unlocked, or already authenticated this session."""
key = derive_key(password, self.device_id)
challenge = self.rng(8)
data, sw = self.ch.send(
0x00, INS_SET_CODE, 0x00, 0x00,
data=build_set_code_data(key, challenge, HMAC_SHA1))
if sw != 0x9000:
raise OathError(sw, "SET CODE failed")
return
def reset(self):
"""RESET: wipe all credentials and any password (P1P2 = 0xDEAD)."""
data, sw = self.ch.send(0x00, INS_RESET, 0xDE, 0xAD)
if sw != 0x9000:
raise OathError(sw, "RESET failed")
return
# ---------------------------------------------------------------------------
# Optional javacard-memory applet (for capacity checks / profiling)
@@ -584,6 +618,11 @@ def parse_args(argv=None):
p.add_argument("--memlog", default=DEFAULT_MEMLOG,
help=f"profiling CSV path (default: {DEFAULT_MEMLOG})")
p.add_argument("--password", help="applet password (else prompted if locked)")
p.add_argument("--reset", action="store_true",
help="wipe all credentials and any password, then exit")
p.add_argument("--set-password", metavar="PASS",
help="after importing, set this applet password "
"(only if the applet has none)")
return p.parse_args(argv)
@@ -596,6 +635,17 @@ def _reauth(oath, pw):
def main(argv=None):
args = parse_args(argv)
# --reset is a standalone admin action; it needs no input spreadsheet.
if args.reset:
reader, connection = pick_reader(args.reader)
if connection is None:
return 1
oath = OathSession(CardChannel(connection))
oath.select()
oath.reset()
print(f"Applet reset on '{reader}' (all credentials and any password wiped).")
return 0
# 1. Load + validate BEFORE touching any reader.
creds = load_credentials(args.input)
problems = validate_input(creds)
@@ -633,6 +683,7 @@ def main(argv=None):
# 5. OATH session.
oath = OathSession(channel)
oath.select()
applet_was_locked = oath.locked
print(f"OATH applet on '{reader}': v{'.'.join(map(str, oath.version))}, "
f"{'LOCKED' if oath.locked else 'unlocked'}.")
pw = None
@@ -699,6 +750,15 @@ def main(argv=None):
if memlog_fh:
memlog_fh.close()
# Post-import: optionally set an access password on a not-yet-protected applet.
if args.set_password and failed == 0:
if applet_was_locked:
print("Applet already password-protected; --set-password skipped.")
else:
oath.select() # ensure OATH is the active applet (profiling deselects it)
oath.set_password(args.set_password)
print("Applet password set.")
print(f"\nDone: {ok} written, {skipped} skipped, {failed} failed.")
if args.profile_memory:
print(f"Memory profile appended to {args.memlog}.")
@@ -706,4 +766,10 @@ def main(argv=None):
if __name__ == "__main__":
try:
sys.exit(main())
except OathError as e:
print(f"OATH error: {e}")
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)

View File

@@ -201,11 +201,25 @@ exceptions (no card, reader gone) surface as clear, actionable messages.
oversize key, SHA512).
- `61xx` reassembly via a fake channel that chunks a long LIST response.
## Password / reset admin (added after initial spec)
The importer also exposes two admin operations on the same protocol layer:
- `--set-password PASS`: after a fully successful import, set an access password
on the applet **only if it has none** (SET CODE with a SHA1 access key derived
as above). Runs post-import for efficiency (single session). Does not change or
remove an existing password.
- `--reset`: standalone; sends RESET (P1P2 = 0xDEAD) to wipe all credentials and
any password, then exits.
Note: the applet reports the full `type|algo` byte (e.g. `0x21`) in the SELECT
ALGORITHM tag; the low nibble is masked off to select the HMAC algorithm.
## Out of scope (YAGNI)
Setting/changing/removing the applet password (authenticate-only); HOTP import;
touch-required; RENAME/DELETE; CSV/text input; on-device code calculation
(beyond optional post-import verification, not included).
Changing or removing an existing password; HOTP import; touch-required;
RENAME/DELETE of individual credentials; CSV/text input; on-device code
calculation (beyond ad-hoc post-import verification, not part of the tool).
## Files

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()