Skip to content

fix: resolve all findings from full-codebase review (security, correctness, perf, binary size)#675

Open
replydev wants to merge 55 commits into
mainfrom
fix/review-findings
Open

fix: resolve all findings from full-codebase review (security, correctness, perf, binary size)#675
replydev wants to merge 55 commits into
mainfrom
fix/review-findings

Conversation

@replydev

@replydev replydev commented Jul 22, 2026

Copy link
Copy Markdown
Owner

This PR fixes every finding from a full-codebase review (security, correctness, performance, binary size, architecture). Each fix is an individual commit with a detailed body; merges group related workstreams. Every reported bug was reproduced against the built binary before fixing, and regression tests were added where practical.

Critical & high-severity bug fixes

  • Data loss: cotp -d db.cotp <cmd> with a bare relative path treated an existing database as first run and silently overwrote it with a new empty one (init_app checked parent().exists(), which is false for ""). Now checks the file itself; regression test added.
  • add validation was entirely dead: the conditional clap rules referenced a wrong arg id ("type") and wrong-case values ("HOTP" vs hotp). Consequences fixed: HOTP could be added without --counter (then panicked the TUI on +/-), Steam produced 6-character codes instead of 5 (wrong for login), Yandex/MOTP could be added without --pin (permanently ERROR).
  • Yandex codes panicked ~20% of time windows: the dynamic-truncation offset (0–15) sliced past the end of a 20-byte SHA1 digest. Yandex now always uses HMAC-SHA256 (matching Yandex/Aegis behavior) and the slice is bounds-checked.
  • extract --index was 0-based while list/edit/delete/TUI are 1-based — it returned the next account's code. Now 1-based; --index 0 rejected. Same for delete --index 0, which previously fell through to an empty-filter match and targeted the first element.
  • FreeOTP+ import bricked HOTP tokens by testing the hash-algorithm field instead of the token type, dropping every counter — and one broken element made list --json fail for the whole database. Both fixed.
  • cotp list panicked (usize underflow) when all issuers/labels were shorter than the column headers.

Security

  • Plaintext exports (cotp export in every format) were world-readable; now created 0600 on unix with a plaintext warning, and I/O errors return instead of panicking. The database file itself is also created 0600.
  • All panics on untrusted input converted to readable errors: corrupted db.cotp (invalid base64 nonce/cipher/salt), malformed encrypted Aegis backups (missing slot params, non-hex fields, non-power-of-two scrypt n — which previously derived a silently wrong key).
  • Zeroization gaps closed: the serialized plaintext database JSON on every save, decrypted Aegis backup plaintext, and rejected password attempts.
  • OTPElement's Debug output now redacts secret/pin; period == 0 is rejected everywhere (previously a division-by-zero panic in three code generators); edit --change-secret now goes through the same validation as add.

Other correctness fixes

  • otpauth URI parsing no longer percent-decodes the whole URI before parsing (RFC-valid labels like C%23Corp previously broke parsing; values were decoded twice).
  • MOTP/Yandex elements now round-trip through export --otp-uriimport: secrets are no longer force-uppercased and pin is emitted/parsed. The Aegis importer now maps pin too.
  • TUI: codes and the progress gauge now respect each element's own period (previously hardcoded 30s); plain c no longer opens the delete popup; no more panics on tiny terminals, unset HOTP counters, oversized QR payloads, or the todo!() popup path; the event thread exits cleanly on shutdown; OSC52 clipboard writes go to stderr so stdout stays pipeable.
  • reading.rs no longer silently deletes an empty database file; declining a delete confirmation is no longer treated as an error; init errors go to stderr with sane exit codes; the add integration test no longer mutates the committed fixture.

Performance

  • Argon2 unlock is ~2–4× faster: the 4 configured lanes now run in parallel (ThreadMode::Parallel). Same parameters, identical derived key — verified by test.
  • The QR page no longer rebuilds the URI/QR matrix/unicode render 4×/second; it's cached per selected element. Search is a single ranked pass with hoisted lowercasing instead of four scans.

Binary size (measured, release build)

  • Dropped globset (pulled the full regex engine for */? matching in extract) for a small hand-rolled matcher: −512 KB.
  • Replaced color-eyre with plain eyre: −199 KB.
  • Pinned idna_adapter = "~1.0" so url stops linking ICU4X tables: −131 KB.
  • Consolidated hex + base64 onto the already-used data-encoding; trimmed ratatui to default features (drops the time crate) and qrcode to no default features (drops the image crate — compile-time win).

Net: roughly −35% binary size (~2.26 MiB → ~1.5 MiB).

Architecture

  • Import/export format dispatch converted from 11-boolean if/else ladders (ending in unreachable!()) to exhaustive internal enums; CLI flags unchanged.
  • OTPType/OTPAlgorithm now implement TryFrom<&str> and reject unknown strings instead of silently defaulting to TOTP/SHA1 (imports error instead of mis-converting).
  • Database persistence extracted out of OTPDatabase into storage/ — the domain type no longer touches crypto or the global path, and save() clears the dirty flag only after a successful write.
  • The dirty-flag API hardened: needs_modification is private with mark_modified()/clear_modified() accessors; the TUI no longer pokes the field directly. Importer/exporter error types unified on eyre.
  • Rendering moved out of app.rs into ui.rs; the Aegis-encrypted password prompt moved out of the TryFrom conversion into the argument layer; CLAUDE.md updated to match reality.

Deliberately not done

  • rust-argon2 → RustCrypto argon2 swap: the RustCrypto crate computes lanes sequentially, which would forfeit the parallel-unlock win above. Kept rust-argon2.
  • opt-level = "z": skipped pending an Argon2 unlock-time benchmark (−64 KB measured, but "z" disables loop vectorization).
  • Hash-based change detection to replace the dirty flag entirely, and a PromptReader abstraction for prompt testability: larger redesigns, left for follow-ups.

Verification

  • cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings, and cargo test --locked all pass at the merge head.
  • New regression tests: bare-relative-path init survival, FreeOTP+ HOTP counters, Yandex offset bounds, URI round-trips, index handling, list padding, Argon2 parallel/sequential key equality.

