Add a requirements file (pyscard, opencv-python, openpyxl) so users can pip install -r requirements.txt. Remove the incorrect cryptography dependency from the README - all crypto uses the Python standard library.
355 lines
14 KiB
Markdown
355 lines
14 KiB
Markdown
# otp-import
|
||
|
||
Bulk-load your saved authenticator **QR-code screenshots** onto a **VivoKey
|
||
Apex** OTP applet (the ykneo-oath applet) — or onto several implants at once — by
|
||
way of a plain, human-editable **CSV**.
|
||
|
||
If you screenshot your two-factor QR codes as you enroll them (see
|
||
[Backing up your OTP keys](#backing-up-your-otp-keys) below), these two tools
|
||
turn that pile of screenshots into working TOTP credentials on a new or
|
||
replacement authenticator, and can load the identical set onto multiple devices.
|
||
|
||
- `qr_to_sheet.py` — decode QR screenshots into the CSV (no hardware needed).
|
||
- `apex_otp_import.py` — write the CSV's credentials onto the applet over PC/SC.
|
||
|
||
**Duplicates are handled at both stages, so you can re-run freely:**
|
||
|
||
- **QR → CSV** deduplicates on the `otpauth://` data: re-scanning a folder only
|
||
appends codes that aren't already in the CSV; ones already present are silently
|
||
skipped. Two screenshots of the *same account with different secrets* (e.g. a
|
||
re-enrollment) are both kept — they are genuinely different credentials.
|
||
- **CSV → applet** keys credentials by name (`issuer:account`): if a name is
|
||
already on the applet you are asked to overwrite or skip it (or pass
|
||
`--overwrite-all` / `--skip-existing`). Loading the same CSV onto a second
|
||
implant just repeats the write. And before writing, the importer halts if two
|
||
rows would collide on the same name — so a re-enrollment with a new secret
|
||
can't silently clobber the old one; you relabel one of them first.
|
||
|
||
## Backing up your OTP keys
|
||
|
||
This utility is only useful if you saved your OTP keys in the first place. When
|
||
you turn on TOTP two-factor authentication somewhere, the site shows a QR code
|
||
(and usually a "can't scan it? enter this code instead" secret). That QR encodes
|
||
an `otpauth://` URI containing the shared secret — capture it **then**, because
|
||
you generally cannot retrieve it later without disabling and re-enrolling 2FA.
|
||
|
||
A good habit, every time you enable 2FA:
|
||
|
||
1. **Screenshot the QR code** and save the image to secure storage. The tools
|
||
read the `otpauth://` data inside the image, not the filename, so the naming
|
||
scheme is purely for your own organization — but a consistent one keeps the
|
||
folder browsable and sortable. A practical convention is
|
||
`YYYY-MM-DD-<relying-party>-<account>`:
|
||
|
||
```
|
||
2024-05-01-github-alice@example.com.png
|
||
2025-11-02-aws-root@example.com.png
|
||
```
|
||
|
||
Date first so the folder sorts chronologically, then the service (relying
|
||
party), then which account it is.
|
||
|
||
2. **Save the one-time backup/recovery codes too.** Most sites offer a set of
|
||
single-use recovery codes next to the QR — copy them into a text file beside
|
||
the screenshot (e.g. `2024-05-01-github-alice@example.com-backup-codes.txt`).
|
||
If you ever lose every authenticator, those codes are how you get back in.
|
||
(This tool doesn't process them; they're your independent recovery path.)
|
||
|
||
3. **Keep it all in secure storage.** These files contain live secrets — treat
|
||
them like passwords. Good homes: a password-manager vault, an encrypted
|
||
volume, or an end-to-end-encrypted cloud folder. Never commit them to a
|
||
repository (this repo's `.gitignore` deliberately excludes `*.png`, `*.csv`,
|
||
and backup-code text files).
|
||
|
||
With that habit in place, provisioning a new, replacement, or spare Apex is just
|
||
pointing these tools at the folder.
|
||
|
||
---
|
||
|
||
## The pipeline
|
||
|
||
```
|
||
QR screenshots (*.png / *.jpg, each holding an otpauth:// URI)
|
||
|
|
||
| (1) qr_to_sheet.py
|
||
| decode each QR; append only NEW otpauth:// data to the CSV
|
||
v
|
||
otp-keys.csv columns: issuer | account | otpauth_uri | source_file
|
||
|
|
||
| (2) optional: edit the issuer / account labels (never otpauth_uri)
|
||
|
|
||
| (3) apex_otp_import.py
|
||
| validate the CSV, authenticate if needed, PUT each credential
|
||
v
|
||
Apex OTP applet (ykneo-oath) one TOTP credential per row
|
||
```
|
||
|
||
1. **QR → CSV** (`qr_to_sheet.py`): decode each screenshot and append any
|
||
**new** credential to the CSV. Deduplication is on the `otpauth://` data, so
|
||
re-running never adds a credential that is already present.
|
||
2. **Edit labels (optional)**: adjust the `issuer` and `account` columns to
|
||
taste. **Never edit the `otpauth_uri` column** — it is the immutable source
|
||
of truth (secret and parameters live inside it).
|
||
3. **CSV → applet** (`apex_otp_import.py`): validate the CSV and write each
|
||
credential onto the applet, authenticating first if the applet has a
|
||
password.
|
||
|
||
The two stages guard different things: stage 1 stops you re-importing the same
|
||
QR; stage 3 stops you writing something you broke while editing labels.
|
||
|
||
---
|
||
|
||
## The CSV
|
||
|
||
Four columns:
|
||
|
||
| Column | Edit it? | Meaning |
|
||
| ------ | -------- | ------- |
|
||
| `issuer` | yes | Issuer shown on the applet (the "Issuer:" part of the name). |
|
||
| `account` | yes | Account shown on the applet (the part after "Issuer:"). |
|
||
| `otpauth_uri` | **no** | The raw `otpauth://` string from the QR. Dedup key and the source of the secret/algorithm/digits/period. |
|
||
| `source_file` | reference | The image filename the row was decoded from. |
|
||
|
||
The credential **name** written to the applet is `issuer:account` from the
|
||
editable columns. The **secret** and other crypto parameters are always taken
|
||
from `otpauth_uri`, so editing a label can never corrupt the code that gets
|
||
generated. The secret is still visible — it lives inside `otpauth_uri`.
|
||
|
||
The CSV is disposable: it can be regenerated from the QR screenshots at any time
|
||
with `qr_to_sheet.py`.
|
||
|
||
---
|
||
|
||
## Requirements
|
||
|
||
- Python 3.9+
|
||
- `pyscard` (smartcard access), `opencv-python` (QR decoding), and `openpyxl`
|
||
(reading legacy `.xlsx`). All cryptography uses the Python standard library.
|
||
- For writing to a card: a PC/SC reader with the Apex (over NFC) or a JavaCard
|
||
carrying the applet presented to it.
|
||
|
||
```
|
||
pip install -r requirements.txt
|
||
```
|
||
|
||
---
|
||
|
||
## Tool 1 — `qr_to_sheet.py` (QR → CSV, no card)
|
||
|
||
Decode a folder of QR screenshots and append new credentials to the CSV. This
|
||
tool never touches a reader, so run it any time — the Apex does not need to be
|
||
present.
|
||
|
||
```
|
||
python qr_to_sheet.py --images <folder> --csv <path/to/otp-keys.csv>
|
||
```
|
||
|
||
Options:
|
||
|
||
| Option | Default | Meaning |
|
||
| ------ | ------- | ------- |
|
||
| `--images DIR` | `.` | Folder of QR images (`*.png`, `*.jpg`, `*.jpeg`). |
|
||
| `--csv PATH` | `otp-keys.csv` | The CSV to create or append to. |
|
||
| `--dry-run` | off | Report what would be added; write nothing. |
|
||
|
||
What it does per run:
|
||
|
||
- Decodes each image (a multi-pass decode copes with scaling/thresholding).
|
||
- Parses the `otpauth://totp/...` URI; seeds `issuer`/`account` from it.
|
||
- Skips any credential whose `otpauth://` data is already in the CSV
|
||
(deduplicated, existing rows never touched), and any duplicate within the same
|
||
run.
|
||
- Appends the genuinely-new rows and prints a summary of added / skipped /
|
||
undecodable.
|
||
|
||
Example:
|
||
|
||
```
|
||
$ python qr_to_sheet.py --images ./screenshots --csv otp-keys.csv
|
||
Scanned 34 image(s): 32 otpauth QR(s) decoded.
|
||
3 new, 29 duplicate(s) skipped.
|
||
could not be decoded (add manually): coinbase-...png, plaid-...png
|
||
Appended 3 row(s) to otp-keys.csv.
|
||
```
|
||
|
||
**Undecodable images** — stylized/colored QR codes, or screenshots that only
|
||
show the secret as text — are listed by filename. Add those by hand: create a
|
||
row with `issuer`, `account`, `source_file`, and an `otpauth_uri` you build from
|
||
the printed secret, e.g.
|
||
`otpauth://totp/Coinbase:you@example.com?secret=THESECRET&issuer=Coinbase`.
|
||
|
||
`otpauth-migration://` payloads (Google Authenticator's batch-export QR) are
|
||
reported as unsupported, not decoded.
|
||
|
||
---
|
||
|
||
## Tool 2 — `apex_otp_import.py` (CSV → applet)
|
||
|
||
Validate the CSV and write its credentials onto the applet. Needs the Apex on a
|
||
reader (except `--dry-run`).
|
||
|
||
```
|
||
python apex_otp_import.py --input otp-keys.csv [options]
|
||
```
|
||
|
||
Options:
|
||
|
||
| Option | Meaning |
|
||
| ------ | ------- |
|
||
| `--input PATH` | CSV (or legacy `.xlsx`) to import. Default `otp-keys.csv`. |
|
||
| `--reader SUBSTR` | Pick a reader by name substring (e.g. `VivoKey`, `3700 F CL`). Omit to auto-detect the single card holding an OATH applet; you are prompted if more than one is present. |
|
||
| `--dry-run` | Validate and print the APDUs that would be sent; no card I/O. |
|
||
| `--list` | Read-only: list the credentials already on the applet. |
|
||
| `--overwrite-all` | Overwrite any credential that already exists (non-interactive). |
|
||
| `--skip-existing` | Skip any credential that already exists (non-interactive). |
|
||
| `--password PASS` | Applet password (otherwise prompted if the applet is locked). |
|
||
| `--profile-memory` | Record per-credential EEPROM consumption to a CSV log (needs the `javacard-memory` applet). |
|
||
| `--set-password PASS` | After a successful import, set an access password — only if the applet has none. |
|
||
| `--reset` | Standalone: wipe all credentials and any password, then exit. |
|
||
| `--import-qr` | Run the QR → CSV step first (see below), then import. |
|
||
| `--qr-images DIR` | QR folder for `--import-qr` (default: the folder of `--input`). |
|
||
| `--memlog PATH` | Path for the `--profile-memory` log (default `apex-otp-memlog.csv`). |
|
||
|
||
By default, when a credential name already exists on the applet you are asked
|
||
whether to overwrite or skip it (with all/none shortcuts). Use `--overwrite-all`
|
||
or `--skip-existing` for unattended runs.
|
||
|
||
Recommended sequence for a first import:
|
||
|
||
```
|
||
python apex_otp_import.py --input otp-keys.csv --dry-run # (1) preview, no card
|
||
python apex_otp_import.py --input otp-keys.csv --list # (2) see what's on the applet
|
||
python apex_otp_import.py --input otp-keys.csv # (3) import for real
|
||
python apex_otp_import.py --input otp-keys.csv --list # (4) confirm
|
||
```
|
||
|
||
### Passwords
|
||
|
||
Authenticating to a password-protected applet uses the standard YKOATH scheme:
|
||
PBKDF2-HMAC-SHA1 of the password with the applet's device id as salt, then a
|
||
mutual challenge-response. `--set-password` sets a password only on an applet
|
||
that has none; it does not change or remove an existing one.
|
||
|
||
Note: unlike a YubiKey, this applet requires authentication before `--reset`
|
||
when a password is set. If you set a password and forget it, the OATH applet
|
||
cannot be reset over the OATH protocol — it must be deleted/reinstalled with
|
||
GlobalPlatform (card-manager keys).
|
||
|
||
### Memory profiling
|
||
|
||
`--profile-memory` measures how much persistent memory each credential consumes
|
||
by reading the `javacard-memory` applet before and after each `PUT`. Each row of
|
||
the log records the timestamp, card UID, memory-applet batch id, credential
|
||
name (never the secret), and bytes consumed. It is slower (a memory read per
|
||
credential) and off by default.
|
||
|
||
---
|
||
|
||
## The three modes
|
||
|
||
| Command | Does | Card needed |
|
||
| ------- | ---- | ----------- |
|
||
| `qr_to_sheet.py --images D --csv C` | QR → CSV | no |
|
||
| `apex_otp_import.py --input C` | CSV → applet | yes |
|
||
| `apex_otp_import.py --input C --import-qr` | QR → CSV → applet (one shot) | yes |
|
||
|
||
`--import-qr` is the whole pipeline in one command: it decodes new QRs into the
|
||
CSV (deduped on `otpauth://`, existing entries silently skipped), then validates
|
||
and imports. The QR folder defaults to the CSV's folder; override with
|
||
`--qr-images`. If you only want to refresh the CSV, use `qr_to_sheet.py` — the
|
||
Apex does not need to be on a reader.
|
||
|
||
```
|
||
# whole pipeline, single command
|
||
python apex_otp_import.py --input otp-keys.csv --import-qr --reader VivoKey
|
||
|
||
# same, with an explicit QR folder and unattended overwrite
|
||
python apex_otp_import.py --input otp-keys.csv --import-qr --qr-images ./screenshots --overwrite-all
|
||
```
|
||
|
||
---
|
||
|
||
## Common workflows
|
||
|
||
**Add newly-enrolled services.** Save the new QR screenshots into your folder,
|
||
then:
|
||
|
||
```
|
||
python qr_to_sheet.py --images ./screenshots --csv otp-keys.csv # appends only the new ones
|
||
# (edit issuer/account labels if you like)
|
||
python apex_otp_import.py --input otp-keys.csv --reader VivoKey --overwrite-all
|
||
```
|
||
|
||
**Re-provision a fresh/wiped Apex from scratch.**
|
||
|
||
```
|
||
python apex_otp_import.py --input otp-keys.csv --reader VivoKey
|
||
```
|
||
|
||
**Password-protect after loading.**
|
||
|
||
```
|
||
python apex_otp_import.py --input otp-keys.csv --reader VivoKey --set-password 'mypass'
|
||
```
|
||
|
||
**Wipe an applet (know the password if it has one).**
|
||
|
||
```
|
||
python apex_otp_import.py --reset --reader VivoKey [--password 'mypass']
|
||
```
|
||
|
||
---
|
||
|
||
## Validation (stage 3)
|
||
|
||
The CSV is validated before any reader is touched; the run halts with a per-row
|
||
report if it finds any of:
|
||
|
||
- Duplicate credential name (`issuer:account`).
|
||
- A secret that is not valid Base32.
|
||
- A decoded key longer than 64 bytes, or a composed name longer than 64 bytes.
|
||
- An unsupported algorithm (the applet accepts SHA1/SHA256, not SHA512).
|
||
- Control characters, or a literal `:` or `/`, inside an issuer or account
|
||
(they collide with the OATH `issuer:account` separator and `period/` prefix).
|
||
- A digit count outside 6–8, or a non-positive period.
|
||
|
||
Fix the offending cells and re-run. The applet itself only enforces the length
|
||
limits; the character and digit/period rules are host-side guards for
|
||
OATH-convention and authenticator-app compatibility.
|
||
|
||
---
|
||
|
||
## Security
|
||
|
||
This tooling handles live TOTP secrets. **Keep your secrets out of this
|
||
repository.** `otp-keys.csv`, any `.xlsx`, the memory log, and QR screenshots
|
||
are excluded by `.gitignore`; keep the real data in a private location (e.g. a
|
||
synced personal folder) and point the tools at it with `--csv` / `--input`.
|
||
|
||
---
|
||
|
||
## Tests
|
||
|
||
Hardware-free unit tests cover the OATH protocol encoding, password derivation,
|
||
input validation, response reassembly, otpauth parsing, dedup, and CSV I/O:
|
||
|
||
```
|
||
python test_oath.py
|
||
python test_qr.py
|
||
```
|
||
|
||
---
|
||
|
||
## How it works
|
||
|
||
The OATH protocol layer is implemented directly over PC/SC (`pyscard`) with no
|
||
Yubico dependency, and is verified against the applet sources
|
||
(`com.vivokey.otp.YkneoOath` / `OathObj`) and `doc/Protocol.txt`. See
|
||
[docs/design.md](docs/design.md) for the importer/protocol design and
|
||
[docs/qr-to-csv-design.md](docs/qr-to-csv-design.md) for the QR-to-CSV design.
|
||
|
||
- `otpauth.py` — parse/canonicalize `otpauth://` URIs (shared by both tools).
|
||
- `qr_to_sheet.py` — QR decode, dedup, CSV append.
|
||
- `apex_otp_import.py` — CSV/xlsx load, OATH SELECT/VALIDATE/LIST/PUT, password
|
||
auth, reset, memory profiling, and the CLI.
|