Add Apex OTP importer
Import TOTP credentials from the export spreadsheet into a VivoKey Apex OTP (ykneo-oath) applet over PC/SC. Includes the OATH protocol layer (SELECT/VALIDATE/LIST/PUT with 61xx reassembly), PBKDF2 password authentication, pre-flight input validation, ask-per-conflict handling, optional javacard-memory profiling, and hardware-free unit tests.
This commit is contained in:
709
apex_otp_import.py
Normal file
709
apex_otp_import.py
Normal file
@@ -0,0 +1,709 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import TOTP credentials from otp-keys-export.xlsx into a VivoKey Apex OTP
|
||||
(ykneo-oath) applet over PC/SC.
|
||||
|
||||
Protocol verified against the apex-totp (com.vivokey.otp.YkneoOath) and
|
||||
javacard-memory applet sources. See
|
||||
docs/superpowers/specs/2026-07-08-apex-otp-import-design.md.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import csv
|
||||
import datetime
|
||||
import getpass
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# ykneo-oath enforces a 14-byte minimum HMAC key; the Yubico host right-zero-pads
|
||||
# short secrets to this length. Padding is HMAC-identical (HMAC zero-pads the key
|
||||
# to the block size regardless), so TOTP output is unchanged.
|
||||
HMAC_MIN_KEY = 14
|
||||
# The applet stores the HMAC key in a 64-byte ipad/opad buffer and rejects longer.
|
||||
MAX_KEY = 64
|
||||
MAX_NAME = 64
|
||||
DEFAULT_PERIOD = 30
|
||||
|
||||
# ykneo-oath tags / type|algorithm nibbles (verified against YkneoOath/OathObj).
|
||||
TAG_NAME = 0x71
|
||||
TAG_NAME_LIST = 0x72
|
||||
TAG_KEY = 0x73
|
||||
TAG_CHALLENGE = 0x74
|
||||
TAG_RESPONSE = 0x75
|
||||
TAG_VERSION = 0x79
|
||||
TAG_ALGORITHM = 0x7B
|
||||
|
||||
HOTP_TYPE = 0x10
|
||||
TOTP_TYPE = 0x20
|
||||
HMAC_SHA1 = 0x01
|
||||
HMAC_SHA256 = 0x02
|
||||
HMAC_SHA512 = 0x03 # rejected by the applet; used only to flag bad input
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential field helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def decode_secret(secret: str) -> bytes:
|
||||
"""Base32-decode an OATH secret, tolerating lowercase, spaces and missing
|
||||
padding."""
|
||||
s = secret.strip().replace(" ", "").upper()
|
||||
s += "=" * ((8 - len(s) % 8) % 8)
|
||||
return base64.b32decode(s)
|
||||
|
||||
|
||||
def pad_key(raw: bytes) -> bytes:
|
||||
"""Right-zero-pad a raw HMAC key to the applet's 14-byte minimum."""
|
||||
if len(raw) >= HMAC_MIN_KEY:
|
||||
return raw
|
||||
return raw + b"\x00" * (HMAC_MIN_KEY - len(raw))
|
||||
|
||||
|
||||
def format_cred_id(issuer: str, account: str, period: int = DEFAULT_PERIOD) -> str:
|
||||
"""Build the on-applet credential name: '[period/]issuer:account'.
|
||||
The period prefix is only added when it differs from the 30s default."""
|
||||
label = f"{issuer}:{account}" if issuer else account
|
||||
if period and period != DEFAULT_PERIOD:
|
||||
return f"{period}/{label}"
|
||||
return label
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credential:
|
||||
issuer: str
|
||||
account: str
|
||||
secret: str # Base32 text
|
||||
oath_type: int = TOTP_TYPE
|
||||
algorithm: int = HMAC_SHA1
|
||||
digits: int = 6
|
||||
period: int = DEFAULT_PERIOD
|
||||
source: str = ""
|
||||
|
||||
@property
|
||||
def cred_id(self) -> str:
|
||||
return format_cred_id(self.issuer, self.account, self.period)
|
||||
|
||||
@property
|
||||
def type_algo(self) -> int:
|
||||
return self.oath_type | self.algorithm
|
||||
|
||||
def raw_key(self) -> bytes:
|
||||
"""Base32-decode the secret (raises on invalid Base32)."""
|
||||
return decode_secret(self.secret)
|
||||
|
||||
def put_data(self) -> bytes:
|
||||
return build_put_data(self.cred_id, self.type_algo, self.digits,
|
||||
pad_key(self.raw_key()))
|
||||
|
||||
|
||||
_TYPE_MAP = {"TOTP": TOTP_TYPE, "HOTP": HOTP_TYPE}
|
||||
_ALGO_MAP = {"SHA1": HMAC_SHA1, "SHA256": HMAC_SHA256, "SHA512": HMAC_SHA512}
|
||||
|
||||
|
||||
def _match_columns(header):
|
||||
"""Map the OTP Keys header row to column indices by keyword (order-agnostic)."""
|
||||
col = {}
|
||||
for i, h in enumerate(header):
|
||||
if h is None:
|
||||
continue
|
||||
k = str(h).strip().lower()
|
||||
if "issuer" in k: col.setdefault("issuer", i)
|
||||
elif "account" in k: col.setdefault("account", i)
|
||||
elif "secret" in k: col.setdefault("secret", i)
|
||||
elif k.startswith("type"): col.setdefault("type", i)
|
||||
elif "algorithm" in k: col.setdefault("algorithm", i)
|
||||
elif "digit" in k: col.setdefault("digits", i)
|
||||
elif "period" in k: col.setdefault("period", i)
|
||||
elif "source" in k: col.setdefault("source", i)
|
||||
return col
|
||||
|
||||
|
||||
def load_credentials(xlsx_path, sheet="OTP Keys"):
|
||||
"""Load credentials from the curated columns of the export spreadsheet."""
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(xlsx_path, read_only=True, data_only=True)
|
||||
ws = wb[sheet]
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = next(rows)
|
||||
col = _match_columns(header)
|
||||
|
||||
def cell(row, key, default=None):
|
||||
i = col.get(key)
|
||||
return row[i] if i is not None and i < len(row) else default
|
||||
|
||||
def as_int(v, default):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
creds = []
|
||||
for row in rows:
|
||||
if row is None:
|
||||
continue
|
||||
secret = cell(row, "secret")
|
||||
issuer = cell(row, "issuer") or ""
|
||||
account = cell(row, "account") or ""
|
||||
if not secret and not issuer and not account:
|
||||
continue # skip blank rows
|
||||
creds.append(Credential(
|
||||
issuer=str(issuer).strip(),
|
||||
account=str(account).strip(),
|
||||
secret=str(secret or "").strip(),
|
||||
oath_type=_TYPE_MAP.get(str(cell(row, "type", "TOTP")).strip().upper(), TOTP_TYPE),
|
||||
algorithm=_ALGO_MAP.get(str(cell(row, "algorithm", "SHA1")).strip().upper(), HMAC_SHA1),
|
||||
digits=as_int(cell(row, "digits"), 6),
|
||||
period=as_int(cell(row, "period"), DEFAULT_PERIOD),
|
||||
source=str(cell(row, "source", "") or "").strip(),
|
||||
))
|
||||
wb.close()
|
||||
return creds
|
||||
|
||||
|
||||
def validate_input(creds):
|
||||
"""Return a list of human-readable problems that must be fixed in the source
|
||||
data before importing. An empty list means the input is clean."""
|
||||
problems = []
|
||||
seen = {}
|
||||
for c in creds:
|
||||
cid = c.cred_id
|
||||
# duplicate credential ids (OATH keys by name, so a dup silently clobbers)
|
||||
if cid in seen:
|
||||
problems.append(
|
||||
f"Duplicate credential name '{cid}': {seen[cid]} and {c.source} "
|
||||
f"map to the same applet entry (different secrets would collide)."
|
||||
)
|
||||
else:
|
||||
seen[cid] = c.source
|
||||
|
||||
# name length
|
||||
if len(cid.encode("utf-8")) > MAX_NAME:
|
||||
problems.append(f"Name too long (>{MAX_NAME} bytes): '{cid}' ({c.source}).")
|
||||
|
||||
# algorithm supported by the applet
|
||||
if c.algorithm not in (HMAC_SHA1, HMAC_SHA256):
|
||||
problems.append(
|
||||
f"Unsupported algorithm 0x{c.algorithm:02X} (SHA512?) for '{cid}' "
|
||||
f"({c.source}); the applet accepts only SHA1/SHA256."
|
||||
)
|
||||
|
||||
# secret decodes and fits
|
||||
try:
|
||||
raw = c.raw_key()
|
||||
except Exception as e:
|
||||
problems.append(f"Invalid Base32 secret for '{cid}' ({c.source}): {e}.")
|
||||
continue
|
||||
if len(raw) == 0:
|
||||
problems.append(f"Empty secret for '{cid}' ({c.source}).")
|
||||
elif len(raw) > MAX_KEY:
|
||||
problems.append(
|
||||
f"Key too long ({len(raw)}>{MAX_KEY} bytes) for '{cid}' ({c.source})."
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BER-TLV helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tlv(tag: int, value: bytes) -> bytes:
|
||||
"""Encode a single-byte-tag BER-TLV with 1/2/3-byte length forms."""
|
||||
n = len(value)
|
||||
if n < 0x80:
|
||||
length = bytes([n])
|
||||
elif n <= 0xFF:
|
||||
length = bytes([0x81, n])
|
||||
else:
|
||||
length = bytes([0x82, (n >> 8) & 0xFF, n & 0xFF])
|
||||
return bytes([tag]) + length + value
|
||||
|
||||
|
||||
_HMAC_HASH = {HMAC_SHA1: "sha1", HMAC_SHA256: "sha256"}
|
||||
|
||||
|
||||
def derive_key(password: str, salt: bytes) -> bytes:
|
||||
"""Derive the 16-byte OATH access key from a password, per the ykneo-oath
|
||||
convention: PBKDF2-HMAC-SHA1(password, salt = device id from SELECT, 1000
|
||||
rounds). PBKDF2 always uses SHA1 here, regardless of the challenge algo."""
|
||||
return hashlib.pbkdf2_hmac("sha1", password.encode("utf-8"), salt, 1000, 16)
|
||||
|
||||
|
||||
def compute_response(key: bytes, challenge: bytes, algo: int = HMAC_SHA1) -> bytes:
|
||||
"""HMAC(key, challenge) using the algorithm named by the SELECT ALGORITHM
|
||||
tag (SHA1 in practice)."""
|
||||
return hmac.new(key, challenge, _HMAC_HASH[algo]).digest()
|
||||
|
||||
|
||||
def build_put_data(name: str, type_algo: int, digits: int, key: bytes) -> bytes:
|
||||
"""PUT payload: NAME(0x71) + KEY(0x73, [type|algo, digits, key...]).
|
||||
`key` must already be padded (see pad_key). PROPERTY/IMF are omitted
|
||||
(TOTP, no touch)."""
|
||||
return (
|
||||
tlv(TAG_NAME, name.encode("utf-8"))
|
||||
+ tlv(TAG_KEY, bytes([type_algo, digits]) + key)
|
||||
)
|
||||
|
||||
|
||||
def build_validate_data(response: bytes, client_challenge: bytes) -> bytes:
|
||||
"""VALIDATE payload: RESPONSE(0x75, our HMAC of the device challenge) +
|
||||
CHALLENGE(0x74, our fresh challenge for mutual auth)."""
|
||||
return tlv(TAG_RESPONSE, response) + tlv(TAG_CHALLENGE, client_challenge)
|
||||
|
||||
|
||||
INS_SEND_REMAINING = 0xA5
|
||||
|
||||
|
||||
class CardChannel:
|
||||
"""Thin wrapper over a pyscard connection that transceives APDUs and
|
||||
transparently follows 61xx status words with SEND REMAINING."""
|
||||
|
||||
def __init__(self, connection):
|
||||
self.conn = connection
|
||||
|
||||
def send(self, cla, ins, p1, p2, data: bytes = b"", le=None):
|
||||
"""Transmit one APDU; returns (response_bytes, sw) with sw = (sw1<<8)|sw2.
|
||||
Reassembles chained 61xx responses."""
|
||||
apdu = [cla, ins, p1, p2]
|
||||
if data:
|
||||
apdu += [len(data)] + list(data)
|
||||
if le is not None:
|
||||
apdu += [le]
|
||||
resp, sw1, sw2 = self.conn.transmit(apdu)
|
||||
out = bytes(resp)
|
||||
while sw1 == 0x61:
|
||||
resp, sw1, sw2 = self.conn.transmit(
|
||||
[0x00, INS_SEND_REMAINING, 0x00, 0x00, sw2])
|
||||
out += bytes(resp)
|
||||
return out, (sw1 << 8) | sw2
|
||||
|
||||
|
||||
def parse_tlvs(data: bytes):
|
||||
"""Parse a concatenation of single-byte-tag BER-TLVs into an ordered list
|
||||
of (tag, value) tuples. Preserves repeated tags (e.g. LIST 0x72 entries)."""
|
||||
out = []
|
||||
i = 0
|
||||
n = len(data)
|
||||
while i < n:
|
||||
tag = data[i]
|
||||
i += 1
|
||||
first = data[i]
|
||||
if first < 0x80:
|
||||
length = first
|
||||
i += 1
|
||||
elif first == 0x81:
|
||||
length = data[i + 1]
|
||||
i += 2
|
||||
elif first == 0x82:
|
||||
length = (data[i + 1] << 8) | data[i + 2]
|
||||
i += 3
|
||||
else:
|
||||
raise ValueError(f"unsupported TLV length byte 0x{first:02X}")
|
||||
out.append((tag, data[i:i + length]))
|
||||
i += length
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OATH session (ykneo-oath) over a CardChannel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Applet-instance AID first, then the shorter Yubico prefixes (the applet
|
||||
# prefix-selects, so any of these reaches it).
|
||||
OATH_AIDS = [
|
||||
bytes.fromhex("A0000005272101014150455801"),
|
||||
bytes.fromhex("A000000527210101"),
|
||||
bytes.fromhex("A0000005272101"),
|
||||
]
|
||||
|
||||
INS_SELECT = 0xA4
|
||||
INS_PUT = 0x01
|
||||
INS_LIST = 0xA1
|
||||
INS_VALIDATE = 0xA3
|
||||
|
||||
_SW_MESSAGES = {
|
||||
0x9000: "OK",
|
||||
0x6A84: "applet full (no space)",
|
||||
0x6982: "authentication required",
|
||||
0x6A80: "wrong data / incorrect password",
|
||||
0x6984: "no such object",
|
||||
0x6D00: "instruction not supported (wrong applet?)",
|
||||
0x6E00: "class not supported",
|
||||
}
|
||||
|
||||
|
||||
def sw_message(sw: int) -> str:
|
||||
return _SW_MESSAGES.get(sw, f"error SW={sw:04X}")
|
||||
|
||||
|
||||
class OathError(Exception):
|
||||
def __init__(self, sw: int, message: str = ""):
|
||||
self.sw = sw
|
||||
super().__init__(message or sw_message(sw))
|
||||
|
||||
|
||||
class OathSession:
|
||||
"""ykneo-oath session: SELECT (with AID fallback), password VALIDATE, LIST,
|
||||
PUT. All wire encoding verified against the applet source."""
|
||||
|
||||
def __init__(self, channel: "CardChannel", rng=os.urandom, aids=OATH_AIDS):
|
||||
self.ch = channel
|
||||
self.rng = rng
|
||||
self.aids = aids
|
||||
self.version = None
|
||||
self.device_id = None
|
||||
self.challenge = None
|
||||
self.algorithm = HMAC_SHA1
|
||||
self._key = None
|
||||
|
||||
def select(self):
|
||||
last_sw = None
|
||||
for aid in self.aids:
|
||||
data, sw = self.ch.send(0x00, INS_SELECT, 0x04, 0x00, data=aid, le=0x00)
|
||||
if sw == 0x9000 and data and data[0] == TAG_VERSION:
|
||||
self._parse_select(data)
|
||||
return self
|
||||
last_sw = sw
|
||||
raise OathError(last_sw or 0, "OATH applet not found (SELECT failed)")
|
||||
|
||||
def _parse_select(self, data):
|
||||
self.challenge = None
|
||||
for tag, val in parse_tlvs(data):
|
||||
if tag == TAG_VERSION:
|
||||
self.version = tuple(val)
|
||||
elif tag == TAG_NAME:
|
||||
self.device_id = val
|
||||
elif tag == TAG_CHALLENGE:
|
||||
self.challenge = val
|
||||
elif tag == TAG_ALGORITHM:
|
||||
self.algorithm = val[0] if val else HMAC_SHA1
|
||||
|
||||
@property
|
||||
def locked(self) -> bool:
|
||||
return self.challenge is not None
|
||||
|
||||
def validate(self, password: str):
|
||||
"""Mutually authenticate against a password-protected applet."""
|
||||
if not self.locked:
|
||||
return
|
||||
key = derive_key(password, self.device_id)
|
||||
our_response = compute_response(key, self.challenge, self.algorithm)
|
||||
client_challenge = self.rng(8)
|
||||
data, sw = self.ch.send(
|
||||
0x00, INS_VALIDATE, 0x00, 0x00,
|
||||
data=build_validate_data(our_response, client_challenge), le=0x00)
|
||||
if sw != 0x9000:
|
||||
raise OathError(sw, "authentication failed (incorrect password?)")
|
||||
device_response = dict(parse_tlvs(data)).get(TAG_RESPONSE)
|
||||
if device_response != compute_response(key, client_challenge, self.algorithm):
|
||||
raise OathError(sw, "mutual authentication check failed")
|
||||
self._key = key
|
||||
return
|
||||
|
||||
def list_names(self):
|
||||
data, sw = self.ch.send(0x00, INS_LIST, 0x00, 0x00, le=0x00)
|
||||
if sw != 0x9000:
|
||||
raise OathError(sw, "LIST failed")
|
||||
names = []
|
||||
for tag, val in parse_tlvs(data):
|
||||
if tag == TAG_NAME_LIST and len(val) >= 1:
|
||||
names.append(val[1:].decode("utf-8", "replace")) # drop type byte
|
||||
return names
|
||||
|
||||
def put(self, cred: "Credential"):
|
||||
data, sw = self.ch.send(0x00, INS_PUT, 0x00, 0x00, data=cred.put_data())
|
||||
if sw != 0x9000:
|
||||
raise OathError(sw)
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional javacard-memory applet (for capacity checks / profiling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEMORY_AID = bytes.fromhex("A0000008466D656D6F727901")
|
||||
INS_GET_BATCH = 0x01
|
||||
|
||||
|
||||
def parse_memory(data: bytes) -> dict:
|
||||
"""Parse the 12-byte SELECT response of the javacard-memory applet."""
|
||||
return {
|
||||
"available_persistent": int.from_bytes(data[0:4], "big"),
|
||||
"total_persistent": int.from_bytes(data[4:8], "big"),
|
||||
"transient_reset": int.from_bytes(data[8:10], "big"),
|
||||
"transient_deselect": int.from_bytes(data[10:12], "big"),
|
||||
}
|
||||
|
||||
|
||||
class MemorySession:
|
||||
"""Optional javacard-memory applet. select() returns None if absent."""
|
||||
|
||||
def __init__(self, channel: "CardChannel"):
|
||||
self.ch = channel
|
||||
|
||||
def select(self):
|
||||
data, sw = self.ch.send(0x00, INS_SELECT, 0x04, 0x00, data=MEMORY_AID, le=0x00)
|
||||
if sw != 0x9000 or len(data) < 12:
|
||||
return None
|
||||
return parse_memory(data)
|
||||
|
||||
def available(self):
|
||||
"""SELECT the applet and return available persistent bytes (or None)."""
|
||||
m = self.select()
|
||||
return m["available_persistent"] if m else None
|
||||
|
||||
def batch(self):
|
||||
data, sw = self.ch.send(0x00, INS_GET_BATCH, 0x00, 0x00, le=0x00)
|
||||
return data.hex() if sw == 0x9000 else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI / orchestration (hardware I/O)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_INPUT = "otp-keys-export.xlsx"
|
||||
DEFAULT_MEMLOG = "apex-otp-memlog.csv"
|
||||
MEM_PER_CRED_ESTIMATE = 250 # rough EEPROM per credential (3x64B arrays + overhead)
|
||||
|
||||
|
||||
def get_uid(connection) -> str:
|
||||
"""Contactless/contact UID via the PC/SC GET DATA pseudo-APDU."""
|
||||
try:
|
||||
resp, sw1, sw2 = connection.transmit([0xFF, 0xCA, 0x00, 0x00, 0x00])
|
||||
if sw1 == 0x90:
|
||||
return bytes(resp).hex().upper()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _readers_with_cards():
|
||||
from smartcard.System import readers
|
||||
out = []
|
||||
for r in readers():
|
||||
try:
|
||||
conn = r.createConnection()
|
||||
conn.connect()
|
||||
out.append((str(r), conn))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def pick_reader(reader_filter=None):
|
||||
"""Return (reader_name, connection) for the single Apex holding an OATH
|
||||
applet, prompting if several are present. (None, None) if none found."""
|
||||
candidates = _readers_with_cards()
|
||||
if reader_filter:
|
||||
candidates = [(n, c) for (n, c) in candidates
|
||||
if reader_filter.lower() in n.lower()]
|
||||
oath = []
|
||||
for name, conn in candidates:
|
||||
try:
|
||||
OathSession(CardChannel(conn)).select()
|
||||
oath.append((name, conn))
|
||||
except Exception:
|
||||
try:
|
||||
conn.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
if not oath:
|
||||
print("No Apex OTP applet found on any reader with a card.")
|
||||
print("Place your Apex on a reader (or insert the test card) and re-run.")
|
||||
return None, None
|
||||
if len(oath) == 1:
|
||||
return oath[0]
|
||||
print("Multiple readers present an OATH applet:")
|
||||
for i, (name, _) in enumerate(oath):
|
||||
print(f" [{i}] {name}")
|
||||
idx = int(input("Choose target reader index: ").strip())
|
||||
chosen = oath[idx]
|
||||
for j, (_, conn) in enumerate(oath):
|
||||
if j != idx:
|
||||
try:
|
||||
conn.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
return chosen
|
||||
|
||||
|
||||
class ConflictPolicy:
|
||||
"""Decide overwrite/skip for a credential already on the applet."""
|
||||
|
||||
def __init__(self, args):
|
||||
self.always = ("overwrite" if args.overwrite_all
|
||||
else "skip" if args.skip_existing else None)
|
||||
|
||||
def decide(self, name):
|
||||
if self.always:
|
||||
return self.always
|
||||
if not sys.stdin.isatty():
|
||||
raise SystemExit(
|
||||
f"'{name}' already exists on the applet and no "
|
||||
f"--overwrite-all/--skip-existing was given (non-interactive).")
|
||||
while True:
|
||||
ans = input(f" '{name}' exists. [o]verwrite / [s]kip / "
|
||||
f"[a]ll overwrite / [x] skip all: ").strip().lower()
|
||||
if ans == "o":
|
||||
return "overwrite"
|
||||
if ans == "s":
|
||||
return "skip"
|
||||
if ans == "a":
|
||||
self.always = "overwrite"
|
||||
return "overwrite"
|
||||
if ans == "x":
|
||||
self.always = "skip"
|
||||
return "skip"
|
||||
|
||||
|
||||
def _put_apdu_hex(cred):
|
||||
data = cred.put_data()
|
||||
return (bytes([0x00, INS_PUT, 0x00, 0x00, len(data)]) + data).hex().upper()
|
||||
|
||||
|
||||
def parse_args(argv=None):
|
||||
p = argparse.ArgumentParser(
|
||||
description="Import TOTP credentials from the export spreadsheet into a "
|
||||
"VivoKey Apex OTP (ykneo-oath) applet over PC/SC.")
|
||||
p.add_argument("--input", default=DEFAULT_INPUT,
|
||||
help=f"export .xlsx (default: {DEFAULT_INPUT})")
|
||||
p.add_argument("--reader", help="substring to select a specific PC/SC reader")
|
||||
p.add_argument("--dry-run", action="store_true",
|
||||
help="validate and print the APDUs; no card I/O")
|
||||
p.add_argument("--list", action="store_true",
|
||||
help="read-only: list credentials already on the applet")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--overwrite-all", action="store_true",
|
||||
help="overwrite any credential that already exists")
|
||||
g.add_argument("--skip-existing", action="store_true",
|
||||
help="skip any credential that already exists")
|
||||
p.add_argument("--profile-memory", action="store_true",
|
||||
help="record per-credential EEPROM consumption to the mem-log")
|
||||
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)")
|
||||
return p.parse_args(argv)
|
||||
|
||||
|
||||
def _reauth(oath, pw):
|
||||
oath.select()
|
||||
if oath.locked:
|
||||
oath.validate(pw)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
|
||||
# 1. Load + validate BEFORE touching any reader.
|
||||
creds = load_credentials(args.input)
|
||||
problems = validate_input(creds)
|
||||
if problems:
|
||||
print(f"Input validation FAILED ({len(problems)} problem(s)) - "
|
||||
f"fix the spreadsheet and re-run:")
|
||||
for p in problems:
|
||||
print(" -", p)
|
||||
return 2
|
||||
print(f"Loaded and validated {len(creds)} credentials from {args.input}.")
|
||||
|
||||
# 2. Dry-run: no card.
|
||||
if args.dry_run:
|
||||
print("\n--dry-run: PUT APDUs that WOULD be sent (no card I/O):")
|
||||
for c in creds:
|
||||
print(f" {c.cred_id}")
|
||||
print(f" {_put_apdu_hex(c)}")
|
||||
return 0
|
||||
|
||||
# 3. Reader/card detection.
|
||||
reader, connection = pick_reader(args.reader)
|
||||
if connection is None:
|
||||
return 1
|
||||
channel = CardChannel(connection)
|
||||
|
||||
# 4. Capacity pre-check (selects the memory applet, before the OATH session).
|
||||
if not args.list:
|
||||
avail = MemorySession(channel).available()
|
||||
if avail is not None:
|
||||
est = MEM_PER_CRED_ESTIMATE * len(creds)
|
||||
msg = (f"Memory applet: {avail} bytes free "
|
||||
f"(rough estimate ~{est} bytes for {len(creds)} credentials).")
|
||||
print("WARNING: " + msg + " They may not all fit." if avail < est else msg)
|
||||
|
||||
# 5. OATH session.
|
||||
oath = OathSession(channel)
|
||||
oath.select()
|
||||
print(f"OATH applet on '{reader}': v{'.'.join(map(str, oath.version))}, "
|
||||
f"{'LOCKED' if oath.locked else 'unlocked'}.")
|
||||
pw = None
|
||||
if oath.locked:
|
||||
pw = args.password or getpass.getpass("Applet password: ")
|
||||
oath.validate(pw)
|
||||
print("Authenticated OK.")
|
||||
existing = set(oath.list_names())
|
||||
|
||||
if args.list:
|
||||
print(f"\n{len(existing)} credential(s) currently on the applet:")
|
||||
for n in sorted(existing):
|
||||
print(" ", n)
|
||||
return 0
|
||||
|
||||
# 6. Import.
|
||||
policy = ConflictPolicy(args)
|
||||
writer = uid = batch = None
|
||||
memlog_fh = None
|
||||
if args.profile_memory:
|
||||
uid = get_uid(connection)
|
||||
ms = MemorySession(channel)
|
||||
ms.select()
|
||||
batch = ms.batch()
|
||||
_reauth(oath, pw)
|
||||
new_file = not os.path.exists(args.memlog)
|
||||
memlog_fh = open(args.memlog, "a", newline="", encoding="utf-8")
|
||||
writer = csv.writer(memlog_fh)
|
||||
if new_file:
|
||||
writer.writerow(["timestamp_iso", "card_uid", "mem_batch", "key_name",
|
||||
"avail_before", "avail_after", "consumed_bytes"])
|
||||
|
||||
ok = skipped = failed = 0
|
||||
try:
|
||||
for c in creds:
|
||||
if c.cred_id in existing and policy.decide(c.cred_id) == "skip":
|
||||
skipped += 1
|
||||
print(f" skip {c.cred_id} (already present)")
|
||||
continue
|
||||
try:
|
||||
if args.profile_memory:
|
||||
before = MemorySession(channel).available()
|
||||
_reauth(oath, pw)
|
||||
oath.put(c)
|
||||
after = MemorySession(channel).available()
|
||||
_reauth(oath, pw)
|
||||
consumed = (before - after) if (before is not None
|
||||
and after is not None) else ""
|
||||
writer.writerow([datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
uid, batch, c.cred_id, before, after, consumed])
|
||||
print(f" ok {c.cred_id} (consumed ~{consumed} B)")
|
||||
else:
|
||||
oath.put(c)
|
||||
print(f" ok {c.cred_id}")
|
||||
existing.add(c.cred_id)
|
||||
ok += 1
|
||||
except OathError as e:
|
||||
failed += 1
|
||||
print(f" FAIL {c.cred_id}: {e}")
|
||||
if e.sw == 0x6A84:
|
||||
print(" Applet reports FULL (0x6A84) - stopping.")
|
||||
break
|
||||
finally:
|
||||
if memlog_fh:
|
||||
memlog_fh.close()
|
||||
|
||||
print(f"\nDone: {ok} written, {skipped} skipped, {failed} failed.")
|
||||
if args.profile_memory:
|
||||
print(f"Memory profile appended to {args.memlog}.")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user