Add QR-to-CSV utility and switch canonical format to CSV

Add qr_to_sheet.py: decode saved OTP QR screenshots and append new
credentials to the canonical CSV, deduplicated on the otpauth:// data.
Add otpauth.py (parse_otpauth / canonical_key / build_otpauth), shared
by both tools. The canonical format becomes a minimal CSV (issuer,
account, otpauth_uri, source_file); load_credentials reads CSV or xlsx
and derives crypto from the immutable otpauth_uri while taking
issuer/account from their editable label columns.
This commit is contained in:
2026-07-08 14:49:59 -07:00
parent cd705a446b
commit 07f65835d0
7 changed files with 577 additions and 48 deletions

View File

@@ -18,6 +18,8 @@ import os
import sys
from dataclasses import dataclass, field
from otpauth import parse_otpauth
# 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.
@@ -105,13 +107,14 @@ _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)."""
"""Map a 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)
if "otpauth" in k: col.setdefault("otpauth", i)
elif "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)
@@ -122,45 +125,68 @@ def _match_columns(header):
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)
def _as_int(v, default):
try:
return int(v)
except (TypeError, ValueError):
return default
def _read_field_dicts(path, sheet):
"""Read a CSV or XLSX into a list of {field: value} dicts keyed by the
matched column names."""
ext = os.path.splitext(path)[1].lower()
if ext == ".csv":
with open(path, newline="", encoding="utf-8") as f:
rows = list(csv.reader(f))
else:
from openpyxl import load_workbook
wb = load_workbook(path, read_only=True, data_only=True)
rows = list(wb[sheet].iter_rows(values_only=True))
wb.close()
if not rows:
return []
header, data = rows[0], rows[1:]
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:
out = []
for row in data:
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
out.append({k: (row[i] if i is not None and i < len(row) else None)
for k, i in col.items()})
return out
def load_credentials(path, sheet="OTP Keys"):
"""Load credentials from a CSV or XLSX. Issuer/account come from their
(editable) columns; the crypto (secret/type/algorithm/digits/period) is
derived from the immutable otpauth_uri when present, else from the
individual crypto columns (legacy)."""
creds = []
for f in _read_field_dicts(path, sheet):
issuer = str(f.get("issuer") or "").strip()
account = str(f.get("account") or "").strip()
otp = str(f.get("otpauth") or "").strip()
secret_col = f.get("secret")
if not (otp or secret_col or issuer or account):
continue # blank row
if otp.startswith("otpauth://"):
o = parse_otpauth(otp)
secret = o.secret
oath_type = _TYPE_MAP.get(o.type.upper(), TOTP_TYPE)
algorithm = _ALGO_MAP.get(o.algorithm.upper(), HMAC_SHA1)
digits, period = o.digits, o.period
else:
secret = str(secret_col or "").strip()
oath_type = _TYPE_MAP.get(str(f.get("type") or "TOTP").strip().upper(), TOTP_TYPE)
algorithm = _ALGO_MAP.get(str(f.get("algorithm") or "SHA1").strip().upper(), HMAC_SHA1)
digits = _as_int(f.get("digits"), 6)
period = _as_int(f.get("period"), DEFAULT_PERIOD)
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(),
issuer=issuer, account=account, secret=secret,
oath_type=oath_type, algorithm=algorithm, digits=digits, period=period,
source=str(f.get("source") or "").strip(),
))
wb.close()
return creds
@@ -528,7 +554,7 @@ class MemorySession:
# CLI / orchestration (hardware I/O)
# ---------------------------------------------------------------------------
DEFAULT_INPUT = "otp-keys-export.xlsx"
DEFAULT_INPUT = "otp-keys.csv"
DEFAULT_MEMLOG = "apex-otp-memlog.csv"
MEM_PER_CRED_ESTIMATE = 250 # rough EEPROM per credential (3x64B arrays + overhead)