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

120 lines
5.8 KiB
Markdown

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