Amal Graafstra 75975478f2 Add requirements.txt and correct the dependency list
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.
2026-07-08 16:13:45 -07:00
2026-07-08 11:32:56 -07:00

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:

  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 68, 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/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.
Description
No description provided
Readme 203 KiB
Languages
Python 100%