diff --git a/README.md b/README.md index 2ec7458..60914bd 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,40 @@ # otp-import -Import TOTP credentials from an exported spreadsheet into a VivoKey Apex OTP -(ykneo-oath) applet over PC/SC. +Turn saved OTP QR-code screenshots into credentials on a VivoKey Apex OTP +(ykneo-oath) applet, via a canonical CSV. -The importer reads the curated columns of an export spreadsheet -(`otp-keys-export.xlsx`: Issuer, Account, Secret, Type, Algorithm, Digits, -Period) and writes each credential to the applet using the standard YKOATH -`PUT` command. It authenticates first if the applet is password-protected, asks -before overwriting credentials that already exist, and can optionally profile -per-credential on-chip memory consumption via the `javacard-memory` applet. +## Pipeline + +1. **QR -> CSV** (`qr_to_sheet.py`): decode QR screenshots and append new + credentials to `otp-keys.csv`, deduplicated on the `otpauth://` data. +2. **Edit labels (optional)**: adjust the `issuer` / `account` columns to taste. + Never edit the `otpauth_uri` column - it is the immutable source of truth. +3. **CSV -> applet** (`apex_otp_import.py`): write to the Apex, validating for + anything you may have broken (duplicate names, invalid characters, overlength). + +The CSV has four columns: `issuer`, `account` (editable labels), `otpauth_uri` +(do not edit), and `source_file`. The secret and other crypto parameters live +inside `otpauth_uri` and are always taken from there. + +``` +# Populate/refresh the CSV from a folder of QR screenshots +python qr_to_sheet.py --images /path/to/qr-screenshots --csv otp-keys.csv +python qr_to_sheet.py --images ... --csv ... --dry-run # preview, no write +``` + +Undecodable images (stylized/colored QRs, or text-only screenshots) are listed +by filename so you can add them by hand. + +## Importing + +Import TOTP credentials from the CSV (or a legacy `.xlsx`) into the applet. + +The importer reads `otp-keys.csv` (or a legacy `.xlsx`), takes the credential +name from the `issuer`/`account` columns and the crypto from `otpauth_uri`, and +writes each credential using the standard YKOATH `PUT` command. It authenticates +first if the applet is password-protected, asks before overwriting credentials +that already exist, and can optionally profile per-credential on-chip memory +consumption via the `javacard-memory` applet. The protocol layer is verified against the applet sources (`com.vivokey.otp.YkneoOath` / `OathObj`) and `doc/Protocol.txt`. See @@ -31,13 +57,13 @@ personal folder) and point the importer at it with `--input`. ``` # 1. Validate the data and preview the APDUs (no card needed) -python apex_otp_import.py --input /path/to/otp-keys-export.xlsx --dry-run +python apex_otp_import.py --input /path/to/otp-keys.csv --dry-run # 2. Read-only: list what is already on the applet -python apex_otp_import.py --input /path/to/otp-keys-export.xlsx --list +python apex_otp_import.py --input /path/to/otp-keys.csv --list # 3. Import (asks before overwriting existing credentials) -python apex_otp_import.py --input /path/to/otp-keys-export.xlsx +python apex_otp_import.py --input /path/to/otp-keys.csv # Non-interactive conflict handling python apex_otp_import.py --input ... --overwrite-all diff --git a/apex_otp_import.py b/apex_otp_import.py index e733bef..2493d05 100644 --- a/apex_otp_import.py +++ b/apex_otp_import.py @@ -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) diff --git a/docs/qr-to-csv-design.md b/docs/qr-to-csv-design.md new file mode 100644 index 0000000..7827767 --- /dev/null +++ b/docs/qr-to-csv-design.md @@ -0,0 +1,119 @@ +# QR-to-CSV Utility — Design + +Date: 2026-07-08 +Status: Draft for review + +## Goal and workflow + +A companion utility that turns saved OTP QR-code screenshots into rows of the +canonical CSV that the importer consumes. The overall flow is a one-way +pipeline, not a hand-maintained file: + +1. **QR -> CSV** (`qr_to_sheet.py`): decode QR images, deduplicate on the + `otpauth://` data, and append only genuinely-new entries to the CSV. +2. **Optional user edits**: the user may adjust the *label* columns (issuer, + account). They are instructed never to touch the `otpauth://` data. +3. **CSV -> applet** (`apex_otp_import.py`): push to the Apex, validating for + human-introduced problems (duplicate names, invalid characters, overlength). + +The two stages guard different risks: stage 1 prevents re-importing the same QR; +stage 3 catches bad edits. The `otpauth://` string is the immutable source of +truth throughout. + +## Canonical format: CSV + +Replaces the `.xlsx` as the canonical format (open, plain-text, git-diffable, +trivial to append and dedup). Minimal schema: + +| Column | Editable? | Purpose | +| ------ | --------- | ------- | +| `issuer` | yes (label) | Issuer shown on the applet; seeded from the otpauth. | +| `account` | yes (label) | Account shown on the applet; seeded from the otpauth label. | +| `otpauth_uri` | **no** | The raw decoded `otpauth://` string. Dedup key (stage 1) and crypto source (stage 3). | +| `source_file` | informational | Filename the QR was decoded from. | + +The secret remains visible (inside `otpauth_uri`), so nothing is hidden. The +credential name written to the applet is `issuer:account` from the editable +columns; the secret/type/algorithm/digits/period come from parsing +`otpauth_uri`. + +A one-time conversion turns the existing `otp-keys-export.xlsx` "OTP Keys" sheet +into `otp-keys.csv` (issuer, account, otpauth_uri, source_file), so no data is +lost and the tools operate on the CSV going forward. + +## Shared module: `otpauth.py` + +Used by both tools: + +- `parse_otpauth(uri) -> OtpAuth`: parse `otpauth://TYPE/LABEL?params` into + `type` (totp/hotp), `issuer` (from the `issuer` param, else the label prefix + before `:`), `account` (label after the `issuer:` prefix, else the whole + label), `secret` (uppercased, spaces removed), `algorithm` (default SHA1), + `digits` (default 6), `period` (default 30). Percent-decodes label/issuer. +- `canonical_key(otp) -> tuple`: a normalized dedup key + `(type, issuer, account, secret, algorithm, digits, period)` with defaults + filled and the secret uppercased. Two scans of the same credential — even with + the issuer given via the label vs. the `issuer` param — produce the same key; + a different secret produces a different key. +- `build_otpauth(...) -> str`: reconstruct a URI (used by the xlsx conversion + for any legacy row lacking a stored URI). + +## `qr_to_sheet.py` + +CLI: `qr_to_sheet.py [--images DIR] [--csv PATH] [--dry-run]` + +1. Enumerate QR images in `--images` (default: the data folder) — `*.png`, + `*.jpg`. +2. Decode each with OpenCV using the robust multi-pass strategy (direct -> + grayscale -> upscale 2x/3x/4x/0.5x -> Otsu/adaptive threshold). +3. `parse_otpauth` each decoded `otpauth://totp/...`. An `otpauth-migration://` + payload is reported as unsupported (out of scope), not parsed. +4. Load the existing CSV (if any); build the set of `canonical_key` values from + every row's `otpauth_uri`. +5. For each decoded QR: if its `canonical_key` is already present (in the CSV or + already added this run), skip it as a duplicate; otherwise append a row + (`issuer`, `account` from the parse; `otpauth_uri` = the raw decoded string; + `source_file` = image filename). +6. Never rewrite or reorder existing rows — append only. +7. Print a summary: added, skipped-as-duplicate, and **undecodable** images + (stylized/colored QRs or text-only screenshots) listed by filename for + manual entry. `--dry-run` reports without writing. + +## Importer changes (`apex_otp_import.py`) + +- `load_credentials` accepts CSV or XLSX, chosen by file extension. +- Crypto (secret/type/algorithm/digits/period) is derived by + `parse_otpauth(otpauth_uri)` when an `otpauth_uri` is present; `issuer` and + `account` come from their columns. When no `otpauth_uri` is present (legacy + xlsx), it falls back to the individual crypto columns as today. +- `validate_input` is unchanged in intent and still catches the stage-3 risks + (duplicate `issuer:account`, control/`:`/`/` characters, overlength names, + bad digits/period, invalid Base32, unsupported algorithm). + +## Testing (hardware-free, TDD) + +- `parse_otpauth`: issuer from param vs. label prefix; percent-decoding; + defaults for algorithm/digits/period; account extraction. +- `canonical_key`: equal for label-issuer vs. param-issuer forms of the same + credential; differs when the secret differs; secret case-insensitive. +- Dedup: given existing CSV rows and a batch of candidates, only the new + canonical keys are appended; intra-run duplicates collapse. +- CSV read/append round-trip; append preserves existing rows verbatim. +- `load_credentials` from CSV: crypto comes from `otpauth_uri`, labels from the + editable columns; legacy fallback path still works. + +QR image decoding is the untestable I/O edge, validated against the real PNGs. + +## Out of scope (YAGNI) + +`otpauth-migration://` (Google Authenticator batch export protobuf) — reported +as unsupported; OCR of text-only screenshots; editing or removing existing rows; +backup/recovery `.txt` codes. + +## Files (in the `otp-import` repo) + +New: `qr_to_sheet.py`, `otpauth.py`, `test_qr.py` (parse/canonical/dedup/CSV +tests), `docs/qr-to-csv-design.md`. Changed: `apex_otp_import.py` +(`load_credentials` CSV + otpauth-authoritative), `test_oath.py` (CSV-load +cases), `README.md`. One-time output: `otp-keys.csv` (git-ignored, lives with +the private data, not in the repo). diff --git a/otpauth.py b/otpauth.py new file mode 100644 index 0000000..c6b82a9 --- /dev/null +++ b/otpauth.py @@ -0,0 +1,73 @@ +"""Parse and canonicalize otpauth:// URIs. Shared by the QR-to-CSV tool and the +importer; the otpauth:// string is the immutable source of truth for a +credential.""" + +from dataclasses import dataclass +from urllib.parse import urlparse, parse_qs, unquote, quote + + +@dataclass +class OtpAuth: + type: str # "totp" / "hotp" + issuer: str + account: str + secret: str # Base32, uppercased, spaces removed + algorithm: str # "SHA1" / "SHA256" / "SHA512" + digits: int + period: int + + +def parse_otpauth(uri: str) -> OtpAuth: + """Parse an otpauth://TYPE/LABEL?params URI. Issuer comes from the `issuer` + parameter when present, else the label prefix before ':'. Account is the + label after that prefix.""" + u = urlparse(uri) + otype = u.netloc.lower() + label = unquote(u.path.lstrip("/")) + q = parse_qs(u.query) + + def g(k, d=""): + return q.get(k, [d])[0] + + issuer = unquote(g("issuer")) + if ":" in label: + label_issuer, account = label.split(":", 1) + if not issuer: + issuer = label_issuer.strip() + else: + account = label + return OtpAuth( + type=otype, + issuer=issuer.strip(), + account=account.strip(), + secret=g("secret").upper().replace(" ", ""), + algorithm=(g("algorithm", "SHA1") or "SHA1").upper(), + digits=int(g("digits", "6") or "6"), + period=int(g("period", "30") or "30"), + ) + + +def canonical_key(otp: OtpAuth) -> tuple: + """Normalized dedup key for the full otpauth data. Equal for two scans of the + same credential (including label-issuer vs. param-issuer forms); different + when any crypto-relevant field differs.""" + return ( + otp.type.lower(), + otp.issuer.strip(), + otp.account.strip(), + otp.secret.upper().replace(" ", ""), + otp.algorithm.upper(), + otp.digits, + otp.period, + ) + + +def build_otpauth(issuer: str, account: str, secret: str, otype: str = "totp", + algorithm: str = "SHA1", digits: int = 6, period: int = 30) -> str: + """Reconstruct an otpauth:// URI (used to seed URIs for legacy rows).""" + label = quote(f"{issuer}:{account}" if issuer else account) + params = [f"secret={secret}"] + if issuer: + params.append(f"issuer={quote(issuer)}") + params += [f"algorithm={algorithm}", f"digits={digits}", f"period={period}"] + return f"otpauth://{otype}/{label}?" + "&".join(params) diff --git a/qr_to_sheet.py b/qr_to_sheet.py new file mode 100644 index 0000000..a34bde1 --- /dev/null +++ b/qr_to_sheet.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Decode saved OTP QR-code screenshots into rows of the canonical CSV. + +Stage 1 of the pipeline (see docs/qr-to-csv-design.md): decode each QR, dedup on +the otpauth:// data, and append only new credentials. The otpauth:// string is +the immutable source of truth; the issuer/account columns are editable labels +seeded from it. +""" + +import argparse +import csv +import glob +import os +import sys + +from otpauth import parse_otpauth, canonical_key + +CSV_COLUMNS = ["issuer", "account", "otpauth_uri", "source_file"] + + +# --------------------------------------------------------------------------- +# CSV + dedup (pure) +# --------------------------------------------------------------------------- + +def read_csv_rows(path): + """Read the canonical CSV into a list of dict rows ([] if it doesn't exist).""" + if not os.path.exists(path): + return [] + with open(path, newline="", encoding="utf-8") as f: + return list(csv.DictReader(f)) + + +def append_csv(path, rows): + """Append rows to the CSV, writing the header first if the file is new. + Existing rows are never rewritten or reordered.""" + new_file = not os.path.exists(path) + with open(path, "a", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=CSV_COLUMNS) + if new_file: + w.writeheader() + for r in rows: + w.writerow({k: r.get(k, "") for k in CSV_COLUMNS}) + + +def existing_keys(rows): + """Canonical dedup keys for every row that carries an otpauth_uri.""" + keys = set() + for r in rows: + uri = (r.get("otpauth_uri") or "").strip() + if uri: + keys.add(canonical_key(parse_otpauth(uri))) + return keys + + +def dedup_new(candidates, existing): + """From (otpauth_uri, source_file) candidates, return (new_rows, dup_count). + Skips any whose canonical otpauth is already present or seen earlier in the + batch.""" + seen = set(existing) + new_rows = [] + dups = 0 + for uri, source in candidates: + key = canonical_key(parse_otpauth(uri)) + if key in seen: + dups += 1 + continue + seen.add(key) + o = parse_otpauth(uri) + new_rows.append({ + "issuer": o.issuer, + "account": o.account, + "otpauth_uri": uri, + "source_file": source, + }) + return new_rows, dups + + +# --------------------------------------------------------------------------- +# QR decoding (I/O) +# --------------------------------------------------------------------------- + +def decode_qr_image(path): + """Decode a QR image to its payload string, or None. Multi-pass to cope with + scaling and thresholding differences across screenshots.""" + import cv2 + detector = cv2.QRCodeDetector() + img = cv2.imread(path) + if img is None: + return None + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img + attempts = [img, gray] + for scale in (2, 3, 4, 0.5): + h, w = gray.shape[:2] + attempts.append(cv2.resize(gray, (int(w * scale), int(h * scale)), + interpolation=cv2.INTER_CUBIC)) + _, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + attempts.append(otsu) + attempts.append(cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, 31, 5)) + for a in attempts: + try: + data, _, _ = detector.detectAndDecode(a) + except cv2.error: + continue + if data: + return data + return None + + +def scan_images(image_dir): + """Return sorted image paths under image_dir.""" + paths = [] + for ext in ("*.png", "*.jpg", "*.jpeg", "*.PNG", "*.JPG"): + paths.extend(glob.glob(os.path.join(image_dir, ext))) + return sorted(set(paths)) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +DEFAULT_CSV = "otp-keys.csv" + + +def main(argv=None): + p = argparse.ArgumentParser( + description="Decode OTP QR screenshots and append new credentials to the " + "canonical CSV (deduplicated on the otpauth:// data).") + p.add_argument("--images", default=".", help="directory of QR images") + p.add_argument("--csv", default=DEFAULT_CSV, help=f"CSV path (default: {DEFAULT_CSV})") + p.add_argument("--dry-run", action="store_true", help="report without writing") + args = p.parse_args(argv) + + images = scan_images(args.images) + if not images: + print(f"No QR images found in {args.images}") + return 1 + + candidates = [] # (otpauth_uri, source_file) + undecodable = [] + unsupported = [] + for path in images: + name = os.path.basename(path) + data = decode_qr_image(path) + if not data: + undecodable.append(name) + elif data.startswith("otpauth://"): + candidates.append((data, name)) + elif data.startswith("otpauth-migration://"): + unsupported.append(name) + else: + undecodable.append(name) + + keys = existing_keys(read_csv_rows(args.csv)) + new_rows, dups = dedup_new(candidates, keys) + + print(f"Scanned {len(images)} image(s): {len(candidates)} otpauth QR(s) decoded.") + print(f" {len(new_rows)} new, {dups} duplicate(s) skipped.") + if unsupported: + print(f" {len(unsupported)} otpauth-migration:// (unsupported): " + + ", ".join(unsupported)) + if undecodable: + print(f" {len(undecodable)} could not be decoded (add manually): " + + ", ".join(undecodable)) + + if args.dry_run: + for r in new_rows: + print(f" + {r['issuer']}:{r['account']} ({r['source_file']})") + return 0 + + if new_rows: + append_csv(args.csv, new_rows) + print(f"Appended {len(new_rows)} row(s) to {args.csv}.") + else: + print("Nothing to append.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_oath.py b/test_oath.py index b08e16a..6ce157c 100644 --- a/test_oath.py +++ b/test_oath.py @@ -228,6 +228,24 @@ class TestLoadCredentials(unittest.TestCase): self.assertEqual(g.source, "g.png") self.assertEqual((creds[1].algorithm, creds[1].digits, creds[1].period), (HMAC_SHA256, 8, 15)) + def test_csv_takes_labels_from_columns_and_crypto_from_otpauth(self): + import csv + d = tempfile.mkdtemp() + path = os.path.join(d, "otp-keys.csv") + with open(path, "w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["issuer", "account", "otpauth_uri", "source_file"]) + # otpauth label is "Amal Graafstra"; the editable account differs + w.writerow(["Heroku", "amal@vivokey.com", + "otpauth://totp/Heroku:Amal%20Graafstra?secret=JBSWY3DP" + "&issuer=Heroku&algorithm=SHA256&digits=8&period=60", "h.png"]) + creds = load_credentials(path) + c = creds[0] + self.assertEqual((c.issuer, c.account), ("Heroku", "amal@vivokey.com")) # from columns + self.assertEqual(c.secret, "JBSWY3DP") # from otpauth + self.assertEqual((c.oath_type, c.algorithm, c.digits, c.period), + (TOTP_TYPE, HMAC_SHA256, 8, 60)) # from otpauth + class TestCardChannel(unittest.TestCase): def test_reassembles_61xx_via_send_remaining(self): diff --git a/test_qr.py b/test_qr.py new file mode 100644 index 0000000..6392975 --- /dev/null +++ b/test_qr.py @@ -0,0 +1,87 @@ +"""Hardware-free tests for the QR->CSV utility (otpauth parsing + dedup).""" +import os +import tempfile +import unittest + +from otpauth import parse_otpauth, canonical_key +from qr_to_sheet import existing_keys, dedup_new, append_csv, read_csv_rows + + +class TestParseOtpauth(unittest.TestCase): + def test_basic_totp(self): + o = parse_otpauth("otpauth://totp/Google:amal@amal.net?secret=abcde&issuer=Google") + self.assertEqual(o.type, "totp") + self.assertEqual(o.issuer, "Google") + self.assertEqual(o.account, "amal@amal.net") + self.assertEqual(o.secret, "ABCDE") # uppercased + self.assertEqual((o.algorithm, o.digits, o.period), ("SHA1", 6, 30)) + + def test_percent_decoding(self): + o = parse_otpauth("otpauth://totp/Google%3Aamal%40amal.net?secret=AA&issuer=Google") + self.assertEqual(o.account, "amal@amal.net") + + def test_issuer_from_label_when_no_param(self): + o = parse_otpauth("otpauth://totp/SAW:michelle@d.com?secret=AA") + self.assertEqual(o.issuer, "SAW") + self.assertEqual(o.account, "michelle@d.com") + + def test_label_without_issuer_prefix(self): + o = parse_otpauth("otpauth://totp/amalg?secret=AA&issuer=Porkbun") + self.assertEqual(o.issuer, "Porkbun") + self.assertEqual(o.account, "amalg") + + def test_explicit_params(self): + o = parse_otpauth("otpauth://totp/X:y?secret=AA&algorithm=SHA256&digits=8&period=60") + self.assertEqual((o.algorithm, o.digits, o.period), ("SHA256", 8, 60)) + + +class TestCanonicalKey(unittest.TestCase): + def test_label_issuer_equals_param_issuer(self): + a = parse_otpauth("otpauth://totp/Google:amal@amal.net?secret=JBSWY3DP&issuer=Google") + b = parse_otpauth("otpauth://totp/amal@amal.net?secret=JBSWY3DP&issuer=Google") + self.assertEqual(canonical_key(a), canonical_key(b)) + + def test_secret_case_insensitive(self): + a = parse_otpauth("otpauth://totp/X:y?secret=jbswy3dp&issuer=X") + b = parse_otpauth("otpauth://totp/X:y?secret=JBSWY3DP&issuer=X") + self.assertEqual(canonical_key(a), canonical_key(b)) + + def test_different_secret_differs(self): + a = parse_otpauth("otpauth://totp/X:y?secret=AAAA&issuer=X") + b = parse_otpauth("otpauth://totp/X:y?secret=BBBB&issuer=X") + self.assertNotEqual(canonical_key(a), canonical_key(b)) + + +A = "otpauth://totp/X:a?secret=AAAA&issuer=X" +B = "otpauth://totp/X:b?secret=BBBB&issuer=X" + + +class TestDedup(unittest.TestCase): + def test_dedups_against_existing_and_within_batch(self): + existing = [{"issuer": "X", "account": "a", "otpauth_uri": A, "source_file": "a.png"}] + keys = existing_keys(existing) + new_rows, dups = dedup_new([(A, "a2.png"), (B, "b.png"), (B, "b2.png")], keys) + self.assertEqual([r["otpauth_uri"] for r in new_rows], [B]) + self.assertEqual(new_rows[0]["issuer"], "X") + self.assertEqual(new_rows[0]["account"], "b") + self.assertEqual(dups, 2) + + +class TestCsvIO(unittest.TestCase): + def test_append_creates_then_appends_preserving_rows(self): + d = tempfile.mkdtemp() + p = os.path.join(d, "otp-keys.csv") + append_csv(p, [{"issuer": "X", "account": "a", "otpauth_uri": "u1", "source_file": "a.png"}]) + append_csv(p, [{"issuer": "Y", "account": "b", "otpauth_uri": "u2", "source_file": "b.png"}]) + rows = read_csv_rows(p) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["issuer"], "X") + self.assertEqual(rows[1]["account"], "b") + self.assertEqual(rows[1]["otpauth_uri"], "u2") + + def test_read_missing_file_is_empty(self): + self.assertEqual(read_csv_rows(os.path.join(tempfile.mkdtemp(), "nope.csv")), []) + + +if __name__ == "__main__": + unittest.main()