diff --git a/CLAUDE.md b/CLAUDE.md index b26a9cef..976c3e4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ tool. Workspace contains: | Crate | Purpose | External deps | |---|---|---| | `keyroost-proto` | Pure-Rust protocol layer (SM4, SHA-1, APDU builders, MAC) | none | -| `keyroost-transport` | PC/SC reader discovery, Molto2 session, YubiKey CCID serial, OATH + OpenPGP applets | `pcsc`; `aes`/`des`/`cipher`/`getrandom`/`zeroize` (PIV mgmt-key auth); `hidapi` (non-Linux HID) | +| `keyroost-transport` | PC/SC reader discovery, Molto2 session, YubiKey CCID serial, OATH + OpenPGP applets, import writers for OATH / Token2 OTP / Molto2 (in-tree `keyroost-import`) | `pcsc`; `aes`/`des`/`cipher`/`getrandom`/`zeroize` (PIV mgmt-key auth); `hidapi` (non-Linux HID) | | `keyroost-hid` | USB HID enumeration of FIDO devices via sysfs | `hidapi` (non-Linux only; Linux uses sysfs) | | `keyroost-ctap` | FIDO2/CTAP-HID transport, CBOR, PIN protocols, credential mgmt, largeBlob (incl. the on-key name entry, `docs/PROTOCOL-device-label.md`) | RustCrypto (`sha2`/`hmac`/`aes`/`cbc`/`p256`/`aes-gcm`), `rand_core`, `zeroize`, `miniz_oxide` (largeBlob DEFLATE); `hidapi` (non-Linux HID) | | `keyroost-oath` | Pure-Rust Yubico/Trussed OATH (TOTP/HOTP) byte layer (APDU + TLV) | `zeroize` | @@ -41,7 +41,7 @@ tool. Workspace contains: | `keyroost-resolve` | Shared key-identity resolution (USB + CCID serials, topology match) and naming (on-key names, first-seen records) | in-tree only (incl. `keyroost-ctap`) | | `keyroost-rsakey` | Host-side RSA-2048 keygen + PKCS#1/PKCS#8 (PEM/DER) loading for OpenPGP import | `rsa`, `rand`, `zeroize` (scoped exception) | | `keyroost-pivtest` | Host-side verification for the PIV slot self-test (`piv test`): fixed challenges checked against the slot certificate's public key | `rsa`, `p256`/`p384`/`p521` (ECDH), `ed25519-dalek`, `x25519-dalek`, `zeroize` (scoped exception) | -| `keyroost-import` | otpauth:// + Aegis / 2FAS / otpauth-list parsers | `zeroize`; `serde`/`serde_json` (behind `bulk`); `scrypt`/`aes-gcm`/`base64` (behind `encrypted`, for Aegis vaults) | +| `keyroost-import` | otpauth:// + Aegis / 2FAS / otpauth-list parsers, file classification, per-key import plan and write loop | `zeroize`; `serde`/`serde_json` (behind `bulk`); `scrypt`/`aes-gcm`/`base64` (behind `encrypted`, for Aegis vaults) | | `keyroost-qr` | QR 2FA import from PNG/JPEG screenshots + Google Authenticator migration batches (always built; the GUI's separate `qr` feature gates *screen capture*, not this) | `rqrr`, `png`, `jpeg-decoder`, `zeroize` | | `keyroost-screengrab` | Windows-only GDI screen capture for QR-from-screen; the sole `unsafe` FFI crate; inert on non-Windows | `windows-sys` (Windows only) | | `keyroost-winwebauthn` | Windows-only non-admin FIDO2 helper: detect a FIDO key, open Windows' security-key settings, relaunch elevated; inert on non-Windows | `windows-sys` (Windows only) | diff --git a/Cargo.lock b/Cargo.lock index e3a65f44..ce774101 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2369,6 +2369,7 @@ dependencies = [ "hidapi", "keyroost-ctap", "keyroost-hid", + "keyroost-import", "keyroost-oath", "keyroost-openpgp", "keyroost-piv", diff --git a/README.md b/README.md index 349ac136..b7d48a84 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,17 @@ a short, vendor-neutral tour of what FIDO2, OATH, OpenPGP, and PIV actually do. two; for **provisioning many slots or keys, the CLI is the intended path** — the management key and PIN come from env/stdin once, so a shell loop does the batch (see the [PIV guide](https://framefilter.github.io/keyroost/piv.html)). -- **Token2 Molto2 / Molto2v2** — program a slot from an `otpauth://` URI; - bulk-import from Aegis (plaintext or encrypted), 2FAS, a list of `otpauth://` - URIs, or a QR code scanned from a PNG/JPEG screenshot or the live screen; sync - the host clock; rotate the customer key; factory reset. +- **Token2 Molto2 / Molto2v2** — program a slot from an `otpauth://` URI or + a QR code scanned from a PNG/JPEG screenshot or the live screen; import a + whole authenticator export into consecutive slots; sync the host clock; + rotate the customer key; factory reset. +- **Importing authenticator codes** — import an authenticator export (Aegis, + plain or encrypted; 2FAS; a list of `otpauth://` URIs; a QR screenshot, + including a Google Authenticator export) into a YubiKey, Solo 2 or + Nitrokey 3 (OATH), a Token2 key's OTP storage, or a Molto2. keyroost shows + what each code becomes before it writes anything (`oath import`, + `otp import`, `molto import --in`, or drop the file on the GUI); see + [Importing codes](#importing-codes). - **Token2 single-profile programmable TOTP token** — program the seed and TOTP configuration onto Token2's single-account card/fob tokens (OTPC-P1-i / P2-i, miniOTP-2-i / 3-i, C301-i, C302-i) over a PC/SC reader. These authenticate @@ -139,12 +146,12 @@ a short, vendor-neutral tour of what FIDO2, OATH, OpenPGP, and PIV actually do. | Device | Capabilities | Notes | |---|---|---| -| **Token2 Molto2 / Molto2v2** | TOTP slot programming, bulk import | Hardware-verified. Programmed over the vendor-specific SM4-MAC protocol ([docs/PROTOCOL.md](docs/PROTOCOL.md)); supports bulk import from Aegis / 2FAS / otpauth-list, clock sync, and customer-key rotation. | +| **Token2 Molto2 / Molto2v2** | TOTP slot programming, code import | Hardware-verified. Programmed over the vendor-specific SM4-MAC protocol ([docs/PROTOCOL.md](docs/PROTOCOL.md)); supports importing Aegis / 2FAS / otpauth-list exports and QR screenshots, clock sync, and customer-key rotation. | | **Token2 single-profile tokens** (OTPC-P1-i / P2-i, miniOTP-2-i / 3-i, C301-i, C302-i) | Single-account TOTP seed + config programming | Programmed over the vendor-specific SM4-MAC protocol with a fixed device key ([docs/PROTOCOL-token2prog.md](docs/PROTOCOL-token2prog.md)); writes the seed and the TOTP algorithm / time-step / display-timeout over a contact or contactless PC/SC reader. The model is recognized from the device serial. | -| **Token2 PIN+ Series** | FIDO2 (+ bio), OTP, OpenPGP, PIV | FIDO2 with fingerprint/bio enrollment and FIDO Metadata Service (MDS) display, plus on-device OTP (TOTP/HOTP) over CCID / NFC / USB-HID, validated on PIN+ hardware. HID/keyboard HOTP applies to the models that carry it — recent releases (R3.2+/R3.3+) have no HID-HOTP and ship with the HID channel disabled by design; CCID is the intended path there. Keys running the R3.4+ OTP applet can put codes behind an OTP PIN; keys without it answer the capability probe with "no such command" and are unaffected. Contributed by [@token2](https://github.com/token2). PIV management was exercised on Token2 hardware by [@episource](https://github.com/episource) ([#128](https://github.com/framefilter/keyroost/pull/128)), and keyroost decodes the BCD-coded serial Token2's PIV and OpenPGP applets report; `piv info` and `openpgp info` show the key's full serial, read from its OTP applet. The OATH / OpenPGP smart-card applets are handled by the standard byte layers but **not yet exercised on PIN+ hardware by this project** (experimental). | -| **YubiKey** (5 series) | FIDO2, OATH, OpenPGP, PIV | Built and verified against a YubiKey 5.7. | -| **SoloKeys Solo 2** | FIDO2, OATH | Trussed firmware; no OpenPGP applet. **HOTP caveat:** the last-shipped Solo 2 firmware (2.3.x) computes HOTP over a 4-byte counter where RFC 4226 specifies 8, so its HOTP codes won't verify against standards-compliant servers (hardware-verified; fixed in the current upstream Trussed secrets app, but Solo 2 no longer receives firmware updates). TOTP is unaffected — its 8-byte time challenge comes from the host. | -| **Nitrokey 3** | FIDO2, OATH, PIV; OpenPGP detected | Built around the same Trussed firmware core as Solo 2, but the final firmware is different — e.g. Nitrokey 3 has PIV support, while Solo 2 does not. PIV verified on a Nitrokey 3A NFC (firmware 1.8.3), contributed by [@episource](https://github.com/episource); keyroost reads the serial and firmware version from the Nitrokey admin application. The OpenPGP applet is detected but not yet exercised by this project. | +| **Token2 PIN+ Series** | FIDO2 (+ bio), OTP (incl. import), OpenPGP, PIV | FIDO2 with fingerprint/bio enrollment and FIDO Metadata Service (MDS) display, plus on-device OTP (TOTP/HOTP) over CCID / NFC / USB-HID, validated on PIN+ hardware. Importing an authenticator export into OTP storage follows Token2's published OTP specification (see [Importing codes](#importing-codes)). HID/keyboard HOTP applies to the models that carry it — recent releases (R3.2+/R3.3+) have no HID-HOTP and ship with the HID channel disabled by design; CCID is the intended path there. Keys running the R3.4+ OTP applet can put codes behind an OTP PIN; keys without it answer the capability probe with "no such command" and are unaffected. Contributed by [@token2](https://github.com/token2). PIV management was exercised on Token2 hardware by [@episource](https://github.com/episource) ([#128](https://github.com/framefilter/keyroost/pull/128)), and keyroost decodes the BCD-coded serial Token2's PIV and OpenPGP applets report; `piv info` and `openpgp info` show the key's full serial, read from its OTP applet. The OATH / OpenPGP smart-card applets are handled by the standard byte layers but **not yet exercised on PIN+ hardware by this project** (experimental). | +| **YubiKey** (5 series) | FIDO2, OATH (incl. import), OpenPGP, PIV | Built and verified against a YubiKey 5.7. | +| **SoloKeys Solo 2** | FIDO2, OATH (incl. import) | Trussed firmware; no OpenPGP applet. **HOTP caveat:** the last-shipped Solo 2 firmware (2.3.x) computes HOTP over a 4-byte counter where RFC 4226 specifies 8, so its HOTP codes won't verify against standards-compliant servers (hardware-verified; fixed in the current upstream Trussed secrets app, but Solo 2 no longer receives firmware updates). TOTP is unaffected — its 8-byte time challenge comes from the host. | +| **Nitrokey 3** | FIDO2, OATH (incl. import), PIV; OpenPGP detected | Built around the same Trussed firmware core as Solo 2, but the final firmware is different — e.g. Nitrokey 3 has PIV support, while Solo 2 does not. PIV verified on a Nitrokey 3A NFC (firmware 1.8.3), contributed by [@episource](https://github.com/episource); keyroost reads the serial and firmware version from the Nitrokey admin application. Importing codes into its OATH storage follows Nitrokey's published secrets-app source (see [Importing codes](#importing-codes)). The OpenPGP applet is detected but not yet exercised by this project. | | **Swissbit iShield Key 2 Pro** (and iShield Key 1) | PIV (partial) | Key generation and certificate handling verified by [@episource](https://github.com/episource) on their own iShield Key 2 Pro. keyroost doesn't use the slot key type and PIN/touch policy this card reports in GET METADATA ([#113](https://github.com/framefilter/keyroost/issues/113)), so a slot without a certificate reads "no certificate (a key may be present)". Answers the Yubico version extension with four bytes instead of three; keyroost keeps the reply as sent. On the iShield Key 1 and 2, keyroost reads the serial and device name from Swissbit's own management applications (and, on the Key 1, the firmware version) for `piv info` and the GUI, contributed by [@episource](https://github.com/episource) ([#163](https://github.com/framefilter/keyroost/pull/163)). Other applets not yet exercised by this project. | | **Any standards-compliant FIDO2 key** (e.g. Thales, Feitian, Titan) | FIDO2 / CTAP2; OATH / OpenPGP / PIV only if the key carries those applets | keyroost implements the published specs, not vendor-specific behavior, so the `fido` commands — getInfo, passkey management, PIN, reset — work on any CTAP2 authenticator, including ones not listed here. Optional features (fingerprint, largeBlob, authenticatorConfig) surface only when the key advertises them in getInfo. The smart-card applets apply only to keys that expose an OATH / OpenPGP / PIV applet over PC/SC. Older U2F-only (CTAP1) keys are detected by `list` but don't support the CTAP2 management commands. | @@ -654,6 +661,7 @@ keyroostctl fido ssh export --rp ssh:demo --out demo-cert.pub # --- OATH over PC/SC --- keyroostctl oath list keyroostctl oath code 'GitHub:me@x.com' --device my-yubikey +keyroostctl oath import -i export.json --dry-run # show what an export would add; writes nothing # --- OpenPGP card --- keyroostctl openpgp info --device my-yubikey @@ -688,6 +696,7 @@ keyroostctl molto info keyroostctl molto import --slot 0 # asks for the otpauth:// URI (hidden) keyroostctl molto import --slot 0 --uri env:URI # or from an environment variable keyroostctl molto import --in ~/Downloads/aegis.json --slot 0 --dry-run # validate first +keyroostctl molto import --in ~/Downloads/aegis.json --slot 0 --file-password env:PW # encrypted export keyroostctl molto seed set --slot 5 # asks for the seed (base32; --encoding hex for hex) # --- Token2 single-profile programmable token (OTPC / miniOTP / C30x) --- @@ -699,6 +708,7 @@ keyroostctl prog config set --algorithm sha1 --period 30 --display-timeout 30 keyroostctl otp list keyroostctl otp add --app GitHub --account me@x.com # asks for the seed (hidden) keyroostctl otp code --app GitHub --account me@x.com +keyroostctl otp import -i export.json # add every code in an export (asks first) # --- Destructive operations (ask y/N, or type "reset", in a terminal) --- keyroostctl factory-reset --device my-yubikey # asks you to type "reset" @@ -728,9 +738,11 @@ a terminal shows a numbered list to pick from; a script is refused with the exact `--device` value for each key. `--device` takes a saved name, a serial, or the number `keyroostctl list` shows (prefix `name:`, `serial:` or `list:` to say which you mean); `keyroostctl --json list` gives scripts the same -values. `--reader` (a reader name, or a unique part of one) and `--path` are -expert overrides: they skip the capability check, and a value that matches -no detected key is used as typed. Neither can be combined with `--device`. +values. A Molto2 answers to either serial keyroost shows for it: the one in +`keyroostctl list` or the one `molto info` prints. `--reader` (a reader +name, or a unique part of one) and `--path` are expert overrides: they skip +the capability check, and a value that matches no detected key is used as +typed. Neither can be combined with `--device`. Five flags have a short form, with the same meaning on every command: `-d` (`--device`), `-y` (`--yes`), `-o` (`--out`), `-i` (`--in`) and @@ -828,6 +840,88 @@ terminal, a command with no source for a secret it needs is refused, naming the flag. In Git Bash (mintty) on Windows the prompt needs a real console: run `winpty keyroostctl …`, or use `env:NAME`. +### Importing codes + +Move the codes from an authenticator app onto a key in one step. keyroost +reads Aegis exports (plain or encrypted), 2FAS exports, a text file with one +`otpauth://` URI per line, and QR screenshots (PNG or JPEG), including a +Google Authenticator export. + +```bash +keyroostctl oath import -i export.json --dry-run # show what would happen; writes nothing +keyroostctl oath import -i export.json # YubiKey, Solo 2, Nitrokey 3: asks once, then adds +keyroostctl otp import -i export.json # a Token2 key's OTP storage +keyroostctl molto import -i export.json --slot 90 # a Molto2, from slot #90 on +keyroostctl oath import -i export.json --file-password env:EXPORT_PW # an encrypted Aegis export +``` + +In the GUI, drop the file on the window, or click **Import file…** in a +key's Authenticator, On-device OTP or Molto2 view. The **Import +authenticator codes** dialog lists the entries, lets you pick the key under +**Add to**, and shows what each entry will become before anything is +written. An encrypted export asks for its password in the dialog. A +certificate or key file dropped on the window gets a note saying where it +can be imported instead. + +Before writing, keyroost reads what is already on the key and marks each +entry: + +- **add**: it goes onto the key. +- **exists**: a code with the same name is already on the key, so it is + left as is. `--replace` (in the GUI, "Replace codes already on the key + that have the same name") replaces it instead. Some keys refuse a second + code with the same name; there, replacing removes the old code first, and + if adding the new one then fails, the old code is gone. +- **skip**: the key can't store it, and the preview says why (for example + "Solo 2 doesn't take SHA-512 codes"). keyroost never changes an entry to + make it fit. Steam codes are skipped on every key. + +On a Molto2, entries fill consecutive slots from `--slot`. A slot already in +use is replaced (**replace**), an entry with no issuer or account is +skipped without using up a slot, and entries past slot #99 are skipped. + +keyroost asks once before writing (a script adds `-y`). It writes one code +at a time and stops at the first error, saying what was added and what was +not. Running the same import again is safe: codes it already added now +show as **exists**. `--dry-run` writes nothing; for `oath import` and `otp +import` the key must be connected, because the preview depends on what is +on it. `molto import --dry-run` reads slot use from a connected Molto2, and +works without one too. `--json` prints the plan and, after writing, what +happened to each entry. + +An encrypted export's password comes from `--file-password env:NAME` or +`--file-password stdin`, or a terminal asks for it. When the export's +password and the key's password or PIN are both piped in, the export's +password is the first line. On `molto import`, `--customer-key stdin` is +the first line and the export's password the second. + +What each key can store: + +| Key | Holds | Limits | +|---|---|---| +| YubiKey | 32 codes, or 64 from firmware 5.7 | names up to 64 bytes; SHA-512 from firmware 4.3.1; 6 to 8 digits | +| Solo 2 | not reported (writing stops if it fills up) | names up to 127 bytes; no SHA-512; secrets up to 64 bytes | +| Nitrokey 3 | 50 codes | names up to 128 bytes; 30-second codes only (the key doesn't store the period); no SHA-512 | +| Token2 OTP storage | 50 codes | app and account up to 64 bytes each, plain ASCII only; no SHA-512; HOTP only with a counter of 0 (the key always starts at 0) | +| Molto2 | 100 slots (#00 to #99) | TOTP only, 30 or 60 seconds; SHA-1 or SHA-256; 4, 6, 8 or 10 digits; titles shortened to 12 bytes | + +On a YubiKey or Solo 2, a TOTP code with a period other than 30 seconds is +stored under a name that starts with its period, such as `60/GitHub:alice` +(the naming Yubico's own tools use). `oath code` and the GUI compute +those codes with that period. + +A Solo 2 name longer than 127 bytes is refused by `oath import`, `oath add` +and the GUI before anything is sent: on Solo 2 firmware 2.3.196, a 128-byte +name made the key stop responding until it was unplugged. On YubiKey 4 +firmware 4.0 to 4.3.4, a name that is the start of another name already on +the key is skipped (and `oath add` refuses it), following the same rule as +Yubico's `ykman`. + +Importing into a Token2 key's OTP storage and into a Nitrokey 3 is built +from the published specifications (Token2's OTP specification and +Nitrokey's secrets-app source). If you use one of these keys, reports in the +[issue tracker](https://github.com/framefilter/keyroost/issues) are welcome. + ### Reading the output A command prints its result on stdout and everything else (prompts, @@ -873,7 +967,7 @@ old script. | Crate | Purpose | External deps | |---|---|---| | `keyroost-proto` | Pure-Rust Molto2 wire protocol (SM4, SHA-1, APDU, MAC) | none | -| `keyroost-transport` | PC/SC discovery, Molto2 session, CCID serial, OATH/OpenPGP/PIV applets, Token2 OTP session | `pcsc`, `aes`/`des`/`cipher` (mgmt-key auth), `getrandom`, `zeroize`, `miniz_oxide` (gzip-compressed PIV certificates); `hidapi` on macOS/Windows | +| `keyroost-transport` | PC/SC discovery, Molto2 session, CCID serial, OATH/OpenPGP/PIV applets, Token2 OTP session, import writers for OATH / Token2 OTP / Molto2 (in-tree `keyroost-import`) | `pcsc`, `aes`/`des`/`cipher` (mgmt-key auth), `getrandom`, `zeroize`, `miniz_oxide` (gzip-compressed PIV certificates); `hidapi` on macOS/Windows | | `keyroost-hid` | USB HID enumeration of FIDO devices | none on Linux (`sysfs`); `hidapi` on macOS/Windows | | `keyroost-ctap` | FIDO2/CTAP-HID transport, CBOR, PIN protocols, credential management, large-blob storage (including the name a key carries) | RustCrypto (`sha2`/`hmac`/`aes`/`cbc`/`p256`/`rand_core`) for client-PIN, `aes-gcm` + `miniz_oxide` for largeBlob entries, `zeroize`; `hidapi` on macOS/Windows | | `keyroost-oath` | Pure-Rust Yubico/Trussed OATH (TOTP/HOTP) byte layer | `zeroize` | @@ -885,7 +979,7 @@ old script. | `keyroost-keyring` | Friendly-name registry (`keys.json`, salted fingerprints) | `serde`, `serde_json`, `getrandom` (the per-computer salt); in-tree SHA-256 from `keyroost-proto` | | `keyroost-resolve` | Shared key-identity resolution (USB + CCID serials, topology match) and naming (names on the key and on this computer) | none (in-tree only, including `keyroost-ctap`) | | `keyroost-rsakey` | Host-side RSA-2048 keygen + PKCS#1/PKCS#8 (PEM/DER) loading | `rsa`, `rand`, `zeroize` | -| `keyroost-import` | `otpauth://` + Aegis / 2FAS / otpauth-list parsers | `zeroize`; `serde`/`serde_json` (behind `bulk`); `scrypt`, `aes-gcm`, `base64` (behind `encrypted`, which implies `bulk`) | +| `keyroost-import` | `otpauth://` + Aegis / 2FAS / otpauth-list parsers, file classification, and the per-key import plan and write loop | `zeroize`; `serde`/`serde_json` (behind `bulk`); `scrypt`, `aes-gcm`, `base64` (behind `encrypted`, which implies `bulk`) | | `keyroost-qr` | QR 2FA import from PNG/JPEG screenshots and Google Authenticator export batches — always built in; the GUI's *live screen* capture is behind the `qr` feature (on in the release archives and the AppImage, off in the Flatpak) | `rqrr`, `png`, `jpeg-decoder`, `zeroize` | | `keyroost-winwebauthn` | Windows-only helper for the non-admin FIDO2 path: detect a FIDO key via the HID access-denied signal, open Windows' security-key settings, and relaunch elevated; inert on non-Windows | `windows-sys` (Windows only) | | `keyroost-screengrab` | Windows-only still screen capture (GDI `BitBlt`) for QR-from-screen; isolates the unsafe Win32 FFI from the GUI crate; inert on non-Windows | `windows-sys` (Windows only) | diff --git a/changelog.d/added-170-import-codes.md b/changelog.d/added-170-import-codes.md new file mode 100644 index 00000000..ef9c9f7f --- /dev/null +++ b/changelog.d/added-170-import-codes.md @@ -0,0 +1,11 @@ +- **Import authenticator codes into any key that stores them.** `oath + import` adds every code in an Aegis (plain or encrypted), 2FAS, + `otpauth://` list or QR export to a YubiKey, Solo 2 or Nitrokey 3, and + `otp import` to a Token2 key's OTP storage. In the GUI, drop the file on + the window or click "Import file…" in the Authenticator, On-device OTP or + Molto2 view; the "Import authenticator codes" dialog works for every key + that stores codes. keyroost reads the key first and previews each entry: + a code whose name is already on the key is skipped (`--replace` + replaces it), and one the key can't store is listed with the reason. + `--dry-run` writes nothing. It asks once, writes one code at a time, and + stops at the first error with a report of what was added. ([#170]) diff --git a/changelog.d/added-170-solo2-name-limit.md b/changelog.d/added-170-solo2-name-limit.md new file mode 100644 index 00000000..d6795f1c --- /dev/null +++ b/changelog.d/added-170-solo2-name-limit.md @@ -0,0 +1,7 @@ +- **Solo 2 names are capped at 127 bytes.** `oath add`, `oath import` and + the GUI refuse a longer Solo 2 name before sending it: a 128-byte name + made the key stop responding until it was unplugged. ([#170]) +- **YubiKey 4 prefix names.** On YubiKey 4 firmware 4.0 to 4.3.4, `oath + add`, `oath import` and the GUI refuse a name that is the start of + another name already on the key, following the same rule as Yubico's + `ykman`. ([#170]) diff --git a/changelog.d/changed-165-key-selection.md b/changelog.d/changed-165-key-selection.md index 8d597fbd..e8152411 100644 --- a/changelog.d/changed-165-key-selection.md +++ b/changelog.d/changed-165-key-selection.md @@ -3,7 +3,9 @@ from (now on every platform); a script gets a refusal that lists the `--device` value for each key. No command takes "the first key found" any more. `--device` takes a name, a serial or a `list` number - (`name:`, `serial:` or `list:` forces which). `--device` with `--reader` - or `--path` is refused, and so is `--device` on commands that touch no - key. `--reader` and `--path` skip the capability check, and a value that - matches no detected key is used as typed. ([#165]) + (`name:`, `serial:` or `list:` forces which); a Molto2 answers to either + serial keyroost shows for it, the one in `list` or the one `molto info` + prints. `--device` with `--reader` or `--path` is refused, and so is + `--device` on commands that touch no key. `--reader` and `--path` skip + the capability check, and a value that matches no detected key is used + as typed. ([#165]) diff --git a/changelog.d/changed-170-file-password.md b/changelog.d/changed-170-file-password.md new file mode 100644 index 00000000..604f875e --- /dev/null +++ b/changelog.d/changed-170-file-password.md @@ -0,0 +1,5 @@ +- **`molto import --file-password`.** An encrypted Aegis export's password + now comes from `--file-password env:NAME` or `--file-password stdin`, the + same flag `oath import` and `otp import` use (v0.12's `import-file + --password-env` / `--password-stdin` are refused with an error naming + it). ([#170]) diff --git a/changelog.d/changed-170-import-dialog.md b/changelog.d/changed-170-import-dialog.md new file mode 100644 index 00000000..3401993f --- /dev/null +++ b/changelog.d/changed-170-import-dialog.md @@ -0,0 +1,5 @@ +- **The Molto2 "Bulk import" dialog is replaced.** "Import file…" in the + Molto2 view, or a file dropped on the window, opens the shared "Import + authenticator codes" dialog. Every dropped file is identified first: a + certificate or key file gets a note saying where to import it, and any + other file gets "keyroost can't import this file". ([#170]) diff --git a/changelog.d/changed-170-molto-import-rules.md b/changelog.d/changed-170-molto-import-rules.md new file mode 100644 index 00000000..2cd707ef --- /dev/null +++ b/changelog.d/changed-170-molto-import-rules.md @@ -0,0 +1,8 @@ +- **`molto import --in` follows the shared import rules.** An entry the + Molto2 can't store (for example a SHA-512 code or a 45-second period) is + skipped with the reason instead of refusing the whole file, and entries + past slot #99 are skipped. An entry with no issuer or account no longer + takes a slot. The preview, the one question before writing (`--yes` in + a script), the report and `--json` are the same as `oath import`. + `--dry-run` shows which slots are in use when a Molto2 is connected + (`--device` picks which one). ([#170]) diff --git a/changelog.d/fixed-170-bulk-import-dialog.md b/changelog.d/fixed-170-bulk-import-dialog.md index 2e582f3b..005ad16e 100644 --- a/changelog.d/fixed-170-bulk-import-dialog.md +++ b/changelog.d/fixed-170-bulk-import-dialog.md @@ -1,10 +1,9 @@ -- **The Molto2 bulk import dialog closes and says why "Program all" is - gray.** Close in the bulk import dialog, and Cancel in the "Import to - slot" dialog, did nothing; both now close the dialog, and closing - either dialog by any route wipes the typed vault password or pasted URI - and drops the parsed entries (which carry seeds). - When "Program all" can't run, one line under the buttons now says why: - the file is still loading, no Molto2 is selected, the Molto2 isn't - unlocked yet (with an Authenticate button right there, and a failed - attempt is reported in that line too), or the entries - don't all fit before slot #99 (with how many do). ([#170]) +- **The Molto2 import dialog closes and says why adding is gray.** Close + in the import dialog, and Cancel in the "Import to slot" dialog, did + nothing; both now close the dialog, and closing either dialog by any + route wipes the typed file password or pasted URI and drops the parsed + entries (which carry seeds). + When adding can't start, one line under the buttons now says why: the + file is still loading, or the Molto2 isn't open or unlocked yet (with + an Authenticate button right there, and a failed attempt is reported in + that line too). ([#170]) diff --git a/changelog.d/fixed-170-oath-period-codes.md b/changelog.d/fixed-170-oath-period-codes.md new file mode 100644 index 00000000..450564ed --- /dev/null +++ b/changelog.d/fixed-170-oath-period-codes.md @@ -0,0 +1,4 @@ +- **Codes for OATH entries named `60/…` use their period.** `oath code` + (without `--period`) and the GUI computed them at 30 seconds, so they + were wrong. `--period` now defaults to the name's `period/` prefix, and + the GUI shows the period next to the code. ([#170]) diff --git a/changelog.d/fixed-170-oath-period-zero.md b/changelog.d/fixed-170-oath-period-zero.md new file mode 100644 index 00000000..348ba25a --- /dev/null +++ b/changelog.d/fixed-170-oath-period-zero.md @@ -0,0 +1,2 @@ +- **`oath code --period 0` is a usage error.** It crashed; it is now + refused with exit 2. ([#170]) diff --git a/crates/keyroost-import/src/bulk.rs b/crates/keyroost-import/src/bulk.rs index 3b08201e..e2ac71ca 100644 --- a/crates/keyroost-import/src/bulk.rs +++ b/crates/keyroost-import/src/bulk.rs @@ -6,6 +6,10 @@ use keyroost_proto::commands::{DisplayTimeout, HmacAlgo, OtpDigits, ProfileConfig, TimeStep}; use serde::Deserialize; +use crate::entry::{ + decode_secret, finish, hash_from_name, name_label, type_from_name, Format, ImportEntry, + ImportFile, ImportItem, OtpKind, TypeName, Unusable, MAX_ENTRIES, +}; use crate::otpauth::{parse as parse_otpauth, OtpAuth, OtpAuthError}; /// One normalized entry ready to be programmed into a Molto2 profile slot. @@ -46,16 +50,7 @@ impl Drop for BulkEntry { impl BulkEntry { pub fn suggested_title(&self) -> String { - let candidate = self - .issuer - .as_deref() - .or(self.account.as_deref()) - .unwrap_or(""); - let mut end = candidate.len().min(12); - while end > 0 && !candidate.is_char_boundary(end) { - end -= 1; - } - candidate[..end].to_owned() + crate::entry::molto_title(self.issuer.as_deref(), self.account.as_deref()) } pub fn to_profile_config( @@ -95,8 +90,17 @@ pub enum BulkError { Json(serde_json::Error), Encrypted(&'static str), UnsupportedFormat(&'static str), - EntryRejected { index: usize, reason: OtpAuthError }, + EntryRejected { + index: usize, + reason: OtpAuthError, + }, EmptyFile, + /// More than [`MAX_ENTRIES`] entries (wide + /// readers only): not an authenticator export. + TooManyEntries, + /// An encrypted Aegis vault that no password slot opened with the + /// password given. + WrongPassword, } impl core::fmt::Display for BulkError { @@ -109,12 +113,66 @@ impl core::fmt::Display for BulkError { write!(f, "entry #{}: {}", index, reason) } BulkError::EmptyFile => write!(f, "no entries found"), + BulkError::TooManyEntries => write!( + f, + "more than {} entries; this isn't an authenticator export", + crate::entry::MAX_ENTRIES + ), + // Same text the decryptor reported before this had its own + // variant, so CLI output is unchanged. + BulkError::WrongPassword => { + write!( + f, + "unsupported format: wrong password (slot did not decrypt)" + ) + } } } } impl std::error::Error for BulkError {} +impl BulkError { + /// A sentence for a person, naming `layout` (e.g. "an Aegis export") + /// when the file's JSON doesn't fit it. Never quotes a value from the + /// file: serde's own messages can (`invalid type: string "…"`), so a + /// JSON problem is told by position, keeping only a missing or repeated + /// field's name, which comes from keyroost's schema. + pub fn describe(&self, layout: &str) -> String { + use serde_json::error::Category; + let BulkError::Json(j) = self else { + let text = self.to_string(); + let mut c = text.chars(); + return match c.next() { + Some(first) => format!("{}{}.", first.to_uppercase(), c.as_str()), + None => String::new(), + }; + }; + let at = format!("line {}, column {}", j.line(), j.column()); + match j.classify() { + Category::Syntax | Category::Eof => format!("The file isn't valid JSON ({}).", at), + Category::Io => "The file couldn't be read.".to_owned(), + Category::Data => { + let text = j.to_string(); + let field = ["missing field `", "duplicate field `"] + .iter() + .find_map(|prefix| { + let name = text.strip_prefix(prefix)?.split('`').next()?; + let schema_like = !name.is_empty() + && name.len() <= 32 + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'); + schema_like.then(|| format!(": {}`{}`", prefix.trim_end_matches('`'), name)) + }) + .unwrap_or_default(); + format!( + "The file doesn't match the layout of {}{} ({}).", + layout, field, at + ) + } + } + } +} + impl From for BulkError { fn from(e: serde_json::Error) -> Self { BulkError::Json(e) @@ -126,9 +184,12 @@ impl From for BulkError { pub mod aegis { use super::*; + // The JSON shape is shared by the narrow (Molto2) and wide mappings; only + // the per-entry `info` differs (`EntryInfo` vs `CounterOnly`), so that the + // narrow path deserializes exactly the fields it always did. #[derive(Deserialize)] - struct Root { - db: DbField, + struct Root { + db: DbField, } /// The `db` field: a plaintext object (parsed straight into [`Db`]) or, for @@ -138,46 +199,47 @@ pub mod aegis { /// token type directly — the plaintext entries land in the zeroize-on-drop /// `Db`/`EntryInfo` without ever transiting a `serde_json::Value` tree /// (whose `String` seeds would otherwise drop unscrubbed). - enum DbField { - Plain(Db), + enum DbField { + Plain(Db), Encrypted, } - impl<'de> serde::Deserialize<'de> for DbField { + impl<'de, I: Deserialize<'de>> serde::Deserialize<'de> for DbField { fn deserialize>(d: D) -> Result { use serde::de::{self, MapAccess, Visitor}; - struct V; - impl<'de> Visitor<'de> for V { - type Value = DbField; + struct V(std::marker::PhantomData); + impl<'de, I: Deserialize<'de>> Visitor<'de> for V { + type Value = DbField; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("an Aegis db object or an encrypted base64 string") } - fn visit_str(self, _v: &str) -> Result { + fn visit_str(self, _v: &str) -> Result, E> { // The encrypted blob is not a secret and is not retained. Ok(DbField::Encrypted) } - fn visit_map>(self, map: A) -> Result { + fn visit_map>(self, map: A) -> Result, A::Error> { Db::deserialize(de::value::MapAccessDeserializer::new(map)).map(DbField::Plain) } } - d.deserialize_any(V) + d.deserialize_any(V(std::marker::PhantomData)) } } #[derive(Deserialize)] - struct Db { - entries: Vec, + struct Db { + entries: Vec>, } #[derive(Deserialize)] - struct Entry { + struct Entry { #[serde(rename = "type")] typ: String, name: Option, issuer: Option, - info: EntryInfo, + info: I, } + /// What the Molto2 path reads of an entry's `info`. #[derive(Deserialize)] struct EntryInfo { secret: String, @@ -195,25 +257,39 @@ pub mod aegis { } } - pub fn parse(json: &str) -> Result, BulkError> { - let root: Root = serde_json::from_str(json)?; - let db: Db = match root.db { - DbField::Plain(db) => db, + /// How many entries a plaintext export holds, counted without + /// materializing any of them; `None` when the shape isn't a plaintext + /// Aegis export (the full parse then reports why). + fn count_entries(json: &str) -> Option { + #[derive(Deserialize)] + struct P { + db: PDb, + } + #[derive(Deserialize)] + struct PDb { + entries: super::Count, + } + serde_json::from_str::

