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.
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 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:
-
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 isYYYY-MM-DD-<relying-party>-<account>:2024-05-01-github-alice@example.com.png 2025-11-02-aws-root@example.com.pngDate first so the folder sorts chronologically, then the service (relying party), then which account it is.
-
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.) -
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
.gitignoredeliberately 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
- QR → CSV (
qr_to_sheet.py): decode each screenshot and append any new credential to the CSV. Deduplication is on theotpauth://data, so re-running never adds a credential that is already present. - Edit labels (optional): adjust the
issuerandaccountcolumns to taste. Never edit theotpauth_uricolumn — it is the immutable source of truth (secret and parameters live inside it). - 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), andopenpyxl(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; seedsissuer/accountfrom 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 OATHissuer:accountseparator andperiod/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 for the importer/protocol design and
docs/qr-to-csv-design.md for the QR-to-CSV design.
otpauth.py— parse/canonicalizeotpauth://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.