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

@@ -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__":
sys.exit(main())
try:
sys.exit(main())
except OathError as e:
print(f"OATH error: {e}")
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)