Skip to content

Import authenticator codes into any key that can store them (re #170) - #173

Merged
framefilter merged 35 commits into
mainfrom
feat/bulk-import
Oct 10, 2026
Merged

framefilter merged 35 commits into
mainfrom
feat/bulk-import

Conversation

@framefilter

Copy link
Copy Markdown
Owner

Until now only the Molto2 could take a whole authenticator export at once. This adds the same import for every key keyroost manages that stores codes, from the CLI and the GUI.

What a person gets

  • keyroostctl oath import --in FILE (YubiKey, Solo 2, Nitrokey 3) and keyroostctl otp import --in FILE (Token2 OTP storage). molto import now works the same way.
  • Files: Aegis (plain or encrypted, --file-password), 2FAS, otpauth:// lists, QR screenshots including Google Authenticator exports.
  • A preview before anything is written: each entry shows add, replace, exists (already on the key) or skip, with a plain reason ("Solo 2 doesn't take SHA-512 codes"). One question, then one entry at a time; on an error it stops and says what was added and what wasn't.
  • Codes already on the key with the same name are left alone unless --replace (GUI: "Replace codes already on the key that have the same name"). Re-running an import is safe.
  • --dry-run shows the preview and writes nothing.
  • GUI: dropping any file opens one "Import authenticator codes" dialog (replaces the Molto2-only bulk dialog). The file is identified first: certificates and keys get a pointer to the right place; other files get "keyroost can't import this file". The dialog lists every connected key that can store codes, with how many entries fit, and preselects the key on screen.

Per-key limits (from the vendors' published specs and source; checked before writing)

  • YubiKey: names up to 64 bytes; 32 codes, or 64 from firmware 5.7. Firmware 4.0–4.3.4 skips a name that starts another name already on the key, as ykman does.
  • Solo 2: names up to 127 bytes (a longer name stops the key responding until it is unplugged, so keyroost refuses it, also in oath add); no SHA-512.
  • Nitrokey 3: 30-second codes only, no SHA-512, 50 codes.
  • Token2 OTP storage: ASCII names, no SHA-512, 50 codes.
  • Molto2: its existing rules; entries it can't store are now skipped with a reason instead of refusing the whole file.

Also in this PR

  • OATH codes for entries named 60/… (or any period/ prefix) are now computed with that period in oath code and the GUI; they were computed at 30 s. oath code --period 0 is refused instead of crashing.
  • A password-protected Solo 2 keeps its unlock for one command; the import unlocks it again when it asks.
  • -d selects a Molto2 by the serial list or molto info shows.
  • molto import --password is now --file-password, the same word as the other imports.

Testing

  • Unit and integration tests for the readers, the per-key rules, the write loop, the GUI dialog state, and that the CLI and GUI plan the same rows for the same file. All gates green locally.
  • Hardware: YubiKey 5.7 and Solo 2 (fw 2.3.196) — import, dry run, re-run, --replace, password-protected keys, generated codes checked against the expected values (30, 45 and 60 s); Molto2 — CLI import and the GUI dialog. Token2 OTP storage and Nitrokey 3 import are built from the published specs; reports from people with those keys are welcome.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU

framefilter and others added 30 commits October 9, 2026 20:00
The parsers feeding the Molto2 slot programmer are about to gain wider
siblings for keys that store HOTP, other TOTP periods and SHA-512. Pin
what the Molto2 path accepts, skips and reports today before anything
moves: the exact Debug of every accepted entry and the exact text of
every error, across Aegis, 2FAS, otpauth lists, parse_any, Google
Authenticator payloads and single otpauth:// URIs. Any refactor that
changes a Molto2 import result now fails here first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…; Molto2 path unchanged (re #170)

OATH keys and Token2 OTP storage can hold entries a Molto2 slot can't:
HOTP with its counter, TOTP periods other than 30/60 s, SHA-512, other
digit counts and longer seeds. The existing parsers drop or reject those
before any key is chosen, and stop a whole file at the first entry the
Molto2 can't take.

Add a format-neutral ImportEntry/ImportFile model and wide parsers for
otpauth:// URIs, Aegis, 2FAS, otpauth lists (now also accepting Google
Authenticator migration lines), migration payloads and QR screenshots.
Every entry in a file becomes an item, in file order, either usable or
carrying a plain reason (Steam/mOTP/Yandex, MD5, a bad secret, ...);
only bad JSON, an encrypted export, or an empty file fail the whole file.
Per-key limits are applied later, against the chosen destination.

The narrow Molto2 parsers keep their own mapping and error order; the
JSON structs are shared, and the new counter fields read leniently so a
file the Molto2 path accepted can't start failing. The golden tests from
the previous commit pass unchanged. Seeds stay in wipe-on-drop buffers
and Debug shows only their length. The wide parsers join the fuzz
targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…rough one entry point (re #170)

The import flow starts from a file the person drops or picks, and that
file may be a certificate, a key or something else entirely. Guessing a
destination from such a file would be wrong, and the CLI and GUI each
sniffing formats on their own would drift apart.

classify() sorts a file by content (and, for binary DER and OpenPGP, by
extension) into an authenticator export, recognized key material, or
unknown, without decrypting or decoding anything and without its JSON
probes materializing a seed. Key material comes with a plain pointer to
where keyroost imports it (PIV certificates, OpenPGP RSA keys) or that
it doesn't, each ending "Nothing was changed."

open_file() in keyroost-qr is the single reader both front ends will
use: it refuses files over 16 MiB, asks for the Aegis password when a
vault needs one, decrypts into a wipe-on-drop buffer, reads QR
screenshots, and returns the wide entry model. keyroost-qr now enables
keyroost-import's existing `encrypted` feature for the vault path; no
new crates. Both functions join the fuzz targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
#170)

The wide readers turn every line or account into an item. A 16 MiB file
of junk lines after one otpauth line, or a Google Authenticator payload
of empty accounts, could therefore become millions of items and
gigabytes of memory. Every wide reader (lists, migration payloads, QR
images, Aegis and 2FAS JSON) now refuses a file past MAX_ENTRIES (1000,
far above any real export) as a whole, before building more items. The
JSON paths count entries without materializing them first.

Review follow-ups:
- An encrypted Aegis vault is recognized only with Aegis's full header
  shape, so a config with a string "db" isn't taken for a vault.
- File-reading errors say what is wrong and where (line and column, or
  a missing field's schema name) and never quote values from the file.
  Unusual type or hash names aren't echoed.
- A wrong vault password is a typed error. Its text is unchanged, so
  CLI output stays the same.
- open_path/read_bounded enforce the 16 MiB cap while reading, so an
  oversized or endless source never lands in memory.
- A checked-in encrypted vault fixture, reproducible from the
  keyroost-import test that documents it, covers decryption end to end
  through open_file.
- The Molto2 path again reads exactly the fields it always did, so a
  repeated "counter" key no longer fails a file it used to accept.
- The golden suite now fingerprints decoded seed bytes (hashed, never
  printed) and adds query-string edge cases. It refuses to build
  without its feature and passes unchanged against the pre-series
  code.
- Both otpauth parsers share one URI splitter and query tokenizer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…#170)

The Molto2 path never reads an entry's counter, so a TOTP entry with a
repeated or non-numeric counter imports there. The wide readers refused the
same entry (a repeated JSON key failed the whole file; a bad counter= on a
TOTP URI marked it unusable), so the import plan's Molto2 rules could not
accept exactly what the Molto2 path accepts.

The Aegis and 2FAS wide readers now read the Molto2 path's own fields first
and each entry's counter in a separate pass that skips every other key
unread. A repeated counter only makes a HOTP entry unusable, and a bad
counter= on a URI only matters for HOTP.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…t gets skipped (re #170)

Before anything is written, each entry of a file gets one verdict for the
chosen key: add, replace, already there, or skipped with a plain reason that
names the device ("Solo 2 doesn't take SHA-512 codes"). Nothing is altered
to fit.

One rule table holds every per-key limit, each citing the research:
- YubiKey: 64-byte whole name, SHA-512 from 4.3.1, 32 codes (64 from 5.7);
- Solo 2: 127-byte name (a 128-byte one makes the key stop responding),
  64-byte secret, no SHA-512;
- Nitrokey 3: 128-byte name and secret, 50 codes, 30-second TOTP only
  (the period isn't stored), no SHA-512;
- other OATH keys: only 6-8 digits and the one-command size up front;
- Token2 OTP: ASCII app/account up to 64 bytes, 4-10 digits, periods
  1-65535, 50 codes, and HOTP only with a starting counter of 0;
- Molto2: its existing rules and slot model.
Limits the research couldn't confirm aren't enforced; the key's own refusal
is reported for that entry instead.

Names already on the key are "already there" unless replacing was asked
for; replacing doesn't use room, and entries past the known capacity are
skipped as "no room". A test checks the Molto2 rules accept exactly what
the Molto2 import path writes, entry by entry, over the golden fixtures.
The fuzz target now plans every parsed file for every key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…he first error (re #170)

An import is many independent writes, so an error halfway leaves the
earlier codes on the key. The write loop makes that honest: it writes the
plan's add and replace rows in file order, stops at the first error, and
reports each row as added, replaced, already there, failed, not tried, or
no room left once the key says it is full.

A code that already works is never removed on a plain add. The old entry
is deleted only after the key refuses an add as a duplicate and replacing
was asked for; if the add after that fails, the report says the old entry
was removed but the new one could not be added. A Molto2 slot write is
never preceded by a delete. The key's adapters (next) map its answers to
"exists", "full" or a plain failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…eports after review (re #170)

- The plan and the Molto2 writer now share one title rule, and the guard
  test compares the exact title, so they can't drift apart silently.
- A 30-second TOTP name that starts with digits and '/' (an issuer like
  "2024/Corp" as well as a bare account) is skipped as ambiguous, because
  authenticator apps would read it as a period. With a period prefix it
  reads back whole and is added. Nitrokey 3 keeps no period in the name,
  so nothing is skipped there.
- A test pins that a replace on a full key isn't skipped for room.
- The 2FAS counter pass reads each service's counter without the seed, so
  no second copy of any seed is made.
- When a replace removes the old entry and the new one can't be added, the
  stop reason names the entry and says why.
- The Token2 reason says "plain ASCII characters", and the Solo 2 secret
  cap explains why it stays at 64 bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…from the key's answer (re #170)

A bulk import has to tell "this name is already on the key" apart from
"the key is full" and "the applet wants its password again", and it has
to do that from the status word the key returns, not from a guess made
before sending. The OATH adapter maps PUT 6983 to "exists" (applets that
refuse duplicates), 6A84 to "full" and 6982 to "needs the password";
DELETE never reads 6983 as "exists", so a replace can't loop.

Applets that drop authorization after each command need a fresh
VALIDATE before the next write. The session now keeps the access key
from the last successful unlock (wiped on drop) so it can re-SELECT and
VALIDATE once without asking for the password again; a second refusal
is reported instead of retried.

The adapter runs over a small I/O trait, so the rules are tested
against a scripted applet without PC/SC, and the plan's command-fit
rule is cross-checked against the real PUT builder. keyroost-transport
gains an in-tree edge to keyroost-import (default features, no serde).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…cked" on Token2 (re #170)

The bulk import writes to Token2 OTP storage and Molto2 slots through the
same one-code-at-a-time store the OATH applets use. On Token2 keys 6983
means the OTP PIN is blocked, so it is reported as that and never read
as "the name exists" the way an OATH applet's 6983 is; a duplicate name
there is simply overwritten by the write.

When the key closes its PIN window mid-run (6982/6985) and a PIN is
held, it is verified again and the write retried once; with no PIN the
key's request is reported instead of guessed at. Token2 entries keep
the field values `otp add` uses (the period as the time step, 30 for
HOTP, no button press).

Molto2 slots get seed, title and config in the order the existing
import uses. Anything the plan already keeps out (HOTP, other periods,
SHA-512, a seed or title past the command limits) fails for that entry
instead of reaching a builder that would panic on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…bytes in oath add (re #170)

A TOTP stored with a non-30 s period carries it as a `period/` name
prefix (the ykman convention, e.g. `60/GitHub:alice`), and the bulk
import writes names that way. `oath code` and the GUI still computed
every code at 30 s, so a 60 s entry showed the wrong code. The OATH byte
layer now splits a stored name into period, issuer and account; `oath
code` uses the prefix unless --period is given (and refuses 0, which
used to reach a panic), and the GUI computes each row and runs its
countdown ring on the row's own period.

A Solo 2 name longer than 127 bytes makes the key stop responding until
it is unplugged, so `oath add` and the GUI add dialog refuse it before
anything is sent: the CLI checks right after choosing the key and before
reading the seed or password.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…clearer refusals after review (re #170)

The Molto2 store wrote the time it was created into every slot's
config, and that field also sets the token's clock, so a long run left
the clock behind by the run's length. It now reads the clock just
before each config goes out, as the single-slot writes do; the clock is
injectable so tests can pin it.

The Token2 store re-verified the OTP PIN whenever the key answered
6982/6985, which could spend an attempt on a wrong PIN. It now
re-verifies only a PIN that already worked in this import, at most
once per write, and a wrong PIN is reported plainly with the tries left
when the key says. An OATH applet that asks for a password nobody gave
now says the storage is password-protected and how to give one (the
front end supplies that part) instead of claiming the password was
refused.

The Solo 2 name refusal lives in one place, OathFamily::name_refusal,
shared by `oath add` and the GUI add dialog; the reauthenticate doc
marks the Solo 2 behavior as read from the firmware source, not
verified on hardware.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…place, --dry-run (re #170)

An authenticator export can now go straight to a key's OATH or Token2 OTP
storage instead of one `add` per code. The key is read before anything is
asked, so the person sees what each entry becomes (added, replacing,
already there, or skipped with the reason the key can't store it) and
confirms once. Codes already on the key are left alone unless --replace,
which is the one word for replacing entries on a key; --overwrite keeps
meaning "replace a local output file".

The key is read again after the question: if its codes changed while the
person was answering, nothing is written. Writing stops at the first
error and the closing line says where, what was added before that and how
many weren't tried, with exit 1; skipped entries are not failures.

The file is read and sorted before any key is looked at, so a certificate
or private key gets a pointer to where it belongs and a missing file fails
without touching a key. The export's password is --file-password (stdin
line 1), the key's own secret keeps its usual flag (--password for OATH,
--pin for Token2 OTP; line 2). The preview, summary and --json output name
codes only; no seed ever appears in them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
… (re #170)

`molto import`, `oath import` and `otp import` now name the same job the
same way: the password of an encrypted export is --file-password on all
three, so --password on an import always means the key's own secret. The
molto flag was added unreleased in this cycle, so no row is kept for it.

The v0.12 spellings --password-env / --password-stdin on `molto import`
now point at --file-password; the generic rows would have named
--password, which `molto import` no longer has. The retired `import-file`
note names it too, and the library's "Aegis export is encrypted" message
(the golden test pins it) names the new flag.

Everything else `molto import` prints is unchanged: it keeps its own file
reader, slot numbering and messages. The one other difference is a binary
file that is recognized key material (a DER certificate, a PKCS #12
bundle): it now gets a pointer to where it belongs instead of "neither a
text export nor a PNG/JPEG image".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…e (re #170)

The line-order test credited `oath import` and `otp import` through the
generic pair reader, which their handlers never call, so a change that
sent the file password to the key as its PIN would have stayed green.
The handlers now take the command's own secret pair and read it through
two functions, one for the file and one for the key's password or PIN,
and the test drives exactly those: both secrets piped (line 1 opens an
encrypted export, or is read and dropped for a plain one, and line 2 is
the key's secret) and each one alone on line 1.

The import JSON gets the same key-shape test as every other --json
output. `molto import --file-password` help says "file", and the warning
for a password given to a file that isn't encrypted is now one shared
sentence (the one `molto import` has always printed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…re anything opens

Only the first dropped file was used, and it went straight into the
Molto2-only bulk dialog whatever it was. Now every dropped file joins a
queue (each path once) and is read on the import thread one at a time,
only when no dialog, notice or device job is running, so a drop during an
operation waits for it. An authenticator export opens the import dialog
(asking for the file password inside it when needed); a certificate or
other key material gets a short notice saying where it belongs, and an
unreadable file says so; nothing else happens. "Import file..." in the
Authenticator, OTP and Molto2 views picks a file into the same queue and
replaces the Molto2 "Bulk import..." button.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…s codes; replaces the Molto2 bulk dialog

The bulk dialog could only fill Molto2 slots, so a YubiKey, Solo 2,
Nitrokey 3 or Token2 key got nothing from an export. The new dialog is
driven by the file: it shows the entries first (asking for an encrypted
vault's password inside), then every connected key that can store codes,
a key with both OATH and OTP storage twice, each with how many entries its
rules accept. The viewed key (or the store of the open tab, when it has
two), else the only capable key, is preselected; otherwise nothing is,
and the reason line says to choose. The list follows hotplug, and a key
that goes away takes its preview and typed password with it.

The preview reads what the chosen key holds and shows per entry what
happens (add, replace, already on the key, skipped with the reason);
same-name entries are skipped unless "Replace existing entries with the
same name" is ticked. The key's password or PIN is asked for in the
dialog, or reused from its pane. "Add N codes to <key>" re-reads the key
and refuses if its codes changed, then writes one at a time through the
shared write loop, stopping on the first error (Molto2 too), and shows
the report. Nothing is held open while the person reads or types.

For the Molto2 the #171 behavior carries over: Close and [X] settle
after the window is drawn, the amber reason line keeps its wording, and
Authenticate appears when the token isn't unlocked. Every close route
wipes the file password and the key password and drops the parsed
entries.

The OATH list now shows a period-prefixed name such as 60/GitHub:alice
as "GitHub" with its period, rather than "60/GitHub".

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Closing the import dialog while an encrypted vault was still being
decrypted let the late result open a fresh dialog with the decrypted
entries (or a notice with the wrong-password error). A retry's result now
only lands in the dialog that asked for it; otherwise it is dropped, and
the entries with it.

Also after review: a refused OATH password during Add wipes the field and
asks again, as the preview already did; masked fields have their undo
history scrubbed when they are wiped on a refusal, a destination change
or a successful open; an unlocked Molto2 whose slot sweep failed says to
refresh the slots instead of "read when unlocked"; a Token2 key supplied
without the OTP function says so plainly, as the OTP pane and the CLI do;
and the result routing (dialog, notice, dropped) is tested.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
… (re #170)

A Solo 2 with an OATH password keeps its unlock for one command only.
Checked on a test key: the import's first PUT after the listing came back
6985 ("conditions of use not satisfied"), not 6982, so the import never
re-unlocked and stopped at the first entry with nothing written. Both
answers now mean "unlock again and retry once"; a key that still refuses
is reported as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
The import preview treated a slot as in use only when it had a title, so a
slot programmed without one (or whose title was cleared) was planned as a
plain add and written over without the "replace" row and note. `molto import`
and the Molto2 slot list both go by the seed; the preview now counts a slot
as in use when it has a seed or a title.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
ykman refuses to add a name that a different name already on the key
starts with on firmware after 4.0.0 and before 4.3.5, because of a known
firmware issue when overwriting in that case. The import plan only
compared whole names, so such an entry was planned as a plain add, and
single `oath add` sent it too. One shared check,
`OathFamily::prefix_refusal`, mirrors ykman's rule; the import plan skips
the entry with a plain reason (names planned earlier in the file count as
on the key), and `oath add` in the CLI and the GUI refuses before
anything is written. The direction is ykman's: a longer name next to a
shorter one on the key is still allowed.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
… alone

The CLI and the GUI each had their own copy of the format and hash names,
the preview and report wording, the summary line and the re-check, and
the copies had drifted ("Aegis, encrypted" vs "Aegis (encrypted)"; one
meaning called "exists", "left as is", "already there" and "already on
the key"). They now come from keyroost-import: `Format::name`,
`HashAlg::name`, `Verdict::word`/`reason`, `Outcome::word`/`reason`,
`Report::summary`, `PlanRow::shown_label`, `Plan::same_rows` and
`plan_for`. One word per meaning: entries are in the file, codes are on
the key; add / replace / exists / skip; "left as is"; nothing says
"overwrite" or "written over". The JSON outcome "already-there" is now
"exists" (unreleased). The Nitrokey 3 touch note says the key may ask
for a touch, since that depends on its firmware. The CLI progress line
says "replacing" for a replace.

Report rows keep a Molto2 slot number (`#5 GitHub — added`), so after
a stop the person can see which slot failed.

In the GUI import dialog:
- choosing a Molto2 destination selects that Molto2 (as clicking it in
  the list would), so Authenticate and Add work from the dialog alone;
  the reason lines no longer send the person to the list or the Molto2
  view for the default key;
- a Molto2 import that wrote everything shows its report like every
  other destination instead of closing silently, and the slot list is
  read again if the sweep after the writes failed;
- "Add" never does nothing silently: when it can't start, the dialog
  says why.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
The bounded file read grew a plain Vec with read_to_end and only the
final buffer was wrapped for wiping, so every reallocation freed a copy
of a plaintext export (every seed in clear) without wiping it.
`read_bounded` now reads in chunks into a wipe-on-drop buffer that grows
by copying into a new wipe-on-drop buffer, and returns it as such; the
CLI and the GUI keep it that way.

The password and open messages for an encrypted Aegis file now say
"export", like the rest of the import flow, instead of "vault".

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
The OATH import adapter sent whatever name the plan gave it. Every caller
plans with the right family today, but a caller that planned with the
generic family (a Solo 2 whose vendor string isn't recognized) would send
a name that makes the key stop responding. `OathImportStore::new` now
takes the family and refuses a name past its hard limit before sending,
as a second line of defense behind the plan.

Token2: the store started out treating the PIN as unproven although the
read just before had verified it on the same session, so a PIN window
the key closed before the first write was reported as "the key didn't
accept the OTP PIN". A PIN verified on the session now counts as proven
(the window is reopened with it, as for later writes), and the unproven
case says plainly that the window closed and no attempt was used.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Skip reasons, plan notes, store errors, unusable-entry reasons, export
formats, key-material kinds and OATH families are all lists that grow as
devices and formats are added. Marking them `#[non_exhaustive]` now, while
they are new, keeps a later addition from being a breaking change for
other crates. Store, Verdict, Outcome, AuthFormat and FileKind stay
exhaustive on purpose: the front ends and the file reader must handle
every case, and the compiler should say so when one is added.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
rustdoc read "[S1][S6]" in the new prefix-rule doc comment as intra-doc
links and failed with -D warnings.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Three edges from the re-review of the bulk-import fixes:

- Token2: the import store counted any open PIN window as proof of the
  typed PIN, but a fingerprint unlock opens the same window without
  checking a PIN, so a later closed window could re-send an unchecked
  (possibly wrong) PIN and spend an attempt. The session now tracks a
  typed-PIN verify separately, and only that lets the store re-send it;
  otherwise the closed window is reported plainly with nothing sent.
- YubiKey 4 firmware 4.0-4.3.4: an entry whose exact name is already on
  the key is "exists" (left as is) before the prefix rule is looked at,
  since nothing is written; with replace it is still refused, as ykman
  does.
- GUI: a Molto2 selection the dialog is waiting to make (while a device
  job runs) is dropped when the person selects another key meanwhile,
  or the destination changes, or the dialog closes, so it never moves
  the selection back against a later click.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
A Molto2 row carries no serial of its own: enumeration never contacts the
token, so `-d serial:X` compared against an empty string and every serial
form failed, while `list` shows the USB serial in the reader name and
`molto info` and the GUI show the card serial. Only the list number
worked.

`--device` now also matches a Molto2 by the serial in its reader name
(no card contact), and, only when the value matched nothing else, by the
card serial read from each connected Molto2 in a short session. Other
keys never wait on a Molto2, and a value that matches cheaply never
touches the token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
`molto import --in` kept its own reader and writer: one entry the Molto2
can't store refused the whole file, an entry with no name still took a
slot, and its dry run listed raw entry fields. The GUI's Molto2
destination already went through the shared plan, so the same file did
different things in the CLI and the GUI.

The file mode now uses the shared reader, plan, preview, single
question, progress lines, report, exit codes and JSON that `oath import`
and `otp import` use, with the Molto2 slot model: entries fill
consecutive slots from --slot, slots in use are replaced and the preview
says so per row, and entries the Molto2 can't store are skipped with
the reason. The dry run reads slot use from a connected Molto2 (no
customer key needed) and still works with none connected, saying slot
use wasn't read. Slot use is read by one shared function in the
transport adapter, which the GUI now calls too. Stdin keeps the
customer key on line 1 and the export's password on line 2. The single
--uri / --qr mode is unchanged.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
…ilent token

Review follow-ups for the shared Molto2 import and the serial lookup:
- the CLI and the GUI now call one `molto_plan`, and a test checks that
  both ways of reading slot use give the same plan for the same file and
  start slot;
- a dry run whose Molto2 doesn't answer shows the preview without slot
  use and says why, instead of failing (a real import still stops);
- the preview header names the start slot it is given rather than
  working it back from the plan's capacity;
- the card-serial lookup runs only for commands that can act on a
  Molto2, and a Molto2 that doesn't answer is named in a note rather
  than left out silently;
- refusals and `list --json` offer `serial:<reader serial>` for a
  Molto2, which stays put when keys come and go;
- test helper reuse and a rewrapped module comment.

re #170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
framefilter and others added 5 commits October 10, 2026 11:04
The import now reaches OATH keys and Token2 OTP storage, and molto import
follows the same rules, so a person needs one place that says what each
key can hold, why an entry is skipped, and how --replace and the export's
password work. The README gains an "Importing codes" section with the
per-key limits; the OATH, OTP and Molto2 Learn pages show the GUI dialog
and CLI; the migration page lists what changed for molto import, the
Molto2 dialog and oath code --period; changelog fragments cover each
change, and the #171 fragment now names the dialog that ships.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
#170)

molto import already skips what doesn't fit before slot #99 and writes the
rest; the GUI refused the whole import with "Only F of C entries fit".
Both now use the shared Molto2 plan's skip ("no slot left after #99"), so
the same file plans the same rows in either front end. The blocker, its
slot count and its message go; the parity test covers an overflowing file,
and the README, Molto2 page and #171 fragment no longer describe it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
Choosing a Molto2 by serial and the molto import dry run's --device
refusal never shipped: v0.12.0 took no serials and ignored --device on a
dry run. The serial note moves into the #165 key-selection entry and its
"fixed" fragment goes; the migration page drops the matching row and says
the old dry run ignored --device.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
… (re #170)

The serial/clock block is the "which token, and is its clock off" check
every Molto2 write prints before it changes anything. The new import
reads slot use through the same helper, so a dry run printed it too,
although it writes nothing; `oath import --dry-run` shows only the
preview. A real import still prints it before the question.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
With no key that can store codes connected, the dialog showed the
"Connect a key that can store authenticator codes…" sentence twice:
under "Add to" and again as the reason line under the buttons. The
list now says briefly that no connected key can store codes; the
reason line keeps the instruction.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Skvxkb6eMzytjPDmL6mPYU
@token2

token2 commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Token2 on device OTP can be password protected starting from R3.4 - has this been taken into account?

@framefilter

Copy link
Copy Markdown
Owner Author

The R3.4 OTP PIN is covered: if the key has one, the import asks for it once, uses it to find codes already on the key, and never spends an extra attempt. Apologies in advance if I misunderstood the question.

@token2

token2 commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

The R3.4 OTP PIN is covered: if the key has one, the import asks for it once, uses it to find codes already on the key, and never spends an extra attempt. Apologies in advance if I misunderstood the question.

Great, looking forward

@framefilter
framefilter merged commit 5175304 into main Oct 10, 2026
13 checks passed
@framefilter
framefilter deleted the feat/bulk-import branch October 10, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants