Add two admin operations on the OATH protocol layer: --reset (RESET 0x04/0xDEAD) and --set-password, which sets an access password after a successful import only if the applet has none. Fix _parse_select to mask the SELECT ALGORITHM tag to its low nibble (the applet reports the full type|algo byte, e.g. 0x21), which previously broke validate on a locked applet. Add a clean top-level OathError handler.
12 KiB
Apex OTP Importer — Design
Date: 2026-07-08 Status: Draft for review
Goal
A single runnable Python script that reads the curated OTP export
(otp-keys-export.xlsx, sheet OTP Keys) and writes every credential to a
VivoKey Apex's OTP (OATH) applet over PC/SC, quickly and repeatably. It must
support password-protected OATH applets (authenticate before writing) and,
optionally, profile per-credential on-chip memory consumption.
Context and constraints
- Host: Windows 11, Python 3.11. Installed and used:
pyscard,cryptography,openpyxl. No Yubico libraries (ykman/yubikit) — the OATH protocol is implemented directly, so there is no added dependency and no reliance on Yubico device enumeration (Apex is not a Yubico device). - Transport: PC/SC. Multiple contactless readers are typically present (ACR1252 CL, OMNIKEY 5022, Identiv uTrust 3700 F CL, VivoKey Smart Reader).
- Input source of truth: the curated columns of the spreadsheet
(Issuer, Account, Secret, Type, Algorithm, Digits, Period) — NOT the
otpauth://URI column. The curated Account values were cleaned during export (e.g. Heroku →amal@vivokey.com, not the QR label "Amal Graafstra").
Verified protocol facts
Verified directly against the applet sources in the local repos
apex-totp (ykneo-oath, com.vivokey.otp.YkneoOath / OathObj) and
javacard-memory (de.chrz.jcmemory.JCMemoryApplet), plus
apex-totp/doc/Protocol.txt.
OATH applet
- Applet AID (from
apex-totp/build.xml):A0 00 00 05 27 21 01 01 41 50 45 58 01(41 50 45 58= "APEX"). SELECT strategy: try this full AID first, then fall back toA0 00 00 05 27 21 01 01(8 B, per repo docs/tests) andA0 00 00 05 27 21 01(7 B, canonical Yubico). Use the first that returns a valid SELECT response (version tag0x79). Prefix selection works because Yubico Authenticator already talks to Apex with the short AID. - INS: PUT
0x01, DELETE0x02, SET CODE0x03, RESET0x04(P1P2 must be0xDEAD), RENAME0x05, LIST0xA1, CALCULATE0xA2, VALIDATE0xA3, CALCULATE ALL0xA4, SEND REMAINING0xA5. - Tags: NAME
0x71, NAME_LIST0x72, KEY0x73, CHALLENGE0x74, RESPONSE0x75, T_RESPONSE0x76, NO_RESPONSE0x77, PROPERTY0x78, VERSION0x79, IMF0x7A, ALGORITHM0x7B. - Type|algo byte: type in high nibble (HOTP
0x10, TOTP0x20), algo in low nibble (SHA10x01, SHA2560x02). SHA512 is rejected by this applet. All our credentials are TOTP/SHA1 →0x21. - SELECT response TLV: VERSION(
0x79) + NAME(0x71, 8-byte device id/salt) and, if a password is set, CHALLENGE(0x74, 8 bytes) + ALGORITHM(0x7B, 1 byte). Presence of the CHALLENGE tag ⇒ applet is locked. - PUT data:
NAME(0x71,len,name)+KEY(0x73, len=2+keylen, [typeAlgo, digits, key…]). PROPERTY/IMF omitted (no touch, TOTP only). Name ≤ 64 B, key ≤ 64 B. The applet XORs exactlykeylenbytes into the 64-byte HMAC ipad/opad, so a short secret yields the same HMAC as a zero-padded one; we still right-pad short secrets to 14 B to match the Yubico host (HMAC-identical). - LIST response (repeats):
NAME_LIST(0x72, len=namelen+1, [type, name…]). - Long responses (LIST/CALCULATE ALL) chunk with SW
61xx; fetch the rest with SEND REMAINING (0xA5) until9000. - Response codes: success
9000; applet full6A84; auth required6982; wrong syntax / wrong data (incl. wrong password on VALIDATE)6A80; no such object6984. - Auth state is stored in a CLEAR_ON_DESELECT buffer: any deselect (e.g. a memory-applet SELECT) drops authentication and requires a fresh VALIDATE.
Password authentication (VALIDATE)
Per doc/Protocol.txt and OathObj:
key = PBKDF2-HMAC-SHA1(password_utf8, salt = 8-byte device id from SELECT, iterations = 1000, dklen = 16). PBKDF2 always uses HMAC-SHA1 here, independent of the credential/challenge algorithm.- The challenge-response HMAC uses the algorithm named by the SELECT
ALGORITHM(0x7B) tag (low nibble: SHA10x01/ SHA2560x02), defaulting to SHA1 — which is what the Yubico host sets in practice (20-byte response).deviceResponse = HMAC(key, deviceChallenge)wheredeviceChallengeis the SELECT0x74value. clientChallenge = os.urandom(8).- Send VALIDATE (
0xA3):RESPONSE(0x75, deviceResponse)+CHALLENGE(0x74, clientChallenge[8]). The response length must equal the applet's digest length or it is rejected — hence honoring the ALGORITHM tag. - Applet returns
RESPONSE(0x75, HMAC(key, clientChallenge)); the host verifies it to confirm mutual auth. Wrong password →6A80.
The derived key is stable across re-SELECTs (device id only changes on RESET), so it is derived once and reused when re-validation is needed during profiling.
Memory applet (optional)
- Applet AID:
A0 00 00 08 46 6D 65 6D 6F 72 79 01(46 6D…79= "Fmemory"). - SELECT returns 12 bytes: availPersistent(4 B BE), totalBaseline(4 B BE), transientReset(2 B), transientDeselect(2 B). Apex Flex baseline = 84336 B.
GET_BATCH=00 01 00 00 00→ 4-byte batch id.- Each SELECT runs
requestObjectDeletion()(GC) then reports free memory. Because selecting it deselects OATH, profiling must re-VALIDATE afterward.
Architecture
One runnable file apex_otp_import.py, organized into focused units:
Tlvhelpers — encode/parse BER-TLV with 1/2/3-byte lengths (0x81/0x82).CardChannel— thin wrapper over a pyscard connection:transmit(apdu)plussend(cla, ins, p1, p2, data, le)that transparently follows61xxwith SEND REMAINING and returns(data, sw).OathSession— SELECT (with AID fallback), parse device id/challenge/locked,derive_key(password),validate(key),list(),put(cred), and status-word → exception mapping. Pure protocol; unit-testable with a fake channel.MemorySession— SELECT the memory applet, parse availPersistent + baseline,get_batch(). Absent applet handled gracefully.Credential— normalized record: issuer, account, secret(bytes), oath_type, algorithm, digits, period, andcred_id(issuer:account, withperiod/prefix only if period ≠ 30).load_credentials(xlsx_path)— read theOTP Keyssheet from curated columns.validate_input(creds)— pre-flight checks; returns a list of problems.pick_reader(...)— enumerate readers, detect cards, SELECT OATH, choose.main()— CLI + orchestration in the order below.
Companion test_oath.py — hardware-free unit tests.
Execution flow
- Load + validate input first (before any reader access).
load_credentials→validate_input. If any problems, print them all and exit non-zero. No interactive fixing — the user edits the spreadsheet. - Reader/card detection. For each reader with a card inserted, try the OATH SELECT. 0 responders → error "Place your Apex on a reader and re-run", exit. 1 → use it. >1 → list reader names and prompt for the target.
- Session. SELECT OATH. If locked,
getpassthe password and VALIDATE (map6A80→ "incorrect password", exit). LIST existing credential names. - Optional capacity pre-check. If the memory applet is present, read
availPersistent and warn (no halt) if
availPersistent < ~250 B × N. - Import. For each credential: if its
cred_idis already on the applet, ask per conflict (overwrite / skip; with all/none shortcuts). Otherwise PUT. Map6A84→ "applet full" and stop further PUTs (report remainder as skipped).--profile-memorywraps each PUT in before/after memory reads (re-VALIDATE between, since the memory SELECT deselects OATH) and appends a log row. - Report. Per-credential OK / skipped / failed summary, plus mem-log path when profiling.
Pre-flight validation rules (halt on any)
- Duplicate
cred_idwithin the input (e.g. the twoSAW:michelle@dangerousthings.comentries with different secrets). - Secret missing or not valid Base32 after uppercasing and removing spaces.
- Decoded key length > 64 bytes.
cred_id(UTF-8) length > 64 bytes.- Algorithm SHA512 (unsupported by the applet), or a non-TOTP/HOTP type.
Each problem is reported with the offending issuer/account and source row.
Conflict handling (on-device)
Interactive per name already present on the applet: [o]verwrite /
[s]kip / [a] overwrite-all / [x] skip-all. Non-interactive overrides:
--overwrite-all, --skip-existing.
Memory profiling (--profile-memory, opt-in)
Per credential: memory SELECT → availBefore; OATH SELECT + re-VALIDATE (if
locked) → PUT; memory SELECT → availAfter; consumed = availBefore - availAfter.
Append to apex-otp-memlog.csv with columns:
timestamp_iso, card_uid, mem_batch, key_name, avail_before, avail_after, consumed_bytes.
timestamp_iso is the host clock; card_uid from FF CA 00 00 00; the key
secret is never logged. Block-granular allocation means consumed_bytes is a
close estimate, not byte-exact.
CLI
apex_otp_import.py [--input PATH] [--reader SUBSTR] [--dry-run] [--list]
[--overwrite-all | --skip-existing] [--profile-memory]
[--memlog PATH] [--password PASS]
--dry-run— load, validate, build every APDU, hex-print them; no card I/O.--list— read-only: connect, authenticate if needed, print on-applet creds.--password— supply the applet password non-interactively (elsegetpass).
Error handling
Central status-word → message map: 6A84 applet full, 6982 auth required,
6A80 wrong data / incorrect password, 6984 no such object, 61xx handled by
SEND REMAINING, 6D00/6E00 wrong INS/CLA (wrong applet or AID). Card
exceptions (no card, reader gone) surface as clear, actionable messages.
Testing (test_oath.py, no hardware)
- TLV encode/parse round-trips incl. multi-byte lengths.
cred_idformatting (period prefix rule).- Byte-exact PUT payload for a known credential (type/algo, digits, padded key).
- Byte-exact VALIDATE payload and mutual-response verification against a known key/challenge.
- Base32 decode + 14-byte right-pad; HMAC-equivalence of padded vs unpadded.
- PBKDF2/HMAC vectors; RFC 6238 TOTP HMAC sanity check.
validate_inputcatches each halt condition (dup, bad Base32, oversize name, oversize key, SHA512).61xxreassembly via a fake channel that chunks a long LIST response.
Password / reset admin (added after initial spec)
The importer also exposes two admin operations on the same protocol layer:
--set-password PASS: after a fully successful import, set an access password on the applet only if it has none (SET CODE with a SHA1 access key derived as above). Runs post-import for efficiency (single session). Does not change or remove an existing password.--reset: standalone; sends RESET (P1P2 = 0xDEAD) to wipe all credentials and any password, then exits.
Note: the applet reports the full type|algo byte (e.g. 0x21) in the SELECT
ALGORITHM tag; the low nibble is masked off to select the HMAC algorithm.
Out of scope (YAGNI)
Changing or removing an existing password; HOTP import; touch-required; RENAME/DELETE of individual credentials; CSV/text input; on-device code calculation (beyond ad-hoc post-import verification, not part of the tool).
Files
Code and docs live in the otp-import repo
(C:\Users\amal\Documents\Repos\otp-import, remote
git.dngr.us/VivoKey/otp-import): apex_otp_import.py, test_oath.py,
docs/design.md, README.md, .gitignore.
The secret data stays out of the repo: the export spreadsheet
(otp-keys-export.xlsx), the profiling CSV (apex-otp-memlog.csv, created only
when profiling), and QR screenshots remain in the private Nextcloud folder and
are excluded by .gitignore. The importer is pointed at the data with
--input.
Risks / notes
- The instance AID on a Fidesmo-provisioned Apex may differ from
build.xml; mitigated by the AID fallback list and validating the SELECT response shape. - Contactless UID from
FF CAmay be random per tap on some configurations; it is logged as-is for correlation, not treated as a stable serial. - Keep the card still: the whole import runs in one OATH session; a mid-import
deselect drops auth and (without profiling's re-VALIDATE) would fail PUTs with
6982.