(json).ok().map(|p| p.db.entries.0) + } + + /// Read the plaintext `db`, or the encrypted-vault error. Shared by the + /// narrow (Molto2) and wide mappings. + fn read_db(json: &str) -> Result, BulkError> { + let root: Root = serde_json::from_str(json)?; + match root.db { + DbField::Plain(db) => Ok(db), DbField::Encrypted => { #[cfg(feature = "encrypted")] - { - return Err(BulkError::Encrypted( - "Aegis export is encrypted; supply a password (CLI: --password env:NAME or --password stdin)", - )); - } + let msg = "Aegis export is encrypted; supply a password (CLI: --file-password env:NAME or --file-password stdin)"; #[cfg(not(feature = "encrypted"))] - { - return Err(BulkError::Encrypted( - "Aegis export is encrypted; build with --features encrypted or re-export plaintext", - )); - } + let msg = "Aegis export is encrypted; build with --features encrypted or re-export plaintext"; + Err(BulkError::Encrypted(msg)) } - }; + } + } + + pub fn parse(json: &str) -> Result, BulkError> { + let db = read_db::(json)?; let mut out = Vec::with_capacity(db.entries.len()); for (i, e) in db.entries.into_iter().enumerate() { if !e.typ.eq_ignore_ascii_case("totp") { @@ -236,6 +312,52 @@ pub mod aegis { Ok(out) } + /// Every entry of a plaintext Aegis export, in file order, each either + /// usable by some key or marked with why not. Whole-file errors: bad + /// JSON, an encrypted vault (decrypt first), or no entries at all. + pub fn parse_wide(json: &str) -> Result { + if count_entries(json).is_some_and(|n| n > MAX_ENTRIES) { + return Err(BulkError::TooManyEntries); + } + // Two reads: the Molto2 path's fields exactly as it reads them, then + // each entry's `counter` on its own (see `CounterOnly`). + let db = read_db::(json)?; + let counters = read_db::(json)?; + let items: Vec = db + .entries + .into_iter() + .zip(counters.entries) + .enumerate() + .map(|(i, (e, c))| { + let label = name_label(e.issuer.as_deref(), e.name.as_deref()) + .unwrap_or_else(|| format!("entry {}", i + 1)); + let entry = wide_entry( + &e.typ, + e.issuer.clone(), + e.name.clone(), + &e.info.secret, + e.info.algo.as_deref(), + e.info.digits.unwrap_or(6), + e.info.period.unwrap_or(30), + c.info, + ); + ImportItem { + index: i, + label, + entry, + } + }) + .collect(); + if items.is_empty() { + return Err(BulkError::EmptyFile); + } + Ok(ImportFile { + format: Format::Aegis, + items, + batch: None, + }) + } + /// Detect an encrypted Aegis vault without parsing the whole structure. /// `Ok(true)` means an `encrypted` decrypt is needed; `Ok(false)` means /// the file is already plaintext. Cheap and side-effect-free: the `db` @@ -275,6 +397,34 @@ pub mod aegis { Ok(matches!(root.db, DbProbe::Encrypted)) } + /// Whether `json` has the full shape of an encrypted Aegis vault: a + /// `version`, a `header` with a `slots` array and a `params` object, and + /// a string `db`. Stricter than [`is_encrypted`], which only looks at + /// `db`, so an unrelated JSON file with a string `db` isn't taken for a + /// vault. Nothing is materialized. + pub(crate) fn has_vault_shape(json: &str) -> bool { + use serde::de::IgnoredAny; + /// Accepts any JSON object (fields ignored); refuses null and + /// non-objects. + #[derive(Deserialize)] + struct AnyObject {} + #[derive(Deserialize)] + struct Header { + #[allow(dead_code)] + slots: super::Count, + #[allow(dead_code)] + params: AnyObject, + } + #[derive(Deserialize)] + struct Vault { + #[allow(dead_code)] + version: IgnoredAny, + #[allow(dead_code)] + header: Header, + } + serde_json::from_str::(json).is_ok() && matches!(is_encrypted(json), Ok(true)) + } + /// Decrypt an Aegis encrypted vault and return the inner plaintext JSON, /// which can then be passed back to `parse()`. The returned buffer wipes /// itself on drop. Requires the `encrypted` crate feature. @@ -289,24 +439,27 @@ pub mod aegis { pub mod twofas { use super::*; + // As for Aegis, the shape is shared and only `otp` differs between the + // narrow (`Otp`) and counter (`CounterOnly`) reads. #[derive(Deserialize)] - struct Root { - #[serde(default)] - services: Vec, + #[serde(bound(deserialize = "O: Deserialize<'de> + Default"))] + struct Root { + #[serde(default = "Vec::new")] + services: Vec>, #[serde(rename = "servicesEncrypted", default)] services_encrypted: Option, } #[derive(Deserialize)] - struct Service { + struct Service { name: Option, secret: String, #[serde(default)] - otp: Otp, + otp: O, } // Same hygiene as the Aegis `EntryInfo`: wipe the serde-owned seed copy. - impl Drop for Service { + impl Drop for Service { fn drop(&mut self) { use zeroize::Zeroize; self.secret.zeroize(); @@ -324,13 +477,68 @@ pub mod twofas { token_type: Option, } - pub fn parse(json: &str) -> Result, BulkError> { - let root: Root = serde_json::from_str(json)?; + /// Whether `json` looks like a 2FAS export, without materializing any + /// service (or seed): `Some(true)` for an encrypted export (the + /// `servicesEncrypted` blob and no plaintext services), `Some(false)` + /// for a plaintext one, `None` when it is neither (or not a JSON object). + pub(crate) fn probe(json: &str) -> Option { + use serde::de::IgnoredAny; + #[derive(Deserialize)] + struct ProbeRoot { + services: Option, + #[serde(rename = "servicesEncrypted")] + services_encrypted: Option, + } + let root: ProbeRoot = serde_json::from_str(json).ok()?; + let services = root.services.map(|c| c.0); + match (services, root.services_encrypted.is_some()) { + (None, false) => None, + (Some(n), true) if n > 0 => Some(false), + (_, true) => Some(true), + (Some(_), false) => Some(false), + } + } + + /// How many services the export lists, counted without materializing + /// any; `None` when it has no `services` array. + fn count_services(json: &str) -> Option { + #[derive(Deserialize)] + struct P { + services: super::Count, + } + serde_json::from_str::

(json).ok().map(|p| p.services.0) + } + + /// The counter pass over `services`: each service's `otp` counter and + /// nothing else — the seed (`secret`) and every other key are skipped + /// unread, so no second copy of any seed is made. + #[derive(Deserialize)] + struct CounterRoot { + #[serde(default = "Vec::new")] + services: Vec, + } + + #[derive(Deserialize)] + struct CounterService { + #[serde(default)] + otp: super::CounterOnly, + } + + /// Read the plaintext `services`, or the encrypted-export error. + fn read_root( + json: &str, + ) -> Result, BulkError> { + let root: Root = serde_json::from_str(json)?; if root.services_encrypted.is_some() && root.services.is_empty() { return Err(BulkError::Encrypted( "2FAS export is encrypted; re-export without a password", )); } + Ok(root) + } + + pub fn parse(json: &str) -> Result, BulkError> { + let root = read_root::(json)?; let mut out = Vec::with_capacity(root.services.len()); for (i, mut s) in root.services.into_iter().enumerate() { // 2FAS includes Steam etc; skip non-TOTP. @@ -357,6 +565,78 @@ pub mod twofas { } Ok(out) } + + /// Every service of a plaintext 2FAS export, in file order (see + /// [`aegis::parse_wide`] for the contract). + pub fn parse_wide(json: &str) -> Result { + if count_services(json).is_some_and(|n| n > MAX_ENTRIES) { + return Err(BulkError::TooManyEntries); + } + // As for Aegis: the Molto2 path's fields, then the counters alone. + let root = read_root::(json)?; + let counters: CounterRoot = serde_json::from_str(json)?; + let items: Vec = root + .services + .iter() + .zip(&counters.services) + .enumerate() + .map(|(i, (s, c))| { + // Same issuer rule as the narrow mapping: the otp issuer, + // else the service name. + let issuer = s.otp.issuer.clone().or_else(|| s.name.clone()); + let label = name_label(issuer.as_deref(), s.otp.account.as_deref()) + .unwrap_or_else(|| format!("entry {}", i + 1)); + let entry = wide_entry( + s.otp.token_type.as_deref().unwrap_or("TOTP"), + issuer, + s.otp.account.clone(), + &s.secret, + s.otp.algorithm.as_deref(), + s.otp.digits.unwrap_or(6), + s.otp.period.unwrap_or(30), + c.otp, + ); + ImportItem { + index: i, + label, + entry, + } + }) + .collect(); + if items.is_empty() { + return Err(BulkError::EmptyFile); + } + Ok(ImportFile { + format: Format::TwoFas, + items, + batch: None, + }) + } +} + +/// Number of elements in a JSON array, drained without allocating (the +/// elements are skipped with `IgnoredAny`; nothing is materialized). +pub(crate) struct Count(pub(crate) usize); + +impl<'de> Deserialize<'de> for Count { + fn deserialize>(d: D) -> Result { + use serde::de::{IgnoredAny, SeqAccess, Visitor}; + struct V; + impl<'de> Visitor<'de> for V { + type Value = Count; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a JSON array") + } + fn visit_seq>(self, mut a: A) -> Result { + let mut n = 0; + while a.next_element::()?.is_some() { + n += 1; + } + Ok(Count(n)) + } + } + d.deserialize_seq(V) + } } /// One otpauth:// URI per line; blank lines and `#` comments allowed. Useful @@ -378,6 +658,259 @@ pub fn parse_otpauth_list(text: &str) -> Result, BulkError> { Ok(out) } +/// One entry per line, like [`parse_otpauth_list`], but nothing a line +/// holds fails the file: each non-blank, non-`#` line becomes an item (index +/// = line index), unusable lines carrying their reason. A Google +/// Authenticator `otpauth-migration://` line contributes all of its entries +/// under that line's index. Whole-file errors: an empty list, or more than +/// [`MAX_ENTRIES`] entries. +pub fn parse_list_wide(text: &str) -> Result { + let mut items = Vec::new(); + let mut batch = None; + for (i, raw) in text.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // Refuse before building the next item, so a giant file never + // grows the list past the cap. + if items.len() >= MAX_ENTRIES { + return Err(BulkError::TooManyEntries); + } + if crate::migration::is_migration_uri(line) { + match crate::migration::parse_wide(line) { + Err(e @ BulkError::TooManyEntries) => return Err(e), + Ok(file) => { + if items.len() + file.items.len() > MAX_ENTRIES { + return Err(BulkError::TooManyEntries); + } + batch = batch.or(file.batch); + items.extend(file.items.into_iter().map(|mut item| { + item.index = i; + item + })); + } + Err(_) => items.push(ImportItem { + index: i, + label: format!("line {}", i + 1), + entry: Err(Unusable::Malformed( + "the Google Authenticator export couldn't be read", + )), + }), + } + continue; + } + let (name, entry) = crate::otpauth::parse_entry_labeled(line); + items.push(ImportItem { + index: i, + label: name.unwrap_or_else(|| format!("line {}", i + 1)), + entry, + }); + } + if items.is_empty() { + return Err(BulkError::EmptyFile); + } + Ok(ImportFile { + format: Format::OtpauthList, + items, + batch, + }) +} + +/// The wide counterpart of [`parse_any`]: JSON goes to Aegis (an encrypted +/// vault is reported as such), then 2FAS; anything else is read as a list. +pub fn parse_any_wide(text: &str) -> Result { + if text.trim_start().starts_with('{') { + match aegis::parse_wide(text) { + Ok(f) => return Ok(f), + Err(e @ BulkError::Encrypted(_)) => return Err(e), + Err(_) => {} + } + return twofas::parse_wide(text); + } + parse_list_wide(text) +} + +/// Map one JSON-export entry to the wide model. Checks run type, secret, +/// hash, digits, period — the Molto2 path's order with the type first. +#[allow(clippy::too_many_arguments)] +fn wide_entry( + typ: &str, + issuer: Option, + account: Option, + secret_b32: &str, + algo: Option<&str>, + digits: u32, + period: u32, + counter: CounterOnly, +) -> Result { + let kind = type_from_name(typ)?; + let secret = decode_secret(secret_b32)?; + let hash = hash_from_name(algo.unwrap_or("SHA1"))?; + let kind = match kind { + TypeName::Totp => OtpKind::Totp { period }, + TypeName::Hotp if counter.repeated => { + return Err(Unusable::Malformed("the counter is given more than once")) + } + TypeName::Hotp => OtpKind::Hotp { + counter: counter.value.unwrap_or(0), + }, + }; + finish(issuer, account, secret, hash, digits, kind) +} + +/// The `counter` of an Aegis `info` / 2FAS `otp` object, read on its own. +/// +/// The wide readers read the other fields through the Molto2 path's own +/// structs, then this in a second pass, so a file the Molto2 path takes is +/// never refused for its counter: a `counter` key the Molto2 path ignores +/// (repeated, or of an odd shape) can't fail the file here. Every other key +/// is skipped unread — the seed included — and a value that isn't an object +/// reads as no counter. +#[derive(Default, Clone, Copy)] +struct CounterOnly { + /// The last well-formed counter (lenient: an odd shape reads as absent). + value: Option, + /// `counter` appeared more than once: a HOTP entry is then unusable. + repeated: bool, +} + +impl<'de> serde::Deserialize<'de> for CounterOnly { + fn deserialize>(d: D) -> Result { + use serde::de::{IgnoredAny, MapAccess, SeqAccess, Visitor}; + /// A key, compared without keeping it. + struct IsCounter(bool); + impl<'de> serde::Deserialize<'de> for IsCounter { + fn deserialize>(d: D) -> Result { + struct K; + impl Visitor<'_> for K { + type Value = IsCounter; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a key") + } + fn visit_str(self, v: &str) -> Result { + Ok(IsCounter(v == "counter")) + } + } + d.deserialize_str(K) + } + } + struct Lenient(Option); + impl<'de> serde::Deserialize<'de> for Lenient { + fn deserialize>(d: D) -> Result { + lenient_u64(d).map(Lenient) + } + } + struct V; + impl<'de> Visitor<'de> for V { + type Value = CounterOnly; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("any JSON value") + } + fn visit_map>(self, mut m: A) -> Result { + let mut out = CounterOnly::default(); + let mut seen = false; + while let Some(IsCounter(is)) = m.next_key()? { + if is { + out.repeated |= seen; + seen = true; + out.value = m.next_value::()?.0; + } else { + m.next_value::()?; + } + } + Ok(out) + } + fn visit_seq>(self, mut a: A) -> Result { + while a.next_element::()?.is_some() {} + Ok(CounterOnly::default()) + } + fn visit_bool(self, _: bool) -> Result { + Ok(CounterOnly::default()) + } + fn visit_i64(self, _: i64) -> Result { + Ok(CounterOnly::default()) + } + fn visit_u64(self, _: u64) -> Result { + Ok(CounterOnly::default()) + } + fn visit_f64(self, _: f64) -> Result { + Ok(CounterOnly::default()) + } + fn visit_str(self, _: &str) -> Result { + Ok(CounterOnly::default()) + } + fn visit_unit(self) -> Result { + Ok(CounterOnly::default()) + } + fn visit_none(self) -> Result { + Ok(CounterOnly::default()) + } + fn visit_some>( + self, + d: D, + ) -> Result { + d.deserialize_any(self) + } + } + d.deserialize_any(V) + } +} + +/// `Option` from any JSON value: a non-negative integer becomes `Some`, +/// anything else (string, float, null, ...) `None` — never an error. +fn lenient_u64<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Option; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("any JSON value") + } + fn visit_u64(self, v: u64) -> Result { + Ok(Some(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(u64::try_from(v).ok()) + } + fn visit_f64(self, _: f64) -> Result { + Ok(None) + } + fn visit_bool(self, _: bool) -> Result { + Ok(None) + } + fn visit_str(self, _: &str) -> Result { + Ok(None) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(self) + } + fn visit_seq>( + self, + mut a: A, + ) -> Result { + while a.next_element::()?.is_some() {} + Ok(None) + } + fn visit_map>( + self, + mut a: A, + ) -> Result { + while a + .next_entry::()? + .is_some() + {} + Ok(None) + } + } + d.deserialize_any(V) +} + /// Try each known format on `bytes` and return the first that parses. pub fn parse_any(bytes: &str) -> Result, BulkError> { if bytes.trim_start().starts_with('{') { @@ -562,4 +1095,327 @@ mod tests { assert_eq!(parse_any(twofas_like).unwrap().len(), 1); assert_eq!(parse_any(list).unwrap().len(), 1); } + + // ---- wide parsers ---------------------------------------------------- + + use crate::entry::HashAlg; + + /// The same corpus as `tests/molto_golden.rs`, in the same order. + const WIDE_AEGIS: &str = r#"{"version":1,"header":{"slots":null,"params":null},"db":{"version":2,"entries":[ + {"type":"totp","name":"alice@example.com","issuer":"GitHub","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA1","digits":6,"period":30}}, + {"type":"hotp","name":"counter","issuer":"Bank","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA1","digits":6,"counter":5}}, + {"type":"steam","name":"gamer","issuer":"Steam","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA1","digits":5,"period":30}}, + {"type":"totp","name":"seven","issuer":"Odd","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA1","digits":7,"period":30}}, + {"type":"totp","name":"slow","issuer":"Slow","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA1","digits":6,"period":45}}, + {"type":"totp","name":"wide","issuer":"Wide","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA512","digits":6,"period":30}}, + {"type":"totp","name":"bad","issuer":"Bad","info":{"secret":"NOT_BASE32!!","algo":"SHA1","digits":6,"period":30}}, + {"type":"totp","name":"long","issuer":"Long","info":{"secret":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","algo":"SHA1","digits":6,"period":30}}, + {"type":"totp","name":"bob","issuer":"","info":{"secret":"JBSWY3DPEHPK3PXP","algo":"SHA256","digits":8,"period":60}} + ]}}"#; + + /// `(index, label, kind or unusable reason)` per item, for compact asserts. + fn shape(file: &crate::entry::ImportFile) -> Vec<(usize, String, Result)> { + file.items + .iter() + .map(|i| { + ( + i.index, + i.label.clone(), + i.entry.as_ref().map(|e| e.kind).map_err(Clone::clone), + ) + }) + .collect() + } + + #[test] + fn aegis_wide_keeps_every_entry_in_order() { + let f = aegis::parse_wide(WIDE_AEGIS).unwrap(); + assert_eq!(f.format, Format::Aegis); + assert_eq!(f.batch, None); + let totp30 = Ok(OtpKind::Totp { period: 30 }); + assert_eq!( + shape(&f), + vec![ + (0, "GitHub:alice@example.com".into(), totp30.clone()), + (1, "Bank:counter".into(), Ok(OtpKind::Hotp { counter: 5 })), + (2, "Steam:gamer".into(), Err(Unusable::Type("steam".into()))), + (3, "Odd:seven".into(), totp30.clone()), + (4, "Slow:slow".into(), Ok(OtpKind::Totp { period: 45 })), + (5, "Wide:wide".into(), totp30.clone()), + (6, "Bad:bad".into(), Err(Unusable::BadSecret)), + (7, "Long:long".into(), totp30), + (8, "bob".into(), Ok(OtpKind::Totp { period: 60 })), + ] + ); + let e = |i: usize| f.items[i].entry.as_ref().unwrap(); + assert_eq!(e(3).digits, 7); + assert_eq!(e(5).hash, HashAlg::Sha512); + assert_eq!(e(7).secret.len(), 64); + assert_eq!(e(8).issuer, None); + assert_eq!(e(8).hash, HashAlg::Sha256); + assert_eq!(e(8).digits, 8); + assert_eq!(f.usable(), 7); + } + + #[test] + fn aegis_wide_file_errors() { + assert!(matches!( + aegis::parse_wide(r#"{"db":"ZW5jcnlwdGVk","header":{}}"#), + Err(BulkError::Encrypted(_)) + )); + assert!(matches!( + aegis::parse_wide(r#"{"db":{"entries":[]}}"#), + Err(BulkError::EmptyFile) + )); + assert!(matches!(aegis::parse_wide("{"), Err(BulkError::Json(_)))); + // A file of only unusable entries is still a file of items. + let f = aegis::parse_wide( + r#"{"db":{"entries":[{"type":"motp","name":"","issuer":"","info":{"secret":"JBSWY3DP"}}]}}"#, + ) + .unwrap(); + assert_eq!(f.usable(), 0); + assert_eq!( + shape(&f), + vec![(0, "entry 1".into(), Err(Unusable::Type("motp".into())))] + ); + } + + #[test] + fn twofas_wide_reads_hotp_counter() { + let json = r#"{"services":[ + {"name":"Bank","secret":"JBSWY3DPEHPK3PXP","otp":{"account":"me","issuer":"Bank","digits":6,"counter":3,"algorithm":"SHA1","tokenType":"HOTP"}}, + {"name":"Mail","secret":"JBSWY3DPEHPK3PXP","otp":{"account":"you","period":20,"algorithm":"SHA512"}}, + {"name":"Steam","secret":"JBSWY3DPEHPK3PXP","otp":{"tokenType":"STEAM"}} + ]}"#; + let f = twofas::parse_wide(json).unwrap(); + assert_eq!(f.format, Format::TwoFas); + assert_eq!( + shape(&f), + vec![ + (0, "Bank:me".into(), Ok(OtpKind::Hotp { counter: 3 })), + (1, "Mail:you".into(), Ok(OtpKind::Totp { period: 20 })), + (2, "Steam".into(), Err(Unusable::Type("STEAM".into()))), + ] + ); + assert_eq!(f.items[1].entry.as_ref().unwrap().hash, HashAlg::Sha512); + assert!(matches!( + twofas::parse_wide(r#"{"servicesEncrypted":"x","services":[]}"#), + Err(BulkError::Encrypted(_)) + )); + assert!(matches!( + twofas::parse_wide(r#"{"services":[]}"#), + Err(BulkError::EmptyFile) + )); + } + + #[test] + fn list_wide_line_errors_are_items_not_file_errors() { + let text = "# header\n\notpauth://totp/A:x?secret=JBSWY3DPEHPK3PXP&period=45\n \notpauth://hotp/B?secret=JBSWY3DPEHPK3PXP&counter=2\nhello there\notpauth://totp/C:y?secret=JBSWY3DP&digits=11\n"; + let f = parse_list_wide(text).unwrap(); + assert_eq!(f.format, Format::OtpauthList); + assert_eq!( + shape(&f), + vec![ + (2, "A:x".into(), Ok(OtpKind::Totp { period: 45 })), + (4, "B".into(), Ok(OtpKind::Hotp { counter: 2 })), + ( + 5, + "line 6".into(), + Err(Unusable::Malformed("not an otpauth:// URI")) + ), + (6, "C:y".into(), Err(Unusable::Digits(11))), + ] + ); + assert!(matches!( + parse_list_wide("# nothing\n\n"), + Err(BulkError::EmptyFile) + )); + } + + #[test] + fn list_wide_accepts_migration_lines() { + let migration = crate::migration::tests::uri_for(&crate::migration::tests::payload(&[ + crate::migration::tests::otp_params(b"0123456789", "Acme:alice", "Acme", 1, 1, 2), + crate::migration::tests::otp_params(b"counterkey", "Legacy", "", 1, 1, 1), + ])); + let text = format!( + "otpauth://totp/A?secret=JBSWY3DPEHPK3PXP\n{}\notpauth-migration://offline?data=!!!\n", + migration + ); + let f = parse_list_wide(&text).unwrap(); + assert_eq!( + shape(&f), + vec![ + (0, "A".into(), Ok(OtpKind::Totp { period: 30 })), + (1, "Acme:alice".into(), Ok(OtpKind::Totp { period: 30 })), + (1, "Legacy".into(), Ok(OtpKind::Hotp { counter: 0 })), + ( + 2, + "line 3".into(), + Err(Unusable::Malformed( + "the Google Authenticator export couldn't be read" + )) + ), + ] + ); + } + + #[test] + fn parse_any_wide_routes_by_shape() { + let aegis_like = r#"{"db": {"entries":[{"type":"hotp","name":"a","issuer":"X","info":{"secret":"JBSWY3DP","counter":1}}]}}"#; + let twofas_like = r#"{"services":[{"name":"X","secret":"JBSWY3DP","otp":{}}]}"#; + assert_eq!(parse_any_wide(aegis_like).unwrap().format, Format::Aegis); + assert_eq!(parse_any_wide(twofas_like).unwrap().format, Format::TwoFas); + assert_eq!( + parse_any_wide("otpauth://hotp/X?secret=JBSWY3DP") + .unwrap() + .format, + Format::OtpauthList + ); + assert!(matches!( + parse_any_wide(r#"{"db":"ZW5j"}"#), + Err(BulkError::Encrypted(_)) + )); + } + + #[test] + fn describe_never_quotes_file_values() { + let err = |json: &str| { + serde_json::from_str::>(json) + .map(|_| ()) + .unwrap_err() + }; + let syntax = BulkError::Json(err(r#"[1, "SENTINEL"#)).describe("a list"); + assert!(!syntax.contains("SENTINEL"), "{syntax}"); + assert!( + syntax.starts_with("The file isn't valid JSON (line 1, column"), + "{syntax}" + ); + let data = BulkError::Json(err(r#"[1, "SENTINEL"]"#)).describe("a list"); + assert!(!data.contains("SENTINEL"), "{data}"); + assert_eq!( + data, + "The file doesn't match the layout of a list (line 1, column 14)." + ); + assert_eq!( + BulkError::TooManyEntries.describe("x"), + "More than 1000 entries; this isn't an authenticator export." + ); + } + + #[test] + fn wide_readers_refuse_more_than_max_entries() { + use crate::entry::MAX_ENTRIES; + // A list: one otpauth line makes the file a list; millions of junk + // lines after it must stop at the cap, not become millions of items. + let mut huge = String::from("otpauth://totp/A?secret=JBSWY3DP\n"); + huge.push_str(&"x\n".repeat(5_000_000)); + assert!(matches!( + parse_list_wide(&huge), + Err(BulkError::TooManyEntries) + )); + assert!(matches!( + parse_any_wide(&huge), + Err(BulkError::TooManyEntries) + )); + // Exactly at the cap is fine; one more is refused. + let line = "otpauth://totp/A?secret=JBSWY3DP\n"; + assert_eq!( + parse_list_wide(&line.repeat(MAX_ENTRIES)) + .unwrap() + .items + .len(), + MAX_ENTRIES + ); + assert!(matches!( + parse_list_wide(&line.repeat(MAX_ENTRIES + 1)), + Err(BulkError::TooManyEntries) + )); + // Migration lines count every entry they carry. + let one = crate::migration::tests::otp_params(b"k", "a", "", 1, 1, 2); + let six_hundred = + crate::migration::tests::uri_for(&crate::migration::tests::payload(&vec![one; 600])); + assert!(matches!( + parse_list_wide(&format!("{six_hundred}\n{six_hundred}\n")), + Err(BulkError::TooManyEntries) + )); + + // JSON: counted before any entry is built. + let aegis_entry = r#"{"type":"totp","name":"a","issuer":"","info":{"secret":"JBSWY3DP"}}"#; + let aegis = |n: usize| { + format!( + r#"{{"db":{{"entries":[{}]}}}}"#, + vec![aegis_entry; n].join(",") + ) + }; + assert_eq!( + aegis::parse_wide(&aegis(MAX_ENTRIES)).unwrap().items.len(), + MAX_ENTRIES + ); + assert!(matches!( + aegis::parse_wide(&aegis(MAX_ENTRIES + 1)), + Err(BulkError::TooManyEntries) + )); + let service = r#"{"name":"X","secret":"JBSWY3DP"}"#; + let twofas = |n: usize| format!(r#"{{"services":[{}]}}"#, vec![service; n].join(",")); + assert_eq!( + twofas::parse_wide(&twofas(MAX_ENTRIES)) + .unwrap() + .items + .len(), + MAX_ENTRIES + ); + assert!(matches!( + twofas::parse_wide(&twofas(MAX_ENTRIES + 1)), + Err(BulkError::TooManyEntries) + )); + assert_eq!( + BulkError::TooManyEntries.to_string(), + "more than 1000 entries; this isn't an authenticator export" + ); + } + + #[test] + fn wide_readers_read_the_counter_only_for_hotp() { + let kinds = |f: ImportFile| -> Vec> { + f.items + .into_iter() + .map(|i| i.entry.map(|e| e.kind)) + .collect() + }; + // A repeated or odd counter on a TOTP entry is ignored, as the Molto2 + // path ignores it; on a HOTP entry a repeated one is unusable. + let aegis = r#"{"db":{"entries":[ + {"type":"totp","name":"a","issuer":"X","info":{"secret":"JBSWY3DP","counter":1,"counter":2}}, + {"type":"hotp","name":"b","issuer":"X","info":{"secret":"JBSWY3DP","counter":1,"counter":2}}, + {"type":"hotp","name":"c","issuer":"X","info":{"counter":"x","secret":"JBSWY3DP"}}]}}"#; + assert_eq!( + kinds(aegis::parse_wide(aegis).unwrap()), + vec![ + Ok(OtpKind::Totp { period: 30 }), + Err(Unusable::Malformed("the counter is given more than once")), + Ok(OtpKind::Hotp { counter: 0 }), + ] + ); + let twofas = r#"{"services":[ + {"name":"A","secret":"JBSWY3DP","otp":{"account":"a","counter":1,"counter":2,"tokenType":"TOTP"}}, + {"name":"B","secret":"JBSWY3DP","otp":{"account":"b","counter":1,"counter":2,"tokenType":"HOTP"}}, + {"name":"C","secret":"JBSWY3DP","otp":{"account":"c","counter":7,"tokenType":"HOTP"}}]}"#; + assert_eq!( + kinds(twofas::parse_wide(twofas).unwrap()), + vec![ + Ok(OtpKind::Totp { period: 30 }), + Err(Unusable::Malformed("the counter is given more than once")), + Ok(OtpKind::Hotp { counter: 7 }), + ] + ); + let list = "otpauth://totp/A?secret=JBSWY3DP&counter=zz\notpauth://hotp/B?secret=JBSWY3DP&counter=zz\n"; + assert_eq!( + kinds(parse_list_wide(list).unwrap()), + vec![ + Ok(OtpKind::Totp { period: 30 }), + Err(Unusable::Malformed("the counter value isn't a number")), + ] + ); + } } diff --git a/crates/keyroost-import/src/classify.rs b/crates/keyroost-import/src/classify.rs new file mode 100644 index 00000000..c60133fd --- /dev/null +++ b/crates/keyroost-import/src/classify.rs @@ -0,0 +1,456 @@ +//! Sorting a dropped or opened file before anything reads it as an import: +//! an authenticator export, recognized key material (a certificate, a key, +//! ...) that belongs somewhere else, or something keyroost doesn't read. +//! +//! Classification looks only at the bytes it is given (plus, for binary +//! DER and OpenPGP files, the file name's extension). It never decrypts a +//! vault or decodes an image, and its JSON probes never materialize an entry +//! or a seed. The JSON probes need the `bulk` feature; without it every JSON +//! file is [`FileKind::Unknown`]. + +/// An authenticator export keyroost can read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFormat { + Aegis, + AegisEncrypted, + TwoFas, + TwoFasEncrypted, + OtpauthList, + /// A PNG or JPEG image (a QR screenshot, maybe). + Image, +} + +/// Key material keyroost recognizes but doesn't import as authenticator +/// codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum KeyMaterial { + Certificate, + CertificateRequest, + PrivateKey, + OpenPgpKey, + SshKey, + Pkcs12, +} + +/// What a file is, as far as importing goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileKind { + Authenticator(AuthFormat), + KeyMaterial(KeyMaterial), + Unknown, +} + +/// Shown for a file that is neither an authenticator export nor recognized +/// key material. +pub const UNKNOWN_FILE: &str = + "keyroost can't import this file. It reads Aegis, 2FAS and otpauth:// list exports and QR screenshots."; + +impl KeyMaterial { + /// "a certificate", "an OpenPGP key", ... + pub fn what(self) -> &'static str { + match self { + KeyMaterial::Certificate => "a certificate", + KeyMaterial::CertificateRequest => "a certificate request", + KeyMaterial::PrivateKey => "a private key", + KeyMaterial::OpenPgpKey => "an OpenPGP key", + KeyMaterial::SshKey => "an SSH key", + KeyMaterial::Pkcs12 => "a PKCS #12 bundle", + } + } + + /// Where keyroost imports it, or that it doesn't, in GUI words. Each ends + /// "Nothing was changed." + pub fn gui_pointer(self) -> &'static str { + match self { + KeyMaterial::Certificate => { + "This looks like a certificate. To put it on a key, open the key's PIV tab \ + and use Import certificate. Nothing was changed." + } + KeyMaterial::PrivateKey => { + "This looks like a private key. keyroost imports RSA-2048 private keys in a \ + key's OpenPGP tab (Import key from file). Nothing was changed." + } + KeyMaterial::CertificateRequest => { + "This looks like a certificate request. keyroost can't import this kind of \ + file. Nothing was changed." + } + KeyMaterial::OpenPgpKey => { + "This looks like an OpenPGP key. keyroost can't import this kind of file. \ + Nothing was changed." + } + KeyMaterial::SshKey => { + "This looks like an SSH key. keyroost can't import this kind of file. \ + Nothing was changed." + } + KeyMaterial::Pkcs12 => { + "This looks like a PKCS #12 bundle. keyroost can't import this kind of file. \ + Nothing was changed." + } + } + } + + /// The same in CLI words. + pub fn cli_pointer(self) -> &'static str { + match self { + KeyMaterial::Certificate => { + "This looks like a certificate. To put it on a key, use: \ + keyroostctl -d KEY piv cert import --slot SLOT --in FILE. Nothing was changed." + } + KeyMaterial::PrivateKey => { + "This looks like a private key. keyroost imports RSA-2048 private keys with: \ + keyroostctl -d KEY openpgp key import --in FILE. Nothing was changed." + } + // No command imports these, so the wording is the GUI's. + other => other.gui_pointer(), + } + } +} + +/// Sort a file by content (and, for binary DER, its extension). Never +/// decrypts or decodes images; reads at most the bytes given. +pub fn classify(bytes: &[u8], file_name: Option<&str>) -> FileKind { + // 1. Images, by magic bytes. + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + return FileKind::Authenticator(AuthFormat::Image); + } + + let text = core::str::from_utf8(bytes).ok(); + if let Some(text) = text { + // 2. JSON: an Aegis or 2FAS export, or nothing keyroost reads. + if text.trim_start().starts_with('{') { + return classify_json(text); + } + // 3. Any otpauth line makes it a list (bad lines are reported per + // line by the list parser). + if text + .lines() + .map(str::trim_start) + .any(|l| l.starts_with("otpauth://") || l.starts_with("otpauth-migration://")) + { + return FileKind::Authenticator(AuthFormat::OtpauthList); + } + } + + // 4. PEM armor, by label. + if let Some(kind) = pem_kind(bytes) { + return FileKind::KeyMaterial(kind); + } + + // 5. An OpenSSH public key line. + let first_line = bytes + .split(|&b| b == b'\n') + .next() + .unwrap_or_default() + .trim_ascii_start(); + const SSH_PREFIXES: [&[u8]; 5] = [ + b"ssh-ed25519 ", + b"ssh-rsa ", + b"ecdsa-sha2-", + b"sk-ssh-", + b"sk-ecdsa-", + ]; + if SSH_PREFIXES.iter().any(|p| first_line.starts_with(p)) { + return FileKind::KeyMaterial(KeyMaterial::SshKey); + } + + let ext = extension(file_name); + // Binary: not UTF-8, or carrying control bytes no text file has (DER + // always does: its INTEGER/OCTET STRING/OID tags are 0x02/0x04/0x06). + let binary = text.is_none() || bytes.iter().any(|&b| b < 0x09 || (0x0e..0x20).contains(&b)); + // 6. DER (an ASN.1 SEQUENCE), named by its extension. + if binary && bytes.first() == Some(&0x30) { + let kind = match ext.as_deref() { + Some("cer" | "crt" | "der") => Some(KeyMaterial::Certificate), + Some("p12" | "pfx") => Some(KeyMaterial::Pkcs12), + Some("key") => Some(KeyMaterial::PrivateKey), + Some("csr") => Some(KeyMaterial::CertificateRequest), + _ => None, + }; + if let Some(kind) = kind { + return FileKind::KeyMaterial(kind); + } + } + + // 7. Binary OpenPGP. + if binary && matches!(ext.as_deref(), Some("gpg" | "pgp")) { + return FileKind::KeyMaterial(KeyMaterial::OpenPgpKey); + } + + FileKind::Unknown +} + +#[cfg(feature = "bulk")] +fn classify_json(text: &str) -> FileKind { + match crate::bulk::aegis::is_encrypted(text) { + // A string `db` is a vault only with Aegis's header around it. + Ok(true) if crate::bulk::aegis::has_vault_shape(text) => { + return FileKind::Authenticator(AuthFormat::AegisEncrypted) + } + Ok(true) => return FileKind::Unknown, + Ok(false) => return FileKind::Authenticator(AuthFormat::Aegis), + Err(_) => {} + } + match crate::bulk::twofas::probe(text) { + Some(true) => FileKind::Authenticator(AuthFormat::TwoFasEncrypted), + Some(false) => FileKind::Authenticator(AuthFormat::TwoFas), + None => FileKind::Unknown, + } +} + +#[cfg(not(feature = "bulk"))] +fn classify_json(_text: &str) -> FileKind { + FileKind::Unknown +} + +/// The first recognized `-----BEGIN

Hardware-verified devices

- - + @@ -79,7 +80,7 @@

Hardware-verified devices

key; writes the seed, HMAC algorithm, period and display timeout. The model is recognized from the device serial. - + - + - + - + + force which. A value that matches several keys is refused. A + Molto2 answers to either serial keyroost shows for it: the one in + keyroostctl list or the one molto info + prints. @@ -334,9 +343,15 @@

CLI: secrets

+ + + stdin (the key's password; an export file's password is + --file-password) @@ -566,7 +581,7 @@

CLI: command and flag names

- + @@ -588,7 +603,8 @@

CLI: command and flag names

values) + 10 (or -i export.json; an encrypted export + adds --file-password env:VAR) @@ -682,6 +698,58 @@

CLI: command and flag names

OTP applet like piv info does.
DeviceCapabilitiesNotes
Token2 Molto2 / Molto2v2TOTP slot programming, bulk importProgrammed over the vendor-specific SM4-MAC protocol; bulk import from - Aegis / 2FAS / otpauth lists, clock sync, customer-key rotation. See + TOTP slot programming, code importProgrammed over the vendor-specific SM4-MAC protocol; imports Aegis / + 2FAS / otpauth-list exports and QR screenshots, clock sync, customer-key + rotation. See the Molto2 page.
Token2 single-profile tokens (OTPC-P1-i / P2-i, miniOTP-2-i / 3-i, C301-i, C302-i)
Token2 PIN+ SeriesFIDO2 (+ bio), OTP, OpenPGP, PIVFIDO2 (+ bio), OTP (incl. import), OpenPGP, PIV FIDO2 with fingerprint enrollment and MDS display, plus on-device OTP (TOTP/HOTP) over CCID / NFC / USB-HID — validated on PIN+ hardware, contributed by Token2. HID-HOTP applies to the models that carry it; recent @@ -90,16 +91,16 @@

