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:
11
README.md
11
README.md
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
36
test_oath.py
36
test_oath.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user