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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user