Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
22dc572
fix(import): preserve HOTP counter when importing FreeOTP+ backups
replydev Jul 22, 2026
fb45b64
fix(export): create plaintext exports with 0600 permissions and stop …
replydev Jul 22, 2026
2b06bcf
fix(tui): stop plain 'c' from opening the delete confirmation popup
replydev Jul 22, 2026
31c6b33
fix(crypto): return a readable error instead of panicking on a corrup…
replydev Jul 22, 2026
9a73804
fix(tui): don't panic on HOTP counter +/- when counter is unset
replydev Jul 22, 2026
e079fc8
fix(otp): always use HMAC-SHA256 for Yandex codes and bounds-check th…
replydev Jul 22, 2026
2c89aa5
fix(import): import the pin field from Aegis backups
replydev Jul 22, 2026
d4d20e4
fix(tui): remove todo!() panic path for the unused EditOtp popup action
replydev Jul 22, 2026
e0ea498
fix(tui): exit event thread cleanly when the receiver is dropped
replydev Jul 22, 2026
5fe23e8
fix(utils): do not re-initialize an existing database given by a bare…
replydev Jul 22, 2026
de32aec
perf(crypto): compute Argon2 lanes in parallel for faster unlock
replydev Jul 22, 2026
b7ac580
fix(tui): avoid u16 underflow panic on terminals shorter than 8 rows
replydev Jul 22, 2026
cff4ccd
fix(otp): reject zero period instead of panicking with division by zero
replydev Jul 22, 2026
c49c2a3
fix(import): reject malformed encrypted Aegis backups instead of pani…
replydev Jul 22, 2026
0e0cf7a
fix(clipboard): emit OSC52 escape sequence on stderr instead of stdout
replydev Jul 22, 2026
03ddf4b
Merge crypto/exporter fixes (export permissions, corrupted-DB errors,…
replydev Jul 22, 2026
59a3518
perf(tui): cache the rendered QR code instead of rebuilding it every …
replydev Jul 22, 2026
0936606
fix(add): make the conditional clap rules for hotp/steam/yandex/motp …
replydev Jul 22, 2026
6ababed
fix(otp): stop percent-decoding the whole otpauth URI before parsing
replydev Jul 22, 2026
238d6c7
refactor(import): move Aegis password prompt out of the conversion layer
replydev Jul 22, 2026
1566d0b
fix(extract): treat --index as 1-based like every other command
replydev Jul 22, 2026
76b065c
fix(import): zeroize decrypted Aegis backup plaintext after parsing
replydev Jul 22, 2026
1fb1f84
fix(tui): refresh codes and progress gauge per-element period
replydev Jul 22, 2026
dcfd3a3
Merge importer fixes (FreeOTP+ HOTP counter, Aegis pin, encrypted-Aeg…
replydev Jul 22, 2026
ba28182
refactor(tui): single-pass ranked search and named Row columns
replydev Jul 22, 2026
4209b7a
fix(otp): make MOTP/Yandex elements round-trip through otpauth URIs
replydev Jul 22, 2026
f1c3cd8
perf(extract): replace globset with a hand-rolled wildcard matcher
replydev Jul 22, 2026
4de7657
fix(otp): zeroize the plaintext database JSON after encryption
replydev Jul 22, 2026
4b037e3
Merge TUI/clipboard fixes (per-period refresh, key handling, panic pa…
replydev Jul 22, 2026
cdd8945
fix(delete): reject index 0 and treat a declined confirmation as success
replydev Jul 22, 2026
298aa82
fix(otp): create the database file with 0600 permissions on unix
replydev Jul 22, 2026
7b2283e
fix(otp): redact secret and pin in OTPElement's Debug output
replydev Jul 22, 2026
eb28b27
fix(otp): return a message instead of panicking when QR rendering fails
replydev Jul 22, 2026
b2dfa86
fix(list): prevent padding underflow panic and fix JSON output
replydev Jul 22, 2026
bb137db
fix(reading): do not delete the database file when it is found empty
replydev Jul 22, 2026
f2f6331
refactor(otp): iterate MIGRATIONS_LIST directly instead of copy-and-sort
replydev Jul 22, 2026
4bcd317
fix(edit): validate the new secret and stop rewriting the database on…
replydev Jul 22, 2026
9645d46
Merge OTP core fixes (Yandex SHA256, period validation, URI parsing, …
replydev Jul 22, 2026
2043117
test: restore empty_database fixture to its committed content
replydev Jul 22, 2026
814bb87
fix(utils): zeroize rejected password attempts and stop panicking on …
replydev Jul 22, 2026
bddffd6
test(add): stop mutating the committed empty_database fixture
replydev Jul 22, 2026
705a4b7
Merge CLI argument fixes (init_app data loss, add validation, index b…
replydev Jul 22, 2026
d4d9a57
build(deps): replace color-eyre with plain eyre
replydev Jul 22, 2026
5839417
build(deps): consolidate hex/base64 encoding onto data-encoding
replydev Jul 22, 2026
12df2d3
build(deps): trim ratatui/qrcode features and pin idna_adapter
replydev Jul 22, 2026
004cdfa
fix(cli): clean up error handling, exit codes and dead event variants
replydev Jul 22, 2026
3fff320
refactor(cli): dispatch import/export formats through exhaustive enums
replydev Jul 22, 2026
976adca
refactor(otp): reject unknown OTP type/algorithm strings instead of d…
replydev Jul 22, 2026
6caec8e
refactor(errors): unify importer/exporter error types on eyre
replydev Jul 22, 2026
192c930
refactor(otp): harden the OTPDatabase dirty-flag API
replydev Jul 22, 2026
59dab57
refactor(storage): extract database persistence out of OTPDatabase
replydev Jul 22, 2026
558f747
refactor(tui): move rendering out of app.rs into ui.rs
replydev Jul 22, 2026
c0598e6
test(crypto): fix err().expect() clippy lints in test targets
replydev Jul 22, 2026
80fc12e
docs: bring CLAUDE.md in line with the review-fix branch
replydev Jul 22, 2026
a8633fd
Merge branch 'main' into fix/review-findings
replydev Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ cargo test --locked --release # tests in release mode (CI runs both)
cargo test <name> # run a single test by substring match
cargo fmt --all -- --check # formatting check (CI gate)
cargo clippy -- -D warnings # lint; warnings are errors (CI gate)
cargo clippy --all-targets -- -D warnings # also lints tests; kept clean too
```

CI (`.github/workflows/build.yml`) requires `cargo fmt`, `cargo clippy -D warnings`, and `cargo test` (debug + release) to pass across Linux/macOS/Windows. On Linux, building requires xcb dev libraries for clipboard support (see README "Other linux distributions").
Expand All @@ -41,25 +42,30 @@ commit-analyzer computes the next version, `ci/write_cargo_version.sh` writes it

`main.rs` drives a fixed lifecycle for every invocation:

1. `init()` — resolves the DB path, and either initializes a new encrypted database on first run (prompting for a password) or loads the existing one. Returns `ReadResult = (OTPDatabase, key, salt)`.
2. `args_parser()` (`arguments/mod.rs`) — if a subcommand was given, dispatches to it; otherwise launches the interactive `dashboard()` (TUI). Each subcommand consumes the `OTPDatabase` and returns a (possibly modified) one.
3. Back in `main()`, if `database.is_modified()` the database is re-encrypted and written to disk. The derived `key` is zeroized before exit.
1. `init()` — resolves the DB path, and either initializes a new encrypted database on first run (prompting for a password) or loads the existing one via `storage::`. Returns `ReadResult = (OTPDatabase, key, salt)` (defined in `storage/mod.rs`).
2. `args_parser()` (`arguments/mod.rs`) — if a subcommand was given, dispatches to it; otherwise launches the interactive `dashboard()` (TUI). Each subcommand consumes the `OTPDatabase` and returns a (possibly modified) one. Exit codes: 1 for init/save errors, 2 for subcommand errors.
3. Back in `main()`, if `database.is_modified()` the database is re-encrypted and written to disk via `storage::save()`. The derived `key` is zeroized before exit.

The `OTPDatabase` is passed by value through the command layer; mutations set a `needs_modification` flag (via `mark_modified()`) that gates the final save. Secrets and keys use `zeroize` throughout — preserve zeroization when touching password/key handling.
The `OTPDatabase` is passed by value through the command layer; mutations set a private dirty flag (via `mark_modified()`/`clear_modified()`) that gates the final save — `mut_element()` deliberately does NOT mark, so callers mark only on real changes (no-op edits skip the rewrite). `storage::save()` clears the flag only after a successful write; this is also what makes `passwd` safe (it saves itself with a key from the new password, and the cleared flag stops `main()` from saving again with the old key). Secrets and keys use `zeroize` throughout — preserve zeroization when touching password/key handling.

## Key modules (`src/`)

- **`arguments/`** — Clap subcommands (`add`, `edit`, `list`, `delete`, `import`, `export`, `extract`, `passwd`). Each implements the `SubcommandExecutor` trait (`fn run_command(self, db: OTPDatabase) -> Result<OTPDatabase>`), wired together with `enum_dispatch` on the `CotpSubcommands` enum. To add a subcommand: create the module, define an `Args` struct, implement `SubcommandExecutor`, and add a variant to `CotpSubcommands`.
- **`otp/`** — core domain. `otp_element.rs` holds `OTPElement` and `OTPDatabase` (serialization, save/encrypt, migrations). `algorithms/` has one generator per scheme (`totp`, `hotp`, `motp`, `steam`, `yandex`). `otp_type.rs` / `otp_algorithm.rs` are the enums; `from_otp_uri.rs` parses `otpauth://` URIs.
- **`arguments/`** — Clap subcommands (`add`, `edit`, `list`, `delete`, `import`, `export`, `extract`, `passwd`). Each implements the `SubcommandExecutor` trait (`fn run_command(self, db: OTPDatabase) -> Result<OTPDatabase>`), wired together with `enum_dispatch` on the `CotpSubcommands` enum. To add a subcommand: create the module, define an `Args` struct, implement `SubcommandExecutor`, and add a variant to `CotpSubcommands`. The mutually exclusive import/export format flags map to exhaustive internal enums (`ImportFormat` in `import.rs`, `ExportKind` in `export.rs`) — add new formats there. `extract` matches issuer/label with a hand-rolled `*`/`?` wildcard matcher (no regex/glob crate).
- **`otp/`** — core domain. `otp_element.rs` holds `OTPElement` and `OTPDatabase`; the database is pure domain data (element accessors, dirty flag, sort) — persistence lives in `storage/`. `algorithms/` has one generator per scheme (`totp`, `hotp`, `motp`, `steam`, `yandex`); Yandex always uses HMAC-SHA256 regardless of the element's stored algorithm. `otp_type.rs` / `otp_algorithm.rs` are the enums, with `TryFrom<&str>` impls that reject unknown strings instead of defaulting; `from_otp_uri.rs` parses `otpauth://` URIs.
- **`storage/`** — persistence layer. Load: `get_elements_from_input`/`get_elements_from_stdin` → `read_from_file` (password prompt, decrypt, legacy-v1 fallback). Save: `save(db, key, salt, path)` / `save_with_pw(db, password, path)` — runs `migrate()` on every save, encrypts, writes with 0600 permissions on unix, zeroizes the plaintext JSON, and clears the dirty flag only after a successful write.
- **`crypto/`** — `cryptography.rs` does Argon2id key derivation (config constants at top of file) + XChaCha20Poly1305 authenticated encryption; also AES-GCM for decrypting Aegis encrypted backups. `encrypted_database.rs` is the on-disk envelope.
- **`importers/`** — one module per source app. `importer.rs::import_from_path::<T>()` is the generic entry point: `T` must be `Deserialize + TryInto<Vec<OTPElement>>`. Import selection happens in `arguments/import.rs`. Some sources (Authy, Microsoft Authenticator, FreeOTP) are pre-converted by Python scripts to `ConvertedJsonList` first (see below); others deserialize natively. Google Authenticator is handled natively by `google_authenticator.rs`, which parses `otpauth-migration://` export URIs (base64 protobuf `MigrationPayload`, decoded with `prost` using hand-declared message structs — no `.proto`/`protoc` build step).
- **`exporters/`** — `andotp`, `freeotp_plus`, `otp_uri`. `do_export::<T: Serialize>()` is the shared writer.
- **`interface/`** — the ratatui/crossterm TUI. `app.rs` holds mutable `App` state; `ui.rs` renders; `event.rs` is the input event loop (250ms tick); `handlers/` route key events by focus (`main_window`, `popup`, `search_bar`). The dashboard runs on `io::stderr()` so stdout stays clean for piping.
- **`exporters/`** — `andotp`, `freeotp_plus`, `otp_uri`. `do_export::<T: Serialize>()` is the shared writer (0600 permissions on unix).
- **`interface/`** — the ratatui/crossterm TUI. `app.rs` holds mutable `App` state (including the cached rendered QR code); `ui.rs` owns the terminal lifecycle (`Tui`) and all rendering, as free functions taking `&mut App`; `event.rs` is the input event loop (250ms tick); `handlers/` route key events by focus (`main_window`, `popup`, `search_bar`). The dashboard runs on `io::stderr()` so stdout stays clean for piping.

## Dependency notes

Errors use plain `eyre` (not color-eyre). All base32/hex/base64 codecs go through `data-encoding` (no `hex`/`base64` crates). `ratatui` and `qrcode` are built with trimmed feature sets, and `url`'s IDNA backend is pinned to the small unicode-rs `idna_adapter` — keep this binary-size budget in mind when adding dependencies.

## Database format & migrations

- Default path resolution (`path.rs`): `--database-path` arg > `COTP_DB_PATH` env > `./db.cotp` (portable / debug builds always) > `$XDG_DATA_HOME/cotp/db.cotp` (auto-migrated from legacy `$HOME/.cotp/db.cotp` if present). The path is a `OnceLock` set once at startup.
- `CURRENT_DATABASE_VERSION` (in `otp_element.rs`) is the schema version. Legacy v1 was a bare `Vec<OTPElement>`; `read_from_file` falls back to parsing that and converts via `From<Vec<OTPElement>>`. Schema upgrades go in `otp/migrations/mod.rs` — add a `Migration { to_version, migration_function }` entry to `MIGRATIONS_LIST`; `migrate()` runs on every save.
- `CURRENT_DATABASE_VERSION` (in `otp_element.rs`) is the schema version. Legacy v1 was a bare `Vec<OTPElement>`; `storage::read_from_file` falls back to parsing that and converts via `From<Vec<OTPElement>>`. Schema upgrades go in `otp/migrations/mod.rs` — add a `Migration { to_version, migration_function }` entry to `MIGRATIONS_LIST`; `migrate()` runs on every save (from `storage::save`).

## Python converters (`converters/`)

Expand Down
Loading