Hardware-verified devices

OpenPGP / PIV applets are handled by the standard byte layers but not yet exercised on PIN+ hardware by this project (experimental).
YubiKey (5 series)FIDO2, OATH, OpenPGP, PIVFIDO2, OATH (incl. import), OpenPGP, PIV Built and verified against a YubiKey 5.7.
SoloKeys Solo 2FIDO2, OATHFIDO2, OATH (incl. import) Trussed firmware; no OpenPGP applet. HOTP caveat: the last-shipped Solo 2 firmware (2.3.x) computes HOTP over a 4-byte counter where RFC 4226 specifies 8, so its HOTP codes won't verify against standards-compliant servers. TOTP is unaffected.
Nitrokey 3FIDO2, OATH, PIV; OpenPGP detectedFIDO2, OATH (incl. import), PIV; OpenPGP detected Built around the same Trussed firmware core as Solo 2, but the final firmware is different — e.g. Nitrokey 3 has PIV support, while Solo 2 does not. PIV verified on a Nitrokey 3A NFC (firmware 1.8.3), diff --git a/docs/migration.html b/docs/migration.html index 57e6f528..873f7837 100644 --- a/docs/migration.html +++ b/docs/migration.html @@ -84,6 +84,10 @@

v0.13.0

command and flag names below). A key can now carry its own name, and keys.json no longer holds serials (see names below). + Authenticator exports can now be imported into OATH keys and Token2 + OTP storage as well as a Molto2, and molto import --in + follows the same rules (see importing + codes below). Library consumers: keyroost-resolve drops its HID-only name resolver for the shared device model, and keyroost-ctap, keyroost-keyring and @@ -164,12 +168,17 @@

