diff --git a/README.md b/README.md index 5cd4aae..a9abf0f 100644 --- a/README.md +++ b/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. diff --git a/apex_otp_import.py b/apex_otp_import.py index f1c6d0e..d611d11 100644 --- a/apex_otp_import.py +++ b/apex_otp_import.py @@ -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) diff --git a/docs/design.md b/docs/design.md index 177d5dd..5d94e16 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 diff --git a/test_oath.py b/test_oath.py index 5192446..8a0e9bd 100644 --- a/test_oath.py +++ b/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()