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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user