Files
otp-import/docs/qr-to-csv-design.md
Amal Graafstra 07f65835d0 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.
2026-07-08 14:49:59 -07:00

5.8 KiB

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