Initial commit

This commit is contained in:
michael
2026-02-09 15:19:21 -08:00
commit 814fbc8f66
5 changed files with 1108 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -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
*~

75
debug_select.py Normal file
View File

@@ -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}")

67
log.py Normal file
View File

@@ -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.")

834
main.py Normal file
View File

@@ -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()

109
profiles.py Normal file
View File

@@ -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)