commit 814fbc8f664b6103a380968ba75507f58b1fdeb9 Author: michael Date: Mon Feb 9 15:19:21 2026 -0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff4c935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +.venv/ + +# Logs +*.log +*.txt + +# Java +gp.jar + +# CAP files (vendor binaries) +*.cap + +# IDE / editor +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/debug_select.py b/debug_select.py new file mode 100644 index 0000000..b6aedf3 --- /dev/null +++ b/debug_select.py @@ -0,0 +1,75 @@ +"""Quick diagnostic: test SELECT on the card currently on the reader.""" +from smartcard.System import readers +from smartcard.util import toHexString +from smartcard.Exceptions import CardConnectionException, NoCardException + +TESLA_VCSEC_AID = [0xF4, 0x65, 0x73, 0x6C, 0x61, 0x4C, 0x6F, 0x67, 0x69, 0x63] +GET_UID_APDU = [0xFF, 0xCA, 0x00, 0x00, 0x00] +GET_PUBLIC_KEY_APDU = [0x80, 0x04, 0x00, 0x00, 0x00] + +r = readers() +print(f"Readers: {r}") +reader = r[0] + +# --- Test 1: fresh connection, SELECT directly (no UID read first) --- +print("\n=== Test 1: Fresh connect -> SELECT (no UID read) ===") +try: + conn = reader.createConnection() + conn.connect() + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(TESLA_VCSEC_AID)] + TESLA_VCSEC_AID + print(f" APDU: {toHexString(select_apdu)}") + resp, sw1, sw2 = conn.transmit(select_apdu) + print(f" Response: {toHexString(resp) if resp else '(empty)'}") + print(f" SW: {sw1:02X} {sw2:02X}") + conn.disconnect() +except Exception as e: + print(f" EXCEPTION: {type(e).__name__}: {e}") + +# --- Test 2: connect, read UID, then SELECT (same connection) --- +print("\n=== Test 2: Connect -> UID -> SELECT (same connection) ===") +try: + conn = reader.createConnection() + conn.connect() + resp, sw1, sw2 = conn.transmit(GET_UID_APDU) + print(f" UID: {toHexString(resp)} (SW: {sw1:02X} {sw2:02X})") + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(TESLA_VCSEC_AID)] + TESLA_VCSEC_AID + resp, sw1, sw2 = conn.transmit(select_apdu) + print(f" SELECT Response: {toHexString(resp) if resp else '(empty)'}") + print(f" SELECT SW: {sw1:02X} {sw2:02X}") + conn.disconnect() +except Exception as e: + print(f" EXCEPTION: {type(e).__name__}: {e}") + +# --- Test 3: connect, read UID, disconnect, reconnect, then SELECT --- +print("\n=== Test 3: Connect -> UID -> disconnect -> reconnect -> SELECT ===") +try: + conn = reader.createConnection() + conn.connect() + resp, sw1, sw2 = conn.transmit(GET_UID_APDU) + print(f" UID: {toHexString(resp)} (SW: {sw1:02X} {sw2:02X})") + conn.disconnect() + conn = reader.createConnection() + conn.connect() + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(TESLA_VCSEC_AID)] + TESLA_VCSEC_AID + resp, sw1, sw2 = conn.transmit(select_apdu) + print(f" SELECT Response: {toHexString(resp) if resp else '(empty)'}") + print(f" SELECT SW: {sw1:02X} {sw2:02X}") + conn.disconnect() +except Exception as e: + print(f" EXCEPTION: {type(e).__name__}: {e}") + +# --- Test 4: SELECT + GET PUBLIC KEY --- +print("\n=== Test 4: SELECT + GET PUBLIC KEY ===") +try: + conn = reader.createConnection() + conn.connect() + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(TESLA_VCSEC_AID)] + TESLA_VCSEC_AID + resp, sw1, sw2 = conn.transmit(select_apdu) + print(f" SELECT SW: {sw1:02X} {sw2:02X}") + if (sw1, sw2) == (0x90, 0x00): + resp, sw1, sw2 = conn.transmit(GET_PUBLIC_KEY_APDU) + print(f" PUBKEY Response: {toHexString(resp) if resp else '(empty)'}") + print(f" PUBKEY SW: {sw1:02X} {sw2:02X}") + conn.disconnect() +except Exception as e: + print(f" EXCEPTION: {type(e).__name__}: {e}") diff --git a/log.py b/log.py new file mode 100644 index 0000000..79c3566 --- /dev/null +++ b/log.py @@ -0,0 +1,67 @@ +from datetime import datetime +from smartcard.System import readers +from smartcard.Exceptions import CardConnectionException, NoCardException +from smartcard.System import readers +from smartcard.util import toHexString +from main import detect_card +from profiles import TeslaVCSEC + + + + +reader = readers()[0] +start_time = datetime.now() +card_count = 0 + +last_uid = None + +try: + while True: + result = detect_card(reader, previous_uid=last_uid) + if result is None: + # User pressed Enter + break + if result == "retry": + # Card vanished mid-detect, loop back + continue + + connection, uid = result + card_count += 1 + print(f"--- Card #{card_count} ---") + + try: + tesla = TeslaVCSEC() + result = tesla.validate(connection) + print(f"{uid}: {result}") + # uid, public_key = process_card( + # args, reader, current_keys, new_keys, connection, uid, + # ) + # write_log_entry(args.log_file, card_count, uid, public_key, "OK") + last_uid = uid + print() + print(f"Card #{card_count} complete. ({card_count} total)") + except (RuntimeError, CardConnectionException, NoCardException) as exc: + _warn(f"Card #{card_count} failed: {exc}") + print() + retry = input(" Retry this card? [Y/n]: ").strip().lower() + if retry in ("", "y", "yes"): + card_count -= 1 + # Card must leave the RF field to reset after a failed + # operation — wait for removal before re-detecting. + _info("Remove card from reader to reset it...") + while _read_uid_quick(reader) is not None: + time.sleep(0.15) + _info("Card removed. Re-present when ready.") + last_uid = None # allow same UID to be picked up again + else: + # write_log_entry( + # args.log_file, card_count, + # uid or "UNKNOWN", None, f"FAILED: {exc}", + # ) + last_uid = uid + + print() + +except KeyboardInterrupt: + print("\n") + _warn("Interrupted by user.") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..58919ff --- /dev/null +++ b/main.py @@ -0,0 +1,834 @@ +import argparse +import os +import secrets +import select +import shutil +import subprocess +import sys +import time +import zipfile +from datetime import datetime + +from smartcard.Exceptions import CardConnectionException, NoCardException +from smartcard.System import readers +from smartcard.util import toHexString + +from profiles import AppletProfile, DefaultProfile, get_profile + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +DEFAULT_KEY = "404142434445464748494A4B4C4D4E4F" + +# GET DATA for card UID (works on most contactless readers) +GET_UID_APDU = [0xFF, 0xCA, 0x00, 0x00, 0x00] + +GP_JAR = "gp.jar" + +# --------------------------------------------------------------------------- +# CAP file parsing +# --------------------------------------------------------------------------- + +def extract_applet_aids(cap_file: str) -> list[list[int]]: + """ + Extract applet AIDs from a CAP file's Applet.cap component. + + Returns a list of AIDs as int-lists (ready for SELECT APDUs). + Returns an empty list on any parse error. + """ + try: + with zipfile.ZipFile(cap_file, "r") as zf: + applet_entries = [n for n in zf.namelist() if n.endswith("Applet.cap")] + if not applet_entries: + return [] + data = zf.read(applet_entries[0]) + except (zipfile.BadZipFile, OSError): + return [] + + if len(data) < 4 or data[0] != 0x03: + return [] + + count = data[3] + aids: list[list[int]] = [] + offset = 4 + for _ in range(count): + if offset >= len(data): + break + aid_len = data[offset] + offset += 1 + if offset + aid_len + 2 > len(data): + break + aid = list(data[offset : offset + aid_len]) + offset += aid_len + 2 # skip install_method_offset (u2) + aids.append(aid) + return aids + + +def _build_cap_profiles( + cap_files: list[str], +) -> dict[str, list[tuple[list[int], AppletProfile]]]: + """ + For each CAP file, extract applet AIDs and resolve their profiles. + + Returns {cap_path: [(aid, profile), ...]}. + """ + result: dict[str, list[tuple[list[int], AppletProfile]]] = {} + for cap in cap_files: + aids = extract_applet_aids(cap) + entries = [] + for aid in aids: + profile = get_profile(aid) + if profile is None: + profile = DefaultProfile(aid) + entries.append((aid, profile)) + result[cap] = entries + return result + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def validate_hex_key(value: str, name: str) -> str: + """Validate that *value* is exactly 32 hex characters (16 bytes).""" + stripped = value.strip() + if len(stripped) != 32: + raise argparse.ArgumentTypeError( + f"{name}: expected 32 hex characters (16 bytes), got {len(stripped)}" + ) + try: + bytes.fromhex(stripped) + except ValueError: + raise argparse.ArgumentTypeError( + f"{name}: invalid hex characters in '{stripped}'" + ) + return stripped.upper() + + +def hex_key_type(value: str) -> str: + """argparse *type* callback for key arguments.""" + return validate_hex_key(value, "key") + + +def generate_random_key() -> str: + """Return a cryptographically random 16-byte key as a 32-char hex string.""" + return secrets.token_hex(16).upper() + + +def resolve_keys(args: argparse.Namespace) -> tuple[str, str, str]: + """Return (enc, mac, dek) from the parsed CLI arguments for *current* keys.""" + individual = [args.key_enc, args.key_mac, args.key_dek] + provided = [k for k in individual if k is not None] + + if len(provided) not in (0, 3): + _die("When using --key-enc / --key-mac / --key-dek you must supply all three.") + + if provided: + return (args.key_enc, args.key_mac, args.key_dek) + + base = args.key or DEFAULT_KEY + return (base, base, base) + + +def resolve_new_keys(args: argparse.Namespace) -> tuple[str, str, str] | None: + """Return (enc, mac, dek) for the *new* keys, or None if no key change requested.""" + if not args.change_keys and not args.randomize_keys: + return None + + if args.randomize_keys: + return (generate_random_key(), generate_random_key(), generate_random_key()) + + individual = [args.new_key_enc, args.new_key_mac, args.new_key_dek] + provided = [k for k in individual if k is not None] + + if provided and len(provided) != 3: + _die("When using --new-key-enc / --new-key-mac / --new-key-dek you must supply all three.") + + if provided: + return (args.new_key_enc, args.new_key_mac, args.new_key_dek) + + if args.new_key: + return (args.new_key, args.new_key, args.new_key) + + if args.change_keys: + _die("--change-keys requires either --new-key, all three --new-key-enc/mac/dek, or --randomize-keys.") + + return None + + +def _die(msg: str) -> None: + """Print an error message and exit.""" + print(f"\n Error: {msg}\n", file=sys.stderr) + sys.exit(1) + + +def _info(msg: str) -> None: + print(f" {msg}") + + +def _success(msg: str) -> None: + print(f" [OK] {msg}") + + +def _warn(msg: str) -> None: + print(f" [!] {msg}", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Card detection (pyscard) +# --------------------------------------------------------------------------- + +def _read_uid_quick(reader) -> str | None: + """Try to read the UID of whatever card is on the reader. Returns None if no card.""" + try: + conn = reader.createConnection() + conn.connect() + response, sw1, sw2 = conn.transmit(GET_UID_APDU) + conn.disconnect() + if sw1 == 0x90 and sw2 == 0x00: + return toHexString(response).replace(" ", "") + except (CardConnectionException, NoCardException): + pass + return None + + + +def _resolve_reader(reader_name: str | None = None): + """Find and return the smartcard reader to use.""" + available = readers() + if not available: + _die("No smartcard readers found. Is a reader connected?") + + if reader_name: + matches = [r for r in available if reader_name.lower() in str(r).lower()] + if not matches: + _die( + f"Reader '{reader_name}' not found. Available readers:\n" + + "\n".join(f" - {r}" for r in available) + ) + return matches[0] + return available[0] + + +def _stdin_ready() -> bool: + """Return True if the user has pressed Enter (non-blocking).""" + ready, _, _ = select.select([sys.stdin], [], [], 0) + if ready: + sys.stdin.readline() # consume the newline + return True + return False + + +def detect_card(reader, previous_uid: str | None = None): + """ + Wait for a new card and return (connection, uid_hex), or None if + the user presses Enter to end the batch. + + If *previous_uid* is set, keeps polling until a different card appears. + """ + # Flush any buffered stdin so stray keypresses during card processing + # don't immediately end the batch. + while select.select([sys.stdin], [], [], 0)[0]: + sys.stdin.readline() + + _info("Waiting for card... (press Enter to end batch)") + + # Poll until a *new* card is present (different from previous_uid). + poll_count = 0 + while True: + if _stdin_ready(): + return None + + # Periodically verify the reader is still connected (~every 5 s). + poll_count += 1 + if poll_count % 30 == 0: + try: + available = readers() + if str(reader) not in [str(r) for r in available]: + _warn("Reader disconnected.") + return None + except Exception: + _warn("Reader disconnected.") + return None + + uid_hex = _read_uid_quick(reader) + + if uid_hex is not None: + if previous_uid is None or uid_hex != previous_uid: + break + # Same card still on reader — keep waiting + time.sleep(0.15) + + # We know a new card is present. Connect fresh for real APDUs. + # The quick-read disconnected (dropping RF briefly), so retry if needed. + connection = None + for _ in range(20): + try: + connection = reader.createConnection() + connection.connect() + break + except (CardConnectionException, NoCardException): + time.sleep(0.15) + if connection is None: + _warn("Card vanished before we could connect. Retrying...") + return "retry" + + # Re-read UID on this connection (the _read_uid_quick one disconnected). + try: + response, sw1, sw2 = connection.transmit(GET_UID_APDU) + if sw1 == 0x90 and sw2 == 0x00: + uid_hex = toHexString(response).replace(" ", "") + except (CardConnectionException, NoCardException): + pass # keep the uid_hex from the quick read + + return connection, uid_hex + + +# --------------------------------------------------------------------------- +# gp.jar interaction +# --------------------------------------------------------------------------- + +def _find_gp_jar() -> str: + """Locate gp.jar — check current dir, then PATH.""" + local = os.path.join(os.getcwd(), GP_JAR) + if os.path.isfile(local): + return local + + # Check if 'gp' wrapper script or alias exists on PATH + gp_on_path = shutil.which("gp") + if gp_on_path: + return gp_on_path + + gp_jar_on_path = shutil.which(GP_JAR) + if gp_jar_on_path: + return gp_jar_on_path + + _die( + f"{GP_JAR} not found.\n" + " Place gp.jar in the current directory, or add it to your PATH.\n" + " Download from: https://github.com/martinpaljak/GlobalPlatformPro/releases" + ) + + +def _check_java() -> None: + """Ensure a Java runtime is available.""" + if shutil.which("java") is None: + _die("Java runtime not found. Please install Java (JRE 8+).") + + +def _build_key_flags(keys: tuple[str, str, str], prefix: str = "") -> list[str]: + """ + Build gp.jar key flags. + + *prefix* is '' for current keys (-key*) or 'lock' for new keys (--lock*). + """ + enc, mac, dek = keys + if prefix: + flag_single = f"--{prefix}" + flag_enc = f"--{prefix}-enc" + flag_mac = f"--{prefix}-mac" + flag_dek = f"--{prefix}-dek" + else: + flag_single = "--key" + flag_enc = "--key-enc" + flag_mac = "--key-mac" + flag_dek = "--key-dek" + + if enc == mac == dek: + return [flag_single, enc] + if prefix: + # gp.jar requires --lock as a base even when individual lock keys differ. + # Set the base to enc, then override mac and dek. + return [flag_single, enc, flag_mac, mac, flag_dek, dek] + # For current keys, use individual --key-enc/mac/dek flags. + return [flag_enc, enc, flag_mac, mac, flag_dek, dek] + + +def run_gp(gp_args: list[str], keys: tuple[str, str, str]) -> subprocess.CompletedProcess: + """ + Run gp.jar with the given arguments and key flags. + + Raises SystemExit with a friendly message on failure. + """ + gp_path = _find_gp_jar() + + # If gp_path is a .jar, run via java -jar; otherwise assume it's a wrapper + if gp_path.endswith(".jar"): + cmd = ["java", "-jar", gp_path] + else: + cmd = [gp_path] + + cmd += _build_key_flags(keys) + cmd += gp_args + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + raise RuntimeError("gp.jar timed out after 120 seconds.") + except FileNotFoundError: + raise RuntimeError("Failed to launch gp.jar. Is Java installed?") + + if result.returncode != 0: + stderr = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"gp.jar failed (exit {result.returncode}):\n {stderr}") + + return result + + +def install_cap(cap_file: str, keys: tuple[str, str, str]) -> None: + """Install a CAP file onto the card via gp.jar.""" + _info(f"Installing {os.path.basename(cap_file)} ...") + try: + run_gp(["--install", cap_file], keys) + _success(f"Installed {os.path.basename(cap_file)}") + except RuntimeError as exc: + raise RuntimeError(f"Failed to install {os.path.basename(cap_file)}: {exc}") + + +def change_keys(current_keys: tuple[str, str, str], new_keys: tuple[str, str, str]) -> None: + """Change the card's GP keys from *current_keys* to *new_keys*.""" + _info("Changing card keys...") + lock_flags = _build_key_flags(new_keys, prefix="lock") + try: + run_gp(lock_flags, current_keys) + _success("Keys changed successfully.") + except RuntimeError as exc: + raise RuntimeError(f"Key change failed: {exc}") + + +# --------------------------------------------------------------------------- +# Card APDU helpers +# --------------------------------------------------------------------------- + +def select_aid(connection, aid: list[int]) -> bool: + """ + SELECT an applet by AID. + + Returns True if the applet responds with SW 90 00, False otherwise. + """ + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(aid)] + aid + try: + response, sw1, sw2 = connection.transmit(select_apdu) + except (CardConnectionException, NoCardException): + return False + return (sw1, sw2) == (0x90, 0x00) + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + +def _write_log(log_file: str, text: str) -> None: + """Append text to the log file, warning (not crashing) on failure.""" + try: + with open(log_file, "a") as f: + f.write(text) + except OSError as exc: + _warn(f"Failed to write log: {exc}") + + +def write_log_header(log_file: str, start_time: datetime) -> None: + """Write the session header to the log file.""" + sep = "=" * 60 + _write_log(log_file, + f"{sep}\n" + f"Batch Session Started: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n" + f"{sep}\n\n" + ) + + +def write_log_entry( + log_file: str, + card_number: int, + uid: str, + validation_results: dict[str, dict[str, str]], + status: str, +) -> None: + """Append a single card entry to the log file.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + lines = [ + f"--- Card #{card_number} ---\n", + f" Time: {now}\n", + f" UID: {uid}\n", + ] + if validation_results: + for profile_name, result in validation_results.items(): + lines.append(f" [{profile_name}]\n") + for key, value in result.items(): + lines.append(f" {key}: {value}\n") + lines.append(f" Status: {status}\n\n") + _write_log(log_file, "".join(lines)) + + +def write_log_footer(log_file: str, total: int) -> None: + """Write the session footer to the log file.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + sep = "=" * 60 + _write_log(log_file, + f"{'-' * 60}\n" + f"Batch Complete: {now}\n" + f"Total Cards Processed: {total}\n" + f"{sep}\n\n" + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="j3r180-batch", + description="Batch provisioning tool for JavaCard smartcards (Tesla key cards).", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "examples:\n" + " %(prog)s --cap-file app.cap\n" + " %(prog)s --cap-file app.cap --randomize-keys\n" + " %(prog)s --cap-file app.cap --change-keys --new-key AABBCCDDEEFF00112233445566778899\n" + " %(prog)s --key 00112233445566778899AABBCCDDEEFF --cap-file a.cap b.cap\n" + ), + ) + + # --- Current key flags --- + key_group = parser.add_argument_group("current card keys (for GP authentication)") + key_group.add_argument( + "-k", "--key", + type=hex_key_type, + default=None, + metavar="HEX", + help=f"single key for ENC/MAC/DEK (default: {DEFAULT_KEY})", + ) + key_group.add_argument("--key-enc", type=hex_key_type, default=None, metavar="HEX", help="ENC key") + key_group.add_argument("--key-mac", type=hex_key_type, default=None, metavar="HEX", help="MAC key") + key_group.add_argument("--key-dek", type=hex_key_type, default=None, metavar="HEX", help="DEK key") + + # --- CAP file --- + parser.add_argument( + "--cap-file", + nargs="+", + metavar="FILE", + help="one or more .cap files to install", + ) + + # --- Key change flags --- + lock_group = parser.add_argument_group("key change options") + lock_group.add_argument( + "--change-keys", + action="store_true", + help="change the card's GP keys after installation", + ) + lock_group.add_argument("--new-key", type=hex_key_type, default=None, metavar="HEX", help="new single key for ENC/MAC/DEK") + lock_group.add_argument("--new-key-enc", type=hex_key_type, default=None, metavar="HEX", help="new ENC key") + lock_group.add_argument("--new-key-mac", type=hex_key_type, default=None, metavar="HEX", help="new MAC key") + lock_group.add_argument("--new-key-dek", type=hex_key_type, default=None, metavar="HEX", help="new DEK key") + lock_group.add_argument( + "--randomize-keys", + action="store_true", + help="generate random keys and lock the card (keys are NOT recorded)", + ) + + # --- Other --- + parser.add_argument( + "--log-file", + default=None, + metavar="PATH", + help="override log file path (default: auto-generated from timestamp + params)", + ) + parser.add_argument( + "--reader", + default=None, + metavar="NAME", + help="smartcard reader name or substring (auto-detect if omitted)", + ) + + return parser + + +def validate_args(args: argparse.Namespace) -> None: + """Perform cross-field validation that argparse can't handle alone.""" + # --key and --key-enc/mac/dek are mutually exclusive + if args.key and any([args.key_enc, args.key_mac, args.key_dek]): + _die("Cannot combine --key with --key-enc/mac/dek. Use one or the other.") + + # Same for new-key variants + if args.new_key and any([args.new_key_enc, args.new_key_mac, args.new_key_dek]): + _die("Cannot combine --new-key with --new-key-enc/mac/dek. Use one or the other.") + + # CAP file validation + if args.cap_file: + for path in args.cap_file: + if not path.lower().endswith(".cap"): + _die(f"'{path}' does not look like a .cap file.") + if not os.path.isfile(path): + _die(f"CAP file not found: {path}") + + # --randomize-keys implies --change-keys + if args.randomize_keys: + args.change_keys = True + + # --new-key* implies --change-keys + if any([args.new_key, args.new_key_enc, args.new_key_mac, args.new_key_dek]): + args.change_keys = True + + # --randomize-keys is mutually exclusive with explicit new keys + if args.randomize_keys and any([args.new_key, args.new_key_enc, args.new_key_mac, args.new_key_dek]): + _die("--randomize-keys cannot be combined with --new-key / --new-key-enc/mac/dek.") + + # Must have something to do + if not args.cap_file and not args.change_keys: + _die("Nothing to do. Specify --cap-file and/or key change flags.") + + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +def process_card( + args: argparse.Namespace, + reader, + current_keys: tuple[str, str, str], + new_keys: tuple[str, str, str] | None, + connection, + uid: str, + cap_profiles: dict[str, list[tuple[list[int], AppletProfile]]], +) -> tuple[str, dict[str, dict[str, str]]]: + """ + Process a single card that has already been detected. + + Returns (uid, validation_results) where validation_results maps + profile name → {field: value} from each profile's validate(). + """ + _success(f"Card detected — UID: {uid}") + + gp_ran = False + disconnected = False + + # --- Per-CAP install: SELECT each applet AID to check presence --- + if args.cap_file: + for cap in args.cap_file: + basename = os.path.basename(cap) + entries = cap_profiles.get(cap, []) + aids = [aid for aid, _ in entries] + + # If we can extract AIDs, check if all applets are already present. + already = False + if aids and not disconnected: + already = all(select_aid(connection, aid) for aid in aids) + + if already: + _info(f"{basename} already installed — skipping.") + continue + + # Need gp.jar — disconnect pyscard first (once). + if not disconnected: + try: + connection.disconnect() + except Exception: + pass + disconnected = True + + install_cap(cap, current_keys) + gp_ran = True + + # --- Change keys --- + if new_keys: + if not disconnected: + try: + connection.disconnect() + except Exception: + pass + disconnected = True + + if args.randomize_keys: + new_keys = (generate_random_key(), generate_random_key(), generate_random_key()) + try: + change_keys(current_keys, new_keys) + gp_ran = True + except RuntimeError as exc: + # Best-effort when applet is already provisioned. + if args.cap_file and not gp_ran: + _warn(f"Key change failed (non-fatal): {exc}") + else: + raise + + # --- Reconnect if needed --- + if gp_ran: + # gp.jar took the reader; reconnect via pyscard (RF field dropped briefly). + connection = None + for _ in range(40): # ~6 seconds max + try: + connection = reader.createConnection() + connection.connect() + break + except (CardConnectionException, NoCardException): + time.sleep(0.15) + if connection is None: + _warn("Card not detected after GP operations. Was it removed?") + return uid, {} + elif disconnected: + try: + connection = reader.createConnection() + connection.connect() + except (CardConnectionException, NoCardException): + _warn("Card not detected. Was it removed?") + return uid, {} + # else: connection is still live from detect_card — use it directly. + + # --- Validate installed applets via profiles --- + validation_results: dict[str, dict[str, str]] = {} + if args.cap_file: + for cap in args.cap_file: + for aid, profile in cap_profiles.get(cap, []): + result = profile.validate(connection) + validation_results[profile.name] = result + + if result.get("installed") == "yes": + if "error" in result: + _warn(f"{profile.name}: installed but {result['error']}") + else: + _success(f"{profile.name}: validated") + for key, value in result.items(): + if key != "installed": + _info(f" {key}: {value}") + else: + _warn(f"{profile.name}: {result.get('error', 'validation failed')}") + + try: + connection.disconnect() + except Exception: + pass + + return uid, validation_results + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + validate_args(args) + + # Resolve keys (validates partial key combos) + current_keys = resolve_keys(args) + new_keys = resolve_new_keys(args) + + # Pre-flight checks + _check_java() + _find_gp_jar() + + # Resolve CAP → profile mappings + cap_profiles: dict[str, list[tuple[list[int], AppletProfile]]] = {} + if args.cap_file: + cap_profiles = _build_cap_profiles(args.cap_file) + + reader = _resolve_reader(args.reader) + start_time = datetime.now() + card_count = 0 + + # Build default log filename: timestamp + non-key params + if args.log_file is None: + parts = [start_time.strftime("%Y%m%d_%H%M%S")] + if args.cap_file: + parts.extend( + os.path.splitext(os.path.basename(f))[0] for f in args.cap_file + ) + if args.randomize_keys: + parts.append("randomize") + elif args.change_keys: + parts.append("rekey") + args.log_file = "_".join(parts) + ".log" + + # Print banner + print() + print("=" * 50) + print(" j3r180 Batch Provisioning Tool") + print("=" * 50) + _info(f"Session started: {start_time.strftime('%Y-%m-%d %H:%M:%S')}") + _info(f"Reader: {reader}") + if args.cap_file: + for cap in args.cap_file: + profiles = [p.name for _, p in cap_profiles.get(cap, [])] + label = ", ".join(profiles) if profiles else "no profile" + _info(f"CAP: {os.path.basename(cap)} -> {label}") + if args.randomize_keys: + _info("Keys will be RANDOMIZED (not recorded).") + elif new_keys: + _info("Keys will be changed.") + _info(f"Log file: {args.log_file}") + print() + + # Validate log file is writable before starting the batch. + try: + with open(args.log_file, "a") as f: + pass + except OSError as exc: + _die(f"Cannot write to log file '{args.log_file}': {exc}") + + write_log_header(args.log_file, start_time) + + last_uid = None + + try: + while True: + result = detect_card(reader, previous_uid=last_uid) + if result is None: + # User pressed Enter + break + if result == "retry": + # Card vanished mid-detect, loop back + continue + + connection, uid = result + card_count += 1 + print(f"--- Card #{card_count} ---") + + try: + uid, validation_results = process_card( + args, reader, current_keys, new_keys, connection, uid, + cap_profiles, + ) + write_log_entry(args.log_file, card_count, uid, validation_results, "OK") + last_uid = uid + print() + _success(f"Card #{card_count} complete. ({card_count} total)") + except (RuntimeError, CardConnectionException, NoCardException) as exc: + _warn(f"Card #{card_count} failed: {exc}") + print() + retry = input(" Retry this card? [Y/n]: ").strip().lower() + if retry in ("", "y", "yes"): + card_count -= 1 + # Card must leave the RF field to reset after a failed + # operation — wait for removal before re-detecting. + _info("Remove card from reader to reset it...") + while _read_uid_quick(reader) is not None: + time.sleep(0.15) + _info("Card removed. Re-present when ready.") + last_uid = None # allow same UID to be picked up again + else: + write_log_entry( + args.log_file, card_count, + uid or "UNKNOWN", {}, f"FAILED: {exc}", + ) + last_uid = uid + + print() + + except KeyboardInterrupt: + print("\n") + _warn("Interrupted by user.") + + # Final summary + write_log_footer(args.log_file, card_count) + print() + print("-" * 50) + _info(f"Session complete. {card_count} card(s) processed.") + _info(f"Log written to: {os.path.abspath(args.log_file)}") + print() + + +if __name__ == "__main__": + main() diff --git a/profiles.py b/profiles.py new file mode 100644 index 0000000..82e124b --- /dev/null +++ b/profiles.py @@ -0,0 +1,109 @@ +""" +Applet validation profiles for the j3r180 batch provisioning tool. + +Each profile defines how to validate a specific applet after installation. +Profiles are matched to CAP files automatically via their applet AID. + +To add a new profile: + 1. Subclass AppletProfile + 2. Implement name and validate() + 3. Register it in APPLET_PROFILES keyed by the applet AID (hex string) +""" + +from abc import ABC, abstractmethod + +from smartcard.Exceptions import CardConnectionException, NoCardException +from smartcard.util import toHexString + + +class AppletProfile(ABC): + """Base class for applet-specific validation profiles.""" + + @property + @abstractmethod + def name(self) -> str: + """Human-readable name for logging/display.""" + ... + + @abstractmethod + def validate(self, connection) -> dict[str, str]: + """ + Validate the applet on a live pyscard connection. + + Must return a dict of named results: + {"installed": "yes", "public_key": "04AB..."} -- success + {"installed": "no", "error": "SELECT failed"} -- failure + + Must NOT raise exceptions. + """ + ... + + +class DefaultProfile(AppletProfile): + """Fallback profile for CAPs without a registered profile. SELECT-only check.""" + + def __init__(self, aid: list[int]): + self._aid = aid + self._name = f"Applet {bytes(aid).hex().upper()}" + + @property + def name(self) -> str: + return self._name + + def validate(self, connection) -> dict[str, str]: + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(self._aid)] + self._aid + try: + response, sw1, sw2 = connection.transmit(select_apdu) + except (CardConnectionException, NoCardException): + return {"installed": "no", "error": "Card communication error"} + if (sw1, sw2) == (0x90, 0x00): + return {"installed": "yes"} + return {"installed": "no", "error": f"SELECT failed (SW={sw1:02X}{sw2:02X})"} + + +class TeslaVCSEC(AppletProfile): + """Profile for the Tesla VCSEC identity applet (TeslaIdent.cap).""" + + PACKAGE_AID = [0xF4, 0x65, 0x73, 0x6C, 0x61, 0x4C, 0x6F, 0x67, 0x69, 0x63] + GET_PUBLIC_KEY_APDU = [0x80, 0x04, 0x00, 0x00, 0x00] + + @property + def name(self) -> str: + return "Tesla VCSEC" + + def validate(self, connection) -> dict[str, str]: + select_apdu = [0x00, 0xA4, 0x04, 0x00, len(self.PACKAGE_AID)] + self.PACKAGE_AID + try: + response, sw1, sw2 = connection.transmit(select_apdu) + except (CardConnectionException, NoCardException): + return {"installed": "no", "error": "Card communication error during SELECT"} + + if (sw1, sw2) != (0x90, 0x00): + return {"installed": "no", "error": f"SELECT failed (SW={sw1:02X}{sw2:02X})"} + + try: + response, sw1, sw2 = connection.transmit(self.GET_PUBLIC_KEY_APDU) + except (CardConnectionException, NoCardException): + return {"installed": "yes", "error": "Card communication error reading public key"} + + if (sw1, sw2) != (0x90, 0x00): + return {"installed": "yes", "error": f"GET PUBLIC KEY failed (SW={sw1:02X}{sw2:02X})"} + + pubkey_hex = toHexString(response).replace(" ", "") + return {"installed": "yes", "public_key": pubkey_hex} + + +# --------------------------------------------------------------------------- +# Registry: applet AID (hex string) → profile instance +# Keys are the APPLET-level AID (as returned by extract_applet_aids()), +# not the package AID. +# --------------------------------------------------------------------------- +APPLET_PROFILES: dict[str, AppletProfile] = { + "F465736C614C6F67696330303201": TeslaVCSEC(), +} + + +def get_profile(aid_bytes: list[int]) -> AppletProfile | None: + """Look up a registered profile for the given applet AID.""" + aid_hex = bytes(aid_bytes).hex().upper() + return APPLET_PROFILES.get(aid_hex)