CLI

--device took a friendly name A name, a serial or a list number. Prefix name:, serial: or list: to - force which. A value that matches several keys is refused.
--device was ignored by commands that touch no key Refused on doctor, name list, - completions, manpage and molto - import --in … --dry-run. Remove it. name clear + completions and manpage. Remove it. + (molto import --in … --dry-run now reads slot use + from the Molto2 it names; see importing + codes.) name clear takes a NAME or -d, not both.
completions <shell> printed a full static script
--new-mgmt-key-env VAR / --new-mgmt-key-stdin --new-mgmt-key env:VAR / --new-mgmt-key stdin
keyroostctl molto import-file FILE --password-env VAR / --password-stdin (an encrypted Aegis export)keyroostctl molto import --in FILE --file-password + env:VAR / --file-password stdin (with + --customer-key stdin too, the customer key is line 1 + and the export's password line 2)
--password-env VAR / --password-stdin --password env:VAR / --password - stdin
--new-password-env VAR / --new-password-stdin --new-password env:VAR / --new-password stdin
keyroostctl otp fp-listkeyroostctl otp list --unlock fingerprint
keyroostctl otp unlock-listkeyroostctl otp list --unlock auto; --pin-only is --unlock pin, the default
keyroostctl molto sync-timekeyroostctl molto sync
keyroostctl molto import-filekeyroostctl molto import --in FILE; --start is --slot (the first slot to fill)
keyroostctl molto import-filekeyroostctl molto import --in FILE; --start is --slot (the first slot to fill), and the export's password is --file-password
keyroostctl molto slotskeyroostctl molto list
keyroostctl molto seed …keyroostctl molto seed set …
keyroostctl molto config …keyroostctl molto config set …
keyroostctl molto import-file export.json --start 10 keyroostctl molto import --in export.json --slot - 10 (or -i export.json)
keyroostctl piv import-cert --slot 9a --file cert.pem keyroostctl piv cert import --slot 9a --in cert.pem (or -i cert.pem)
+

Importing codes

+

New: oath import and otp import add every + code in an authenticator export to a key, and the desktop app does + the same for every key that stores codes. molto import + --in now follows the same rules: it reads the key first, shows + what each entry becomes, skips what the key can't store, and asks once + before writing.

+
keyroostctl molto import -i export.json --slot 90 --dry-run   # check the new preview first
+
+ + + + + + + + + + + + + + + + +
BeforeNow
The Molto2 view's Bulk import… button opened + a Molto2-only dialogImport file… (or dropping a file on the + window) opens the Import authenticator codes + dialog, the same one used for every key that stores codes. A + certificate or key file gets a note saying where to import it + instead.
molto import --in refused the whole file when one + entry couldn't be stored on a Molto2 (for example a SHA-512 code), + or when the entries didn't all fit before slot #99That entry is skipped with the reason, the rest are written, and + entries past slot #99 are skipped.
An entry with no issuer or account left its slot emptyIt is skipped without using up a slot, so the next entry takes + that slot. Check slot numbers in a script that relied on the + gap.
molto import --in asked only when a target slot + was in useIt asks once whenever it will write anything. A script adds + --yes, as before.
molto import --in … --dry-run printed + Found N entries; would fill slots … and touched no + keyIt prints the same preview as oath import, reads + which slots are in use from a connected Molto2 (no customer key + needed; it still works with none connected), and takes + --device / --reader.
oath code NAME computed a code at 30 seconds + unless --period was given, so a code stored as + 60/Issuer:account was wrong (also in the app)--period defaults to the name's + period/ prefix, else 30. --period 0 is + refused (exit 2).
oath add sent any name to a Solo 2A Solo 2 name over 127 bytes is refused before anything is sent + (a 128-byte name made the key stop responding until it was + unplugged). On YubiKey 4 firmware 4.0 to 4.3.4, a name that is the + start of another name on the key is refused, following the same + rule as Yubico's ykman.
+

Library (crates.io) API changes

diff --git a/docs/molto2.html b/docs/molto2.html index eb3b696c..f6965f46 100644 --- a/docs/molto2.html +++ b/docs/molto2.html @@ -4,18 +4,18 @@ Token2 Molto2 — programmable TOTP token — keyroost - + - + - + @@ -82,7 +82,7 @@

What it is

-

Programming & bulk import

+

Programming & importing

Load a single slot from an otpauth:// URI, or migrate a whole set of accounts at once:

    @@ -92,6 +92,27 @@

    Programming & bulk import

  • a QR code in a PNG/JPEG screenshot, including a Google Authenticator export (most desktop builds can also capture one from the screen)
+

In the desktop app, click Import file… in the Molto2 view (it was + Bulk import…), or drop the file on the window. The same Import + authenticator codes dialog is used for every key that stores codes: it lists + the entries, lets you pick the Molto2 under Add to and the first slot + to fill, and shows what each slot will get before anything is written. If the Molto2 + isn't unlocked yet, the dialog has an Authenticate button.

+

Entries fill consecutive slots from the first one. A slot already in use is + replaced, and the preview marks it. An entry the Molto2 can't store (for example a + SHA-512 code, a 45-second period, or a counter-based HOTP code) is skipped with the + reason, and the rest are still written. An entry with no issuer or account is + skipped without using up a slot, and entries past slot #99 are skipped. keyroost + writes one slot at a time and stops at the first error, saying what was written.

+
keyroostctl molto import -i export.json --slot 90 --dry-run   # preview; writes nothing
+keyroostctl molto import -i export.json --slot 90             # asks once, then writes
+keyroostctl molto import -i export.json --file-password env:EXPORT_PW   # encrypted Aegis export
+

The dry run reads which slots are in use from a connected Molto2 (no + customer key needed) and also works with none connected. An encrypted export's + password comes from --file-password env:NAME or + --file-password stdin, or a terminal asks for it; with + --customer-key stdin too, the customer key is the first line and the + export's password the second.

keyroost can also sync the device clock to the host — important, because TOTP depends on accurate time, and a drifted clock shows wrong codes.

@@ -123,17 +144,19 @@

What keyroost does with the Molto2

k

Read slot occupancy and titles; program a slot from an otpauth:// URI; - set a slot's title or delete its seed (the title stays); bulk-import from Aegis / 2FAS / an + set a slot's title or delete its seed (the title stays); import from Aegis / 2FAS / an otpauth:// list or a QR screenshot; sync the host clock; rotate the customer key; and factory-reset — over a pure-Rust protocol layer (SM4, SHA-1, APDU builders, MAC) with no external crypto dependencies.

-

On the command line, writing to a slot that already holds a seed, deleting a seed - and molto reset name the token and ask y/N first. A +

On the command line, writing to a slot that already holds a seed, importing a file + (molto import --in), deleting a seed and molto reset name the + token and ask y/N first. A script, or a command whose seed is piped in, adds --yes. With several Molto2 tokens connected, a terminal shows a numbered list to pick from and a script - names one with --device (the number keyroostctl list shows) - or --reader; keyroost never uses the first one it finds. Molto2 tokens + names one with --device (the number keyroostctl list shows, + or either serial keyroost shows for the token: the one in keyroostctl + list or the one molto info prints) or --reader; keyroost never uses the first one it finds. Molto2 tokens can't be given a friendly name yet.

diff --git a/docs/oath.html b/docs/oath.html index 7bff0e02..476e995e 100644 --- a/docs/oath.html +++ b/docs/oath.html @@ -109,7 +109,8 @@

What keyroost does with OATH

keyroostctl oath list
 keyroostctl oath code <name> --device my-yubikey
-keyroostctl oath delete <name>      # asks y/N first; a script adds --yes
+keyroostctl oath delete <name> # asks y/N first; a script adds --yes +keyroostctl oath import -i export.json # add every code in an authenticator export

In the desktop app, credentials can be added from an otpauth:// URI (the same format behind setup QR codes); on the CLI the seed is typed at a hidden prompt by oath add, or in a script given as @@ -122,6 +123,62 @@

What keyroost does with OATH

and oath reset ask y/N first, naming the key.

+
+

Importing codes from an authenticator app

+

keyroost can add every code in an authenticator export to a YubiKey, Solo 2 or + Nitrokey 3 in one step. It reads Aegis exports (plain or encrypted), 2FAS + exports, a text file with one otpauth:// URI per line, and QR + screenshots, including a Google Authenticator export.

+

In the desktop app, drop the file on the window, or click Import + file… in the key's Authenticator view. The Import authenticator + codes dialog lists the entries (an encrypted export asks for its password + first), lets you pick the key under Add to, and shows what each entry + will become before anything is written. On the command line:

+
keyroostctl oath import -i export.json --dry-run   # show what would happen; writes nothing
+keyroostctl oath import -i export.json             # asks once, then adds the codes
+keyroostctl oath import -i export.json --file-password env:EXPORT_PW   # encrypted Aegis export
+

Before writing, keyroost reads what is already on the key and marks each entry:

+
    +
  • add: it goes onto the key.
  • +
  • exists: a code with the same name is already on the key, so it is + left as is. --replace (in the app, Replace codes already on the key + that have the same name) replaces it instead. Some keys refuse a second code + with the same name; there, replacing removes the old code first, and if adding the + new one then fails, the old code is gone.
  • +
  • skip: the key can't store it, and the preview says why. keyroost + never changes an entry to make it fit.
  • +
+

keyroost asks once before writing (a script adds --yes), writes one + code at a time, and stops at the first error, saying what was added and what wasn't. + Running the same import again is safe: codes already added show as + exists. A dry run needs the key connected, because the preview + depends on what is on it.

+

What each key can store

+
    +
  • YubiKey: 32 codes, or 64 from firmware 5.7. Names up to 64 bytes + (the period prefix included). SHA-512 from firmware 4.3.1. On firmware 4.0 to 4.3.4, + a name that is the start of another name already on the key is skipped, and + oath add refuses it, following the same rule as Yubico's + ykman.
  • +
  • Solo 2: doesn't report how many codes it holds, so writing + stops if it fills up. Names up to 127 bytes: a longer name is refused by the import, + oath add and the app before anything is sent, because on firmware + 2.3.196 a 128-byte name made the key stop responding until it was unplugged. No + SHA-512 codes.
  • +
  • Nitrokey 3: 50 codes. Only 30-second codes, because the key + doesn't store the period. No SHA-512 codes. Import into a Nitrokey 3 is built + from Nitrokey's published secrets-app source; reports from people using one are + welcome in the issue + tracker.
  • +
+

Codes with a period other than 30 seconds

+

A YubiKey or Solo 2 doesn't store a TOTP code's period. Instead, the name + starts with it, such as 60/GitHub:alice (the naming Yubico's own tools + use), and an import names codes that way. keyroost reads the prefix: + oath code uses it as the period unless --period says + otherwise, and the app shows those codes with their period.

+
+

Authoritative resources

diff --git a/docs/otp.html b/docs/otp.html index e40e948c..1fd0f62b 100644 --- a/docs/otp.html +++ b/docs/otp.html @@ -78,6 +78,36 @@

Adding & reading codes

touch the key to reveal the code.

+
+

Importing codes from an authenticator app

+

keyroost can add every code in an authenticator export to the key's OTP storage in + one step: an Aegis export (plain or encrypted), a 2FAS export, a text file with one + otpauth:// URI per line, or a QR screenshot, including a Google + Authenticator export. In the On-device OTP tab, click + Import file… (or drop the file on the window); the Import + authenticator codes dialog shows what each entry will become before anything + is written. On the command line:

+
keyroostctl otp import -i export.json --dry-run   # show what would happen; writes nothing
+keyroostctl otp import -i export.json             # asks once, then adds the codes
+

An entry whose app and account are already on the key is left as is + (exists); --replace replaces it. An entry the key can't + store is skipped, with the reason. keyroost asks once, writes one + code at a time, and stops at the first error, saying what was added and what wasn't. + What the OTP storage takes:

+
    +
  • up to 50 codes;
  • +
  • app and account names of up to 64 bytes each, in plain ASCII characters;
  • +
  • SHA-1 or SHA-256 (no SHA-512), 4 to 10 digits;
  • +
  • HOTP codes only with a counter of 0, because the key always starts the counter + at 0.
  • +
+

When both the export's password and the OTP PIN are piped in, the export's password + (--file-password stdin) is the first line and the PIN the second. Import + into OTP storage is built from Token2's published OTP specification; reports from + people using it are welcome in the + issue tracker.

+
+

HID-HOTP (HOTP-on-touch)

HID-HOTP is a single dedicated slot that makes the key act like a keyboard: touch it @@ -168,6 +198,7 @@

Command line

keyroostctl otp list                  # stored entries
 keyroostctl otp code --account <name>  # read a live code
 keyroostctl otp info                   # interface states & slot status
+keyroostctl otp import -i export.json  # add every code in an authenticator export
 keyroostctl otp button set --digits 6                    # provision HID-HOTP (asks for the base32 seed, hidden)
 keyroostctl otp interface --fido --ccid --keyboard        # enable interfaces (re-plug after)

Every otp subcommand accepts diff --git a/fuzz/fuzz_targets/bulk_import.rs b/fuzz/fuzz_targets/bulk_import.rs index b8905da9..ca3a3c43 100644 --- a/fuzz/fuzz_targets/bulk_import.rs +++ b/fuzz/fuzz_targets/bulk_import.rs @@ -3,8 +3,67 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { + // Dropped-file classification sees raw bytes, text or not. + let _ = keyroost_import::classify::classify(data, None); + let _ = keyroost_import::classify::classify(data, Some("dropped.der")); if let Ok(s) = std::str::from_utf8(data) { let _ = keyroost_import::parse_bulk_any(s); let _ = keyroost_import::aegis::is_encrypted(s); + // The wide parsers read the same attacker-supplied files. + let _ = keyroost_import::parse_any_wide(s); + let _ = keyroost_import::parse_list_wide(s); + let _ = keyroost_import::aegis::parse_wide(s); + let _ = keyroost_import::twofas::parse_wide(s); + // Planning any file for any key must not panic either. + if let Ok(file) = keyroost_import::parse_any_wide(s) { + plan_everywhere(&file, data); + } } }); + +fn plan_everywhere(file: &keyroost_import::ImportFile, data: &[u8]) { + use keyroost_import::plan::{plan, Existing, OathFamily, Store, StoredName, Target}; + let first_slot = data.first().copied().unwrap_or(0); + let stores = [ + Store::Molto2, + Store::Token2Otp, + Store::Oath(OathFamily::YubiKey { version: None }), + Store::Oath(OathFamily::YubiKey { + version: Some((5, 7, 1)), + }), + Store::Oath(OathFamily::YubiKey { + version: Some((4, 3, 0)), + }), + Store::Oath(OathFamily::Solo2), + Store::Oath(OathFamily::Nitrokey3), + Store::Oath(OathFamily::Other), + ]; + for store in stores { + for replace in [false, true] { + // Plan once against an empty key, then against the key holding + // everything the first plan named. + let mut target = Target { + store, + first_slot, + existing: Existing::default(), + replace, + }; + let first = plan(file, &target); + target.existing = Existing { + names: first + .rows + .iter() + .filter_map(|r| r.name.clone()) + .collect::>(), + hidden: usize::from(first_slot), + }; + let second = plan(file, &target); + let _ = (first.counts(), second.counts(), second.writes().count()); + for r in &second.rows { + if let keyroost_import::plan::Verdict::Skip(s) = &r.verdict { + let _ = s.reason(store); + } + } + } + } +} diff --git a/fuzz/fuzz_targets/migration.rs b/fuzz/fuzz_targets/migration.rs index 224fef61..8fede209 100644 --- a/fuzz/fuzz_targets/migration.rs +++ b/fuzz/fuzz_targets/migration.rs @@ -4,7 +4,9 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = keyroost_import::migration::parse_payload(data); + let _ = keyroost_import::migration::parse_payload_wide(data); if let Ok(s) = std::str::from_utf8(data) { let _ = keyroost_import::migration::parse(s); + let _ = keyroost_import::migration::parse_wide(s); } }); diff --git a/fuzz/fuzz_targets/otpauth.rs b/fuzz/fuzz_targets/otpauth.rs index 0215d4db..b73401ea 100644 --- a/fuzz/fuzz_targets/otpauth.rs +++ b/fuzz/fuzz_targets/otpauth.rs @@ -5,5 +5,6 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(s) = std::str::from_utf8(data) { let _ = keyroost_import::parse_otpauth(s); + let _ = keyroost_import::otpauth::parse_entry_labeled(s); } }); diff --git a/fuzz/fuzz_targets/qr_image.rs b/fuzz/fuzz_targets/qr_image.rs index a01438a3..a289374c 100644 --- a/fuzz/fuzz_targets/qr_image.rs +++ b/fuzz/fuzz_targets/qr_image.rs @@ -4,4 +4,7 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = keyroost_qr::entries_from_image(data); + let _ = keyroost_qr::entries_from_image_wide(data); + // The shared file reader (no password: never runs the vault KDF). + let _ = keyroost_qr::open_file(data, None, None); });