replydev added 30 commits July 23, 2026 00:03
The FreeOTP+ importer decided whether to keep the counter by testing
the hash algorithm field (token.algo, e.g. "SHA1") against "HOTP"
instead of the token type field (token.type). The comparison was never
true, so every imported HOTP token ended up with counter: None and
failed code generation with "Missing counter value".

Trigger: import any FreeOTP+ backup containing an HOTP token.

Fix: compare token.type against "HOTP" when extracting the counter.
Adds a fixture with an HOTP entry (counter 4) and a test asserting the
counter survives the conversion.
…panicking on I/O errors

All export formats (full database JSON, andOTP, FreeOTP+, otpauth URIs)
are written unencrypted. The file was previously created with
File::create, which honors the default umask and typically yields
world-readable 0644 permissions, letting any local user read every
exported OTP secret. The file is now opened via OpenOptions with
mode(0o600) on Unix (write + create + truncate), so only the owner can
read it; on non-Unix platforms the behavior is unchanged. A warning is
also printed to stderr reminding the user that the exported file
contains secrets in plain text.

Additionally:

- The two .expect() calls on file creation and writing panicked on any
  I/O failure (e.g. 'cotp export -p /nonexistent/dir/x.json') even
  though the function already returns Result; errors are now propagated
  as a readable message including the target path.
- The serialized plaintext String was only zeroized on the success
  path; it is now zeroized on every path (empty export, write failure,
  success) before returning.

Unit tests cover the error propagation, the empty-export short-circuit,
and (on Unix) the 0600 permission bits of the created file.
The main window key handler matched KeyCode::Char('d' | 'D' | 'c') in a
single arm and only distinguished the intended Ctrl modifier inside it,
so pressing bare 'c' (a natural guess for "copy") fell into the else
branch and opened the delete-OTP confirmation popup.

Split the arm: 'c'/'C' now only acts with KeyModifiers::CONTROL (the
intended Ctrl-C exit path, matching the search-bar handler), while
'd'/'D' keeps its dual role of Ctrl-D exit and plain-d delete prompt.
Bare 'c' now does nothing.
…ted database

decrypt_string() used .expect()/.unwrap() when Base64-decoding the
nonce, cipher text and salt fields of the on-disk encrypted database
envelope. A hand-edited or bit-rotted db.cotp therefore crashed cotp at
startup with a panic and backtrace instead of a useful diagnostic, even
though the function already returns a color_eyre Result.

The three decode calls are now map_err'd into the Result with messages
of the form 'database file is corrupted: cannot decode Base64 <field>:
<cause>', so the user gets an actionable error message.

Unit tests feed envelopes with invalid Base64 in each of the three
fields and assert that decrypt_string returns Err (mentioning the
corrupted field) rather than panicking.
handle_counter_switch called element.counter.unwrap() with a comment
claiming the unwrap was safe because the element type is HOTP. That
assumption is false: HOTP elements can exist with counter == None, for
example when imported from an otpauth:// URI that omits the counter
parameter. Pressing '+' or '-' on such an element crashed the TUI
while the terminal was in raw mode.

Treat a missing counter as 0 via unwrap_or(0), so '+' initializes it
to 1 and '-' saturates at 0; the code is regenerated afterwards as
before via mark_modified() and tick(true).
…e digest slice

Yandex code generation dispatched on the element's algorithm field,
falling back to HMAC-SHA1 for anything that was not SHA256/SHA512.
Since SHA1 is the default algorithm when adding an element, the default
configuration used HMAC-SHA1, whose output is only 20 bytes. The dynamic
offset (last byte & 0xf) ranges over 0..=15, so for offsets 13..=15 the
slice period_hash[offset..offset + 8] read out of bounds and panicked.
That is roughly 3 out of 16 time windows (~19%), and inside the TUI the
panic fired mid raw-mode, corrupting the terminal.

Yandex.Key and Aegis' reference implementation (which this port is based
on) exclusively use HMAC-SHA256, so honoring the stored algorithm field
was wrong as well as unsound: codes produced with SHA1/SHA512 never
matched Yandex's servers.

Fix: calculate_yandex_code is no longer generic over the hash and always
uses HMAC-SHA256 (32-byte output, offset+8 <= 23 always in bounds). As
defense in depth, the digest indexing now goes through get()/get_mut()
and returns OtpError instead of panicking. The existing test vector
already exercised SHA256 and still passes.
AegisInfo had no pin field and the AegisElement -> OTPElement
conversion hardcoded pin: None, so Yandex/MOTP entries imported from
Aegis backups lost their pin and could never generate codes.

Trigger: import a plain Aegis backup containing a Yandex or MOTP entry
whose info object carries a pin.

Fix: add pin: Option<String> (serde default) to AegisInfo and map it
through to OTPElement. Adds a unit test covering entries with and
without a pin.

Also removes the dead AegisHeader empty struct (kept alive only by an
allow(dead_code)) and the unused Serialize derives on these
import-only types.
handlers/popup.rs matched PopupAction::EditOtp with todo!(), and
App::new initialized popup.action to exactly that variant. Nothing in
the codebase ever implemented editing via popup, so the variant was a
landmine: any future code path that focuses the popup without going
through show_popup() would hit the todo!() and panic while the
terminal is in raw mode.

Delete the EditOtp variant entirely and derive Default on PopupAction
with GeneralInfo as the default, which is harmless if it is ever
reached: its handler just returns focus to the main page on
i/Esc/Enter. All real popups keep setting their action explicitly via
show_popup().
After dashboard() returns, the mpsc receiver inside EventHandler is
dropped, but the polling thread keeps running. Its next
sender.send(...).expect(...) then panicked with "failed to send
terminal event" / "failed to send tick event", race-dependently
spraying a panic message onto the just-restored terminal.

Treat a send error (receiver gone) as the shutdown signal and break
out of the loop instead of panicking, for both terminal events and
tick events.

Note: the Event enum still carries dead unit payloads
(Mouse(()), Resize((), ()), FocusGained(), Paste(())); they cannot be
collapsed without editing the exact-shape match in main.rs, which is
out of scope for this change.
… relative path

init_app decided whether this was a first run by checking the existence of
the database file's parent directory. For a bare relative filename (e.g.
`cotp -d db.cotp`), Path::parent() returns the empty path and
Path::new("").exists() is always false, so an existing, populated database
was treated as a first run: cotp prompted for a new password and overwrote
the file with an empty database, destroying all stored secrets.

The first-run decision is now taken from the database file itself
(db_path.exists()); the parent directory is only created when the file does
not exist yet, treating an empty parent as the current directory. When
directory creation fails, the underlying io::Error is now printed to stderr
instead of being silently discarded (the Result<bool, ()> signature is kept
because the caller in src/main.rs matches on Err(()) and is out of scope
for this change).

Adds an integration test that runs the binary twice from a temporary
working directory with a bare relative --database-path and asserts the
database and its content survive the second run.
KEY_DERIVATION_CONFIG declared lanes: 4 but ThreadMode::Sequential, so
every database unlock computed the four 32 MiB lanes one after another
on a single core. Switching to ThreadMode::Parallel lets rust-argon2
compute the lanes on separate threads, giving an expected 2-4x speedup
of key derivation (and thus of every cotp startup) on multi-core
machines.

This is safe for existing databases: in Argon2 the lane count is part
of the hash parameters, while the thread mode is purely an execution
strategy of the implementation, so the derived key is bit-for-bit
identical. This is verified by a new unit test whose expected key for a
fixed password+salt was captured from the previous Sequential
implementation before the change; the test still passes with Parallel.

Deliberately not migrating to the RustCrypto argon2 crate here: it is
single-threaded, which would forfeit exactly this multi-lane
parallelism win.
The main page layout computed Constraint::Length(height - 8) from the
raw frame height; on a terminal shorter than 8 rows the u16
subtraction underflowed and panicked in debug builds while the
terminal was in raw mode.

Use height.saturating_sub(8) instead, and harden the same subtraction
pattern in centered_rect() (100 - percent_x/percent_y) with
saturating_sub as defense in depth against out-of-range percentages.
A period of 0 was accepted everywhere an element can enter the database
(add/edit flags, otpauth URIs with period=0, imports), but every
time-based code generator divides the current Unix time by the period:
totp_maker, yandex_otp_maker and motp_maker all computed seconds/period
and panicked with a division by zero. In the TUI the panic fired on the
very first render tick, crashing the dashboard while the terminal was in
raw mode.

Fix at both levels:

- OTPElementBuilder::validate now rejects period == 0 (and digits == 0),
  so new elements built through the builder can never carry the invalid
  value.
- The generators are hardened for elements that already exist in a
  database or bypass the builder: totp, yandex and motp return the new
  OtpError::InvalidPeriod instead of dividing by zero. motp's signature
  changes from String to Result<String, OtpError>; its only caller is
  OTPElement::get_otp_code which already returns that Result type.
…cking

The encrypted Aegis importer used unwrap()/expect() on untrusted
backup contents: slot.n/p/r and slot.salt could be absent on a type-1
slot, and the nonce, tag, key and salt hex strings could be non-hex.
Any such malformed (or maliciously crafted) file crashed cotp with a
panic instead of reporting an invalid backup.

In addition, the scrypt cost parameter was converted with
(n as f32).log2() as u8, which silently truncates a non-power-of-two
n, derives a key with the wrong cost and surfaces as a misleading
"wrong password" decryption failure.

Trigger: run cotp import --aegis-encrypted with a backup whose slot is
missing n/r/p/salt or contains non-hex nonce/tag/key/salt fields.

Fix: convert every unwrap/expect on backup data into a descriptive Err
through the existing String error path, validate n.is_power_of_two()
and derive log2(n) with n.trailing_zeros(). Adds unit tests feeding
malformed slot JSON and asserting an Err is returned instead of a
panic.
The SSH clipboard path wrote the OSC52 escape sequence (with the
base64-encoded secret) to io::stdout(). The TUI dashboard deliberately
runs on io::stderr() so that stdout stays clean for piping; writing
the escape to stdout polluted piped output with the raw escape bytes
and broke that contract.

Write the sequence to io::stderr() instead - the terminal the TUI
owns - which is where terminals interpret the OSC52 sequence anyway
when the alternate screen is active.
…tick

render_qrcode_page called element.get_qrcode() on every render, i.e.
on every 250ms tick and every key event while the QR page was open.
Each call rebuilds the otpauth URI, recomputes the QR matrix and
re-renders it to a unicode string, four times per second for a static
image.

Cache the rendered string in App keyed by the selected element index
and reuse it while the same element stays selected. The cache is
invalidated when the selection changes (different index), when the
table is refreshed (App::tick force/period refresh, which covers HOTP
counter changes and deletions) and when leaving the page via
App::reset(). get_qrcode() itself is unchanged.
…actually fire

All conditional rules on the add subcommand were dead:

- required_if_eq("otp_type", "HOTP") (and YANDEX/MOTP) compared against
  uppercase values, but ValueEnum possible values are lowercase ("hotp",
  "yandex", "motp"), so the predicates never matched.
- default_value_if("type", "STEAM", "5") referenced a nonexistent arg id
  (the field id is otp_type; "type" is only the long flag name) on top of
  the same case mismatch.

Consequences: `add -t hotp` succeeded without --counter (the element then
renders ERROR and the TUI +/- counter keys panic), `add -t steam` produced
6-digit codes while Steam codes are 5 digits (every generated code
invalid), and yandex/motp entries added without --pin were permanently
broken.

Fix the arg ids and value casing so hotp requires --counter, yandex and
motp require --pin, and steam defaults digits to 5. Additionally add a
post-parse backstop (validate_type_invariants) in the execution path so
the invariants still hold even if the declarative clap rules silently rot
again, plus unit tests covering both the clap rules and the backstop.
from_otp_uri ran urlencoding::decode over the entire URI and only then
handed it to Url::parse. Any percent-encoded structural character in a
label or query value therefore corrupted parsing:

- otpauth://totp/C%23Corp?secret=... failed entirely, because "%23"
  became "#" and the whole query turned into a URL fragment
- "%3F" in a label became "?" and truncated the label / shifted the
  query, "%26" in a value became "&" and split the query pair
- every value was effectively decoded twice (the pre-decode plus
  Url::query_pairs, which decodes again), so labels containing a literal
  "%25" decoded to "%" instead of "%25"

Fix: parse the RAW URI with Url::parse and percent-decode only the
individual components. Query values are decoded exactly once by
Url::query_pairs; the first path segment is decoded once before being
split on ':' into issuer and label (decoding before splitting preserves
the GH-548 behavior of treating an encoded %3A as the separator).

Also renames the meaningless helper fn get() to issuer_label_segments()
and adds regression tests for labels containing %23, %3F, %26, %25 and
a double-encoded literal %25 (%2525).
The TryFrom<AegisEncryptedDatabase> for Vec<OTPElement> impl called
the blocking terminal prompt utils::password() inside a pure
conversion trait. That coupled decryption to an interactive terminal,
making the whole encrypted-Aegis import path impossible to unit test
and inconsistent with the rest of the codebase, where interactive
decisions live in the argument layer.

Fix: replace the TryFrom impl with an explicit
AegisEncryptedDatabase::decrypt(self, password) -> Result<Vec<OTPElement>, String>
method, and move the prompting into a dedicated
import_aegis_encrypted() branch in arguments/import.rs, which reads
the file, deserializes it, asks for the password (zeroizing it after
use, as before) and delegates to decrypt(). Adds a unit test invoking
decrypt() on a backup with no usable key slot and asserting a graceful
error.

No happy-path decryption test is added because no encrypted Aegis
fixture exists in test_samples/.
extract compared the user-supplied --index directly against the 0-based
enumerate() position, while list, edit, delete and the TUI all present and
accept 1-based indexes. As a result `extract --index 1` silently returned
the SECOND element's OTP code — off by one from what the user sees in
`list` — and `extract --index 0` returned the first element instead of
being rejected.

The index filter now subtracts 1 before comparing against the array
position (i.checked_sub(1) == Some(index)), and --index 0 is rejected with
an explicit error explaining that indexes are 1-based. Unit tests cover
1-based selection, out-of-range indexes and the index-0 rejection.
After decrypting an encrypted Aegis backup, map_results() converted
the plaintext into a String containing every OTP secret in the backup
and dropped it without zeroization, leaving the secrets recoverable
from freed memory. The raw bytes kept inside the FromUtf8Error on the
invalid-utf8 path leaked the same way.

Trigger: any successful (or utf8-invalid) cotp import
--aegis-encrypted run.

Fix: zeroize the plaintext JSON String once parsing is done, and
zeroize the error's byte buffer on the invalid-utf8 path, consistent
with the zeroization already applied to the password and master key.
The dashboard hardcoded a 30 second cycle everywhere: App::tick only
regenerated codes when the global 30s progress (utils::percentage)
wrapped, and the gauge always showed that same 30s cycle. Elements
with a different period - 60s TOTP, 10s MOTP default - kept showing
expired codes for up to 30 seconds and a gauge unrelated to their
actual validity window.

Track each element's own RFC 6238 time step (unix_seconds / period)
across ticks and regenerate the table as soon as any element crosses
its own period boundary. The progress gauge now shows the elapsed
fraction of the SELECTED element's period, computed at render time so
it also reacts immediately to selection changes; when nothing is
selected it falls back to the global 30s utils::percentage cycle.

The period-aware helpers live in src/interface/app.rs (current_step,
period_percentage, element_steps); src/utils.rs is intentionally left
untouched. The 250ms tick rate is unchanged, and a period of 0 is
clamped to 1 to avoid division by zero.
search_and_select was four near-identical scan loops over the table
rows - issuer prefix, label prefix, issuer substring, label substring
- each re-lowercasing the search query and the inspected cell on every
iteration, and each reaching into the row through positional
values.get(1)/get(2).unwrap() magic indices.

Replace it with a single ranked pass: the query is lowercased once,
each row is assigned the rank of the best predicate it satisfies
(issuer prefix < label prefix < issuer substring < label substring)
and the minimum (rank, index) wins, preserving the exact selection
semantics of the four sequential loops including tie-breaking by row
order.

To remove the magic column indices, Row now has named fields (id,
issuer, label, otp_code) instead of values: Vec<String>; height() and
cells() iterate an ordered columns() array. This also lets
copy_selected_code_to_clipboard read row.otp_code directly, dropping
the unreachable "Cannot get OTP Code column" branch. All Row users
live in src/interface/, so the change is fully contained.
Three related defects made exported MOTP and Yandex entries unusable
after re-import:

- from_otp_uri force-uppercased every secret. MOTP secrets are lowercase
  hex strings fed as TEXT into MD5 (see motp_maker), so an imported MOTP
  entry silently generated wrong codes.
- get_otpauth_uri never emitted the pin parameter, while Yandex and MOTP
  codes cannot be generated without it: exporting and re-importing such
  an entry produced an element that could only ever return
  "Missing pin value".
- from_otp_uri constructed OTPElement literally, bypassing
  OTPElementBuilder validation, so invalid (e.g. non-BASE32) secrets
  were accepted at import time and only failed later, at code
  generation.

Fix: from_otp_uri now builds the element through OTPElementBuilder,
which validates the secret encoding, period and digits, and normalizes
secret case per type (uppercase for the base32 types TOTP/HOTP/Steam/
Yandex, lowercase hex for MOTP). get_otpauth_uri emits a pin=<value>
query parameter when the element has a pin, and from_otp_uri parses the
same "pin" parameter back.

Adds round-trip tests (element -> get_otpauth_uri -> from_otp_uri) for
TOTP, MOTP with a lowercase hex secret, and Yandex with a pin, plus a
test that invalid BASE32 secrets are rejected at URI import.
The extract subcommand used GlobBuilder/GlobMatcher solely for
case-insensitive wildcard matching of the --issuer/--label filters. That
single use dragged globset and the full regex engine (regex-automata,
regex-syntax, bstr) into the release binary, measured at about 512 KB of
binary size for functionality that boils down to matching `*` and `?`.

Replace it with a small iterative two-pointer wildcard matcher
(wildcard_match) that supports `*` (any possibly-empty sequence) and `?`
(exactly one character), performs whole-string matching, and is
case-insensitive via Unicode to_lowercase - preserving the previous
observable behavior of the extract filters. All pre-existing extract
filter tests keep passing unchanged, and new unit tests cover the matcher
itself (literal, empty pattern, `*` collapse, `?`, Unicode case folding).

globset is removed from Cargo.toml; it only remains in Cargo.lock as a
transitive dev-dependency of assert_fs, which does not affect the shipped
binary.
OTPDatabase::overwrite_database_key serialized the whole database —
every secret and pin included — into a plaintext JSON String that was
simply dropped at the end of the function, leaving its contents in
freed heap memory on every save. The read path (reading.rs) already
zeroizes the decrypted plaintext, so the save path was the only place
the full plaintext lingered.

Fix: bind the serialized JSON as mutable and zeroize() it immediately
after encrypt_string_with_key has consumed it, before the encryption
result is unwrapped or anything is written to disk.
Two issues in the delete subcommand:

1. `delete --index 0` silently targeted the FIRST element. The
   index.and_then(|i| i.checked_sub(1)) chain mapped 0 to None, which fell
   through to the issuer/label matcher with both filters defaulting to the
   empty string - and `contains("")` matches every element, so element 0
   was proposed for deletion even though the user never referred to it.
   Out-of-range indexes fell through the same way. Now, when --index is
   supplied, 0 and out-of-range values are rejected with explicit errors
   and the issuer/label matcher is only consulted when no index was given.

2. Answering "N" to the confirmation prompt returned
   Err("Operation interrupt by the user"), which main.rs surfaces as
   "An error occurred: ..." with a failure exit code. Declining a
   confirmation prompt is a normal outcome, not an error: it now prints a
   neutral message and returns Ok with the unchanged database (which is
   not marked modified, so nothing is re-encrypted or rewritten).
replydev and others added 25 commits July 23, 2026 00:09
The database file was written via File::create, which creates new files
with mode 0644 (subject to umask): any local user could read the
encrypted database. The contents are encrypted with a key derived from
the user's password, so this is defense in depth rather than a direct
secret leak, but there is no reason for the file to be world-readable —
it also exposes the encrypted blob to offline brute-forcing by other
local users.

Fix: on unix the file is now opened through OpenOptions with
write/create/truncate and .mode(0o600) (std::os::unix::fs::
OpenOptionsExt), so newly created databases are only accessible by
their owner. Other platforms keep the plain File::create behavior,
which already truncates.
OTPElement derived Debug, so any debug formatting of an element — eyre
error reports, dbg!/log statements, assertion failure output in tests —
printed the raw secret and pin to the terminal or logs in plaintext.

Fix: replace the derive with a hand-written Debug implementation that
prints "***" for the secret and for the pin (when present) while
keeping all non-sensitive fields. The type still implements Debug, so
existing assert_eq! usage in tests keeps working.
OTPElement::get_qrcode unwrapped QrCode::new. A QR code has a maximum
data capacity, so an element with an oversized label, issuer or secret
made the otpauth URI too long to encode and the unwrap panicked. The
method is called from the TUI QR-code view, so the panic fired while
the terminal was in raw mode, crashing the dashboard and leaving the
terminal corrupted.

Fix: handle the QrCode::new error and return the printable string
"Cannot render QR code: data too long" instead, which the TUI simply
displays in place of the QR code. The String return type is kept so
the caller in src/interface/ is untouched.
Three issues in the list subcommand:

1. The table view hand-rolled its padding with
   " ".repeat(issuer_width - 6) where issuer_width is the longest issuer
   length + 3. With only short issuers (2 characters or fewer, e.g. issuer
   "ab"), the subtraction underflows usize and the command panics.
   The manual string concatenation is replaced with {:<width$} format
   specifiers, and both column widths are clamped to at least their header
   lengths, so no subtraction can underflow and columns stay aligned.

2. `list --json` printed the JSON document with print!, leaving no
   trailing newline (the next shell prompt glued to the output and piped
   consumers received an unterminated last line). It now uses println!.

3. `list --json` was all-or-nothing: a single uncomputable element (e.g.
   an HOTP entry missing its counter) failed the entire command, while the
   table view degrades per-row with "ERROR". The JSON view now degrades
   the same way, emitting the OTP error string as that element's otp_code
   value instead of aborting the whole listing.
read_decrypted_text deleted the database file as a side effect of the
read path whenever the file existed but was empty. A destructive,
irreversible file removal buried inside a read function is surprising:
an empty file can be the leftover of an interrupted or failed write,
and the user may want to restore a backup over that exact path rather
than have cotp silently discard it and start over.

Treating the empty file as a first run here was not an option either:
first-run detection (utils::init_app) happens before the password is
read and uses a verified "choose a password" prompt, while this path
has already consumed a plain "Password:" prompt (or stdin), so it
cannot correctly initialize a new database.

Fix: keep the file untouched and return a clear error stating the
database path, that the file is empty or corrupted, and what to do
(restore a backup over it, or remove it manually and restart cotp to
initialize a new database). The now-unused delete_db helper is
removed.
migrate() copied the const MIGRATIONS_LIST into a local binding and
sorted it on every database save, allocating and sorting work for a
list whose order is known at compile time.

Fix: iterate the const directly by reference. The list is kept sorted
by ascending to_version as a documented convention, enforced with a
debug_assert!(is_sorted_by_key(...)) so any future out-of-order entry
fails fast in debug/test builds without adding release overhead.
… no-op edits

Two issues in the edit subcommand:

1. `edit --change-secret` stored the raw prompted string directly into the
   element, bypassing the base32/hex validation and case normalization
   that `add` performs through OTPElementBuilder. An invalid secret (e.g.
   not valid base32 for a TOTP entry) was silently persisted into the
   encrypted database and only surfaced later as a code-generation error.
   The new secret is now routed through OTPElementBuilder with the
   element's own type/fields, so it gets exactly the same validation and
   normalization as `add` and an invalid secret aborts the edit with a
   clear error before anything is saved.

2. run_command called mark_modified() unconditionally, so even a no-op
   `edit -i N` with no field arguments re-encrypted and rewrote the whole
   database file. The element is now compared against a snapshot taken
   before applying the changes and the database is only marked modified
   when at least one field actually changed value.
The add integration test mutates test_samples/cli_integration_test/
empty_database in place when run; a test run's side effect was
accidentally swept into a previous commit. Restore the original
fixture bytes. The underlying test is being fixed separately to copy
the fixture to a temporary directory instead of writing to it.
…prompt errors

Password prompt hygiene fixes:

- password(): a typed attempt that was rejected for being shorter than the
  minimum length was dropped without being zeroized, leaving the secret in
  memory even though the crate uses zeroize for all other password/key
  handling. Rejected attempts are now zeroized before re-prompting.

- verified_password(): the confirmation copy of the password was never
  zeroized (neither on match nor on mismatch), and a non-matching first
  attempt was dropped without zeroization too. Both are now zeroized on
  every path, including when the confirmation prompt itself fails.

- rpassword::prompt_password(...).unwrap() panicked with a backtrace when
  stdin is not a TTY (e.g. cotp invoked from a script or with a closed
  stdin). The prompt logic now lives in Result-returning helpers
  (try_password / try_verified_password) that propagate the io::Error; the
  public String-returning signatures are kept as thin wrappers that print
  a clear message and exit, because their callers (src/main.rs,
  src/arguments/passwd.rs, src/reading.rs, src/importers/aegis_encrypted.rs)
  are outside the scope of this change.

- The direct rpassword unwraps in the add (OTP URI prompt) and edit
  (--change-secret prompt) subcommands now propagate the error through
  their color_eyre::Result return values instead of panicking.
add_with_label_should_work ran the add subcommand directly against
test_samples/cli_integration_test/empty_database and persisted the new
element into it, dirtying the git working tree on every `cargo test` run
(and making subsequent runs start from a different database state).

The test now copies the fixture into an assert_fs::TempDir first and runs
the binary against the temporary copy, so the committed fixture is never
modified.
cotp only ever prints errors with plain Display formatting ({e} in
main.rs), so color-eyre's extra machinery — panic/error report hooks,
backtrace capture and symbolization (gimli, addr2line, object) and the
tracing-error span-trace stack — was compiled in but never visible to
users. Depending directly on eyre keeps the exact same Result/Report
API (imports simply change from color_eyre::eyre::* to eyre::*) while
dropping the whole reporting stack, measured at -199 KB (-8.4%) on the
release binary.

Changes:
- Cargo.toml: color-eyre 0.6.5 -> eyre 0.6.12 (default features kept)
- src/**: color_eyre::eyre:: -> eyre::, color_eyre::Result -> eyre::Result
- src/main.rs: drop the color_eyre::install() hook, no longer needed

Error output remains readable: messages are unchanged, they just lose
the (never-requested) colorized backtrace support.
cotp already depends on data-encoding for base32/base64 in several
modules; the hex and base64 crates only served a handful of remaining
call sites. Routing those through data-encoding removes two direct
dependencies and their duplicate encode/decode code paths.

Replacements:
- src/otp/otp_element.rs: hex::decode -> HEXLOWER_PERMISSIVE.decode
  (keeps accepting mixed-case hex secrets, as the hex crate did)
- src/otp/algorithms/motp_maker.rs: hex::encode -> HEXLOWER.encode
- src/importers/aegis_encrypted.rs: hex::FromHex -> a decode_hex helper
  over HEXLOWER_PERMISSIVE (Aegis writes lowercase, stays permissive)
- src/clipboard.rs: base64 STANDARD engine -> BASE64.encode (same
  padded standard alphabet for the OSC52 escape sequence)
- src/importers/google_authenticator.rs: base64 STANDARD engine ->
  BASE64.decode with a BASE64_NOPAD fallback. The old engine required
  canonical padding; the fallback additionally accepts migration URIs
  whose trailing '=' was stripped by a QR scanner (new test covers it).

Semantic notes:
- The invalid-hex error text changes from the hex crate's "Odd number
  of digits" to data-encoding's "invalid length at N" (test updated).
- base64 remains in Cargo.lock as a transitive dependency of
  rust-argon2; hex is gone entirely.
Three independent dependency-weight cuts, none of which change any
user-visible behavior:

- ratatui: default features minus "all-widgets" and "macros". The TUI
  only uses Block, Borders, Cell, Clear, Gauge, Paragraph, Row, Table
  and Wrap - all available without extra features. In ratatui 0.30 the
  default feature set itself enables all-widgets (= widget-calendar),
  which drags the `time` crate into every build; cotp also never uses
  the ratatui-macros crate. Kept: crossterm, layout-cache,
  underline-color.
- qrcode: default-features = false. cotp only renders unicode QR
  strings for `cotp list --qrcode`; the default "image" feature
  compiles the whole `image` crate for PNG rendering that is never
  called.
- idna_adapter = "~1.0" pinned as a direct dependency so `url` resolves
  its IDNA backend to the small unicode-rs adapter instead of the
  ICU4X normalizer/properties tables. cotp only parses otpauth:// URIs
  and never needs internationalized domain names. Measured -131 KB on
  the release binary.

cargo tree -e normal | grep -iE 'icu|image|time':
  before: icu_collections, icu_locale_core, icu_normalizer(+data),
          icu_properties(+data), icu_provider, image v0.25.9,
          time v0.3.47, time-core v0.1.8
  after:  (no matches; remaining time/wait-timeout hits are
          dev-dependencies of assert_cmd only)
Four related cleanups on the binary entry path:

- Print all startup errors to stderr. Init/database errors went to
  stdout while subcommand errors went to stderr; stdout must stay clean
  for piping (the TUI already runs on stderr for the same reason).
- Use conventional exit codes. exit(-1)/exit(-2) wrap to 255/254 on
  POSIX; main now returns std::process::ExitCode with 1 (init or save
  failure) and 2 (subcommand failure). The derived key is still
  zeroized on every path before returning.
- Drop dead Event variants. Event::Mouse(()), Resize((), ()),
  FocusGained(), FocusLost() and Paste(()) carried erased unit payloads
  through the channel only for main.rs to ignore them; the event thread
  now drops those crossterm events at the source and the enum shrinks
  to Tick and Key. The thread still exits when a send fails (receiver
  dropped).
- Propagate password-prompt failures. utils::password /
  verified_password printed and exited the process on prompt errors
  (e.g. stdin not a TTY); their callers (main.rs, arguments/passwd.rs,
  arguments/import.rs, reading.rs) now use the Result-returning
  try_password / try_verified_password and bubble the error up, and the
  exit-on-failure wrappers are removed. utils::init_app also returns a
  proper eyre error (including the directory and the underlying
  io::Error) instead of Err(()) that main.rs replaced with a generic
  message.
Both the import and export subcommands modeled their mutually exclusive
format flags as a struct of booleans and dispatched through an if/else
ladder. The import ladder ended in a reachable
Err("Invalid arguments provided") fallthrough, and the export ladder
ended in unreachable!() guarded only by an odd Default impl that set
cotp: true.

Convert the parsed flags into proper enums instead:

- import: BackupType::format() maps the flags to a new ImportFormat
  enum via a flag table; run_command dispatches with a single
  exhaustive match, including the special-cased password-prompting
  import_aegis_encrypted() path. The Authy / Microsoft Authenticator /
  FreeOTP flags share one arm since all three import the intermediate
  ConvertedJsonList produced by the Python converters.
- export: ExportFormat::kind() maps the flags to a new ExportKind enum;
  the strange Default for ExportFormat impl is deleted and the
  "no flag means cotp format" default is expressed explicitly at the
  call site with map_or.

The CLI surface (flag names, short options, ArgGroup semantics, help
output) is unchanged; --help output was verified byte-identical before
and after.

Also add an integration test asserting that 'cotp delete' without any
of --index/--issuer/--label is rejected by clap (via
required_unless_present_any) instead of falling through to an
empty-string issuer/label match that would target the first element.
…efaulting

OTPType and OTPAlgorithm implemented From<&str> mapping any
unrecognized string to Totp / Sha1. A backup entry with an unknown or
misspelled type (e.g. "OCRA") or algorithm silently became a
wrong-code TOTP/SHA1 entry on import instead of surfacing an error.

Replace both impls with TryFrom<&str> that still parses known names
case-insensitively but returns a descriptive error ("Unknown OTP
type/algorithm: ... (expected one of ...)") for anything else.

Propagate the fallible conversion through every caller:

- importers/aegis.rs, converted.rs, freeotp_plus.rs: the per-element
  From impls become TryFrom, and the list-level TryFrom impls collect
  Results so a single bad entry fails the import with the specific
  reason (errors mapped to the modules' existing String error type).
- importers/authy_remote_debug.rs: From<AuthyExportedJsonElement> and
  From<AuthyExportedList> become TryFrom for the same reason.
- otp/from_otp_uri.rs: unknown type/algorithm in an otpauth:// URI now
  fails URI parsing via ? instead of being coerced to TOTP/SHA1.
- freeotp_plus.rs additionally derives the HOTP counter from the
  parsed OTPType instead of re-comparing the uppercased raw string.

Add unit tests covering case-insensitive parsing of all known values,
the error for unknown values, and an importer-level test showing an
import file with an unknown type now produces a clear error.
Importer and exporter boundaries used ad-hoc String errors that were
then re-wrapped (and sometimes Debug-formatted, printing quoted
strings) before reaching the user:

- The TryInto<Vec<OTPElement>> impls in aegis.rs, converted.rs,
  freeotp_plus.rs and authy_remote_debug.rs used Error = String; they
  now use eyre::Report, so the OTPType/OTPAlgorithm parse errors from
  the previous commit propagate without .to_string() round-trips.
- AegisEncryptedDatabase::decrypt and its helpers (calc_master_key,
  get_params, map_results) returned Result<_, String> built from
  format! with {e:?}; they now return eyre::Result with Display-
  formatted ({e}) causes.
- do_export in exporters/mod.rs returned Result<PathBuf, String>; it
  now returns eyre::Result<PathBuf>, and the serde failure message no
  longer Debug-formats the error.
- import_from_path now bounds the conversion error with
  Into<eyre::Report> instead of Debug, so import errors are no longer
  stringified via {:?} (which wrapped String errors in quotes in
  user-facing output). The serde deserialization error is also
  Display-formatted now.
- arguments/import.rs drops the error-laundering
  .map_err(|e| eyre!("{e}")) conversions, both on the dispatch result
  and in import_aegis_encrypted.
- arguments/export.rs wraps the export error with WrapErr context
  instead of flattening it into a new eyre! message, and main.rs
  prints errors with the alternate {e:#} format so the whole context
  chain ("outer context: root cause") stays visible to the user.

OtpError (src/otp/otp_error.rs) is intentionally left as-is: it is a
proper domain error type.

User-visible behavior is unchanged except for cleaner error messages
(no more Debug-quoted strings).
Make the needs_modification field private so the dirty flag can only be
toggled through mark_modified()/the new clear_modified(), and route every
direct field access from outside otp_element.rs through accessors:

- interface/handlers/popup.rs: the "don't save before quit" path now
  calls clear_modified() instead of writing the field directly.
- arguments/list.rs, arguments/extract.rs, exporters/freeotp_plus.rs,
  exporters/otp_uri.rs: use elements_ref() instead of touching the
  (now private) elements field.
- exporters/andotp.rs: use the new into_elements() accessor for the
  by-value export and elements_ref() for the borrowed one (which now
  converts to &[OTPElement], adjusting arguments/export.rs).
- main.rs: build the first-run database with OTPDatabase::default()
  instead of a struct literal over private fields.

save() now clears the dirty flag only AFTER a successful encrypt+write,
so a failed save leaves the database marked dirty, and documents the
passwd-path invariant: passwd persists the database itself via
save_with_pw (new salt + key), and the cleared flag is what makes the
final is_modified() check in main() skip a second save that would
re-encrypt with the old key and undo the password change.

mut_element() keeps handing out &mut OTPElement without marking the
database dirty, now with a doc comment making the contract explicit:
callers mark modified themselves, which lets no-op edits (cotp edit
with unchanged values) skip rewriting the database file.
OTPDatabase mixed domain data with encryption and filesystem writes:
save()/save_with_pw() reached into crypto:: and wrote to the global
DATABASE_PATH from inside the domain type.

Introduce src/storage/mod.rs as the persistence layer and fold the
small reading.rs module into it, so load and save live side by side:

- get_elements_from_input/get_elements_from_stdin/read_from_file moved
  from reading.rs (deleted), keeping the ReadResult flow identical;
  read_decrypted_text and read_from_file now take the database path as
  a parameter instead of reading the global.
- save(db, key, salt, path) and save_with_pw(db, password, path) moved
  from OTPDatabase; both take the path explicitly, and callers
  (main.rs, arguments/passwd.rs) pass DATABASE_PATH. migrate() still
  runs on every save, the modified flag is still cleared only after a
  successful write, and the passwd-path invariant doc moved along.
- create_database_file() (0600 on unix) and the encrypt+write logic
  moved to storage; plaintext-JSON zeroization after encryption is
  preserved. The only non-mechanical change: the encryption result is
  now propagated with `?` instead of the previous unwrap(), so an
  encryption failure reports an error instead of panicking.

OTPDatabase keeps only pure domain operations (elements accessors,
add/delete/mut, dirty flag, sort) and loses its crypto/path/fs imports.
CLAUDE.md documents the split as "app.rs holds mutable App state;
ui.rs renders", but the render functions (render_main_page,
render_qrcode_page, render_table_box, render_alert and the top-level
render dispatcher) lived on App while ui.rs only handled the terminal
lifecycle.

Move them to ui.rs as free functions taking &mut App, purely
mechanically (self -> app). The QR-code render cache keeps its exact
behavior: rendering still populates app.qrcode_cache keyed by the
selected index, and tick()/reset() in app.rs keep invalidating it.

App keeps only state and state-derived logic: title and qrcode_cache
become pub(crate) so ui.rs can read/fill them, and progress() becomes
pub(crate) but stays in app.rs since it is a state computation, not
rendering.
The CI clippy gate (cargo clippy -- -D warnings) only lints the
lib/bin target, so clippy::err_expect violations in #[cfg(test)] code
went unnoticed. Replace the three result.err().expect(...) patterns in
src/crypto/cryptography.rs tests with result.expect_err(...), making
cargo clippy --all-targets -- -D warnings pass cleanly too. No other
--all-targets lints surfaced.
Update the guidance to match what this branch changed:

- Runtime data flow: ReadResult and load/save now live in the new
  storage/ module; document the dirty-flag contract (private flag,
  mark_modified/clear_modified, mut_element not marking, flag cleared
  only after a successful write and the passwd invariant) and the
  exit codes (1 init/save errors, 2 subcommand errors).
- Key modules: add the storage/ persistence-layer bullet; OTPDatabase
  described as pure domain data; ui.rs now really owns all rendering
  (free functions over &mut App) plus the terminal lifecycle; import/
  export format dispatch through the exhaustive ImportFormat/
  ExportKind enums; extract's hand-rolled wildcard matcher; Yandex
  always HMAC-SHA256; OTPType/OTPAlgorithm TryFrom<&str> rejecting
  unknown strings.
- Common commands: note cargo clippy --all-targets is kept clean too.
- New short "Dependency notes" section: plain eyre (no color-eyre),
  data-encoding for all codecs (no hex/base64 crates), trimmed
  ratatui/qrcode features and the idna_adapter pin.
- Database format: read_from_file reference now points at storage::.
/// derive the exact same key and keep existing databases decryptable.
#[test]
fn test_derived_key_unchanged_by_thread_mode() {
let key = argon_derive_key(b"pa$$w0rd", b"0123456789abcdef").unwrap();
fn test_decrypt_invalid_base64_nonce_returns_error() {
let corrupted =
r#"{"version":1,"nonce":"!!!not-base64!!!","salt":"c2FsdA==","cipher":"Y2lwaGVy"}"#;
let result = decrypt_string(corrupted, "pa$$w0rd");
fn test_decrypt_invalid_base64_cipher_returns_error() {
let corrupted =
r#"{"version":1,"nonce":"bm9uY2U=","salt":"c2FsdA==","cipher":"!!!not-base64!!!"}"#;
let result = decrypt_string(corrupted, "pa$$w0rd");
fn test_decrypt_invalid_base64_salt_returns_error() {
let corrupted =
r#"{"version":1,"nonce":"bm9uY2U=","salt":"!!!not-base64!!!","cipher":"Y2lwaGVy"}"#;
let result = decrypt_string(corrupted, "pa$$w0rd");
)
.expect("Invalid test database JSON");

let result = database.decrypt("password");
}"#,
);

let result = calc_master_key(&slot, "password");
}"#,
);

let result = calc_master_key(&slot, "password");
}"#,
);

let result = calc_master_key(&slot, "password");
Comment thread src/utils.rs
// whose parent is the empty path and never "exists", which previously caused an
// existing database to be re-initialized and overwritten.
if db_path.exists() {
return Ok(false);
Comment thread src/utils.rs
));
}
Ok(!db_path.exists())
Ok(true)
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