diff --git a/README.md b/README.md index e980446..d57b092 100644 --- a/README.md +++ b/README.md @@ -128,9 +128,9 @@ When you generate a credential you will: - `bitwarden`: supports login CSV fields (`name`, `login_username`, `login_password`, `type`, etc.). Non-login item types are skipped. - `apple`: supports Apple-style title/url/username/password exports (with optional notes/OTP columns). - `nordpass`: supports NordPass CSV template columns. -- **Data model note**: The vault currently stores only `service`, `username`, and `password`. - - During export, unsupported provider fields (folder, notes, TOTP, cards, custom fields, etc.) are emitted as empty/default values. - - During import, unsupported/non-credential rows are ignored with a reason. +- **Data model note**: The vault stores `service`, `username`, `password`, `url`, and `note` (with optional hidden flag). + - During export, unsupported provider fields (folder, TOTP, cards, custom fields, etc.) are emitted as empty/default values. + - During import, unsupported/non-credential rows are ignored with a reason. Imported `url` values (e.g., `login_uri`) are preserved. > **⚠️ Security:** CSV exports are plaintext — they are not encrypted and anyone with filesystem access can read them. > All export formats automatically escape formula-triggering characters (`=`, `+`, `-`, `@`) to prevent @@ -194,26 +194,28 @@ All security checks run automatically on every commit via GitHub Actions. For a security-sensitive offline credential vault, the following scope limits are documented so users can make an informed decision about acceptable risk. -- **Brute-force lockout is process-local, not persistent.** The escalating - unlock lockout (`30s`, `5m`, `30m`) is enforced only while the application - is running. A restart resets the counter; the delay is never persisted to - disk. An attacker with local restart capability could attempt unlimited - guesses across restarts. This is a deliberate trade-off (no durable - anti-bruteforce state is written to disk). +- **Brute-force lockout is now persistent.** Since `0.2.4` the escalating + unlock lockout (`30s`, `5m`, `30m`) is persisted in the vault `config` table + (`lockout_failed_attempts`/`lockout_until_epoch`) and survives restarts. A + successful unlock clears it. Pre-`0.2.4` vaults had process-local only lockout. - **Migration backups are retained indefinitely.** Each one-time upgrade (v1→v2, identity schema, zero-width identity, AAD re-encryption) writes a backup next to the vault (e.g. `vault.db.identity_zw.bak`). These backups - are encrypted with the same vault key and are never auto-deleted; you should - remove obsolete backups manually once you are confident the vault is healthy. + are encrypted with the same vault key and are never auto-deleted; prune via + `generate-it --prune-backups 1` or `m` → `p` (pruning now orders by `st_ctime` + not forgeable `st_mtime`). Remove obsolete backups once vault is healthy. - **Metadata sanitization is partial.** Stored credential notes are clamped and truncated to a safe bound before rendering, but the raw `service` and `username` fields are drawn to the screen as provided. A malicious or unexpected value could display terminal control sequences. Only enter - service/usernames you trust into your own vault. + service/usernames you trust into your own vault. CSV export now escapes + `=+-@|%` (and tab/CR-prefixed formulas via stripped check) for spreadsheet + safety; vault search is `LIKE` on `service/username/url` (final `LIMIT 500` + < `LIKE_LIMIT 2000`) — true FTS5 would be needed for 2B-scale. ## Custom word list -The included word list contains **5,800** lowercase words filtered from `/usr/share/dict/words` (a-z only, 4–10 characters), providing ~50 bits of entropy for a 4-word passphrase. +The included word list contains **7,690** lowercase words sourced from the EFF Large Wordlist (filtered to lowercase a-z, 4–10 characters), providing ~51.6 bits of entropy for a 4-word passphrase. Override the word list with the `GENERATE_IT_WORDLIST` environment variable: @@ -221,6 +223,16 @@ Override the word list with the `GENERATE_IT_WORDLIST` environment variable: Otherwise, Generate It uses the bundled default word list. +## Vault maintenance + +Migration and rotation operations create backups next to the vault (e.g., `vault.db.v1.bak`, `vault.db.dek.bak`, `vault.db.pwd.bak`, `vault.db.aad_v*.bak`). + +- Check vault health: `generate-it --check-vault` or press `m` in the TUI (vault health view). +- Check integrity: `generate-it --check-integrity` or `m` → `c` in the TUI. +- Prune old backups: `generate-it --prune-backups 1` or `m` → `p` in the TUI (keeps newest 1). + +Backups are never auto-deleted; remove obsolete backups manually once you are confident the vault is healthy. + ## Development ### Setup diff --git a/generate_it/__main__.py b/generate_it/__main__.py index ee4c1b0..8562d1f 100644 --- a/generate_it/__main__.py +++ b/generate_it/__main__.py @@ -1,15 +1,257 @@ """Generate It entrypoint. Runs the curses TUI for generating passwords, passphrases, and usernames. +Also provides headless CLI helpers (--check-vault, --change-password). """ from __future__ import annotations +import argparse +import getpass +import json +import sys +from pathlib import Path + from . import tui +from .storage import StorageManager + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="generate-it", description="Generate It — credential generator + vault") + parser.add_argument("--vault-path", type=Path, default=None, help="Custom vault.db path") + parser.add_argument("--check-vault", action="store_true", help="Print vault status (no TUI) and exit") + parser.add_argument("--json", action="store_true", help="With --check-vault, output JSON") + parser.add_argument("--change-password", action="store_true", help="Change master password (headless)") + parser.add_argument("--prune-backups", type=int, metavar="N", default=None, help="Prune backups, keep N newest (headless)") + parser.add_argument("--check-integrity", action="store_true", help="Decrypt all credentials and report issues (requires unlock)") + # Generation (headless, vault-independent) + parser.add_argument("--generate-password", action="store_true", help="Generate a random password and print it") + parser.add_argument("--generate-passphrase", action="store_true", help="Generate a passphrase and print it") + parser.add_argument("--generate-username", action="store_true", help="Generate a username and print it") + parser.add_argument("--length", type=int, default=None, help="Password length (8-64) or username random length (3-64)") + parser.add_argument("--words", type=int, default=None, help="Word count for passphrase (3-10) or username words (1-3)") + parser.add_argument("--no-letters", action="store_true", help="Exclude letters from password") + parser.add_argument("--no-numbers", action="store_true", help="Exclude numbers from password") + parser.add_argument("--special", action="store_true", help="Include special characters (password) or add special token (passphrase)") + parser.add_argument("--add-numbers", action="store_true", help="Add numbers to passphrase/username") + parser.add_argument("--username-style", choices=["adjective", "random", "words"], default=None, help="Username style") + parser.add_argument("--separator", choices=["_", "-"], default=None, help="Separator for username words/adjective style") + parser.add_argument("--copy", action="store_true", help="Copy generated credential to clipboard") + return parser + + +def _storage_for_args(args: argparse.Namespace) -> StorageManager: + return StorageManager(db_path=args.vault_path) if args.vault_path else StorageManager() + + +def _run_check_vault(args: argparse.Namespace) -> int: + storage = _storage_for_args(args) + status = storage.get_vault_status() + if args.json: + print(json.dumps(status, indent=2)) + else: + for key, value in status.items(): + print(f"{key}: {value}") + return 0 + + +def _run_prune_backups(args: argparse.Namespace) -> int: + storage = _storage_for_args(args) + deleted = storage.prune_backups(keep_latest=args.prune_backups if args.prune_backups is not None else 1) + print(f"Deleted {len(deleted)} backup(s).") + for path in deleted: + print(f" - {path}") + return 0 + + +def _run_check_integrity(args: argparse.Namespace) -> int: + storage = _storage_for_args(args) + if not storage.vault_exists(): + print("No vault found.", file=sys.stderr) + return 1 + password = getpass.getpass("Master password: ") + try: + storage.unlock_vault(password) + except Exception as exc: + print(f"Unlock failed: {exc}", file=sys.stderr) + return 1 + finally: + # Minimize lifetime of plaintext master password. + try: + password = "\x00" * len(password) + except Exception: + pass + try: + del password + except Exception: + pass + issues = storage.check_vault_integrity() + if not issues: + print("Integrity OK: all credentials decrypt correctly.") + return 0 + print(f"Found {len(issues)} issue(s):") + for item in issues: + print(f" id={item['id']} service={item['service']} error={item['error']}") + return 1 + + +def _run_change_password_cli(args: argparse.Namespace) -> int: + storage = _storage_for_args(args) + if not storage.vault_exists(): + print("No vault found.", file=sys.stderr) + return 1 + current = getpass.getpass("Current master password: ") + new_one = getpass.getpass("New master password: ") + confirm = getpass.getpass("Confirm new master password: ") + if new_one != confirm: + print("New passwords do not match.", file=sys.stderr) + # Clear promptly even on mismatch — overwrite each variable explicitly + # (loop variable rebinding would not affect the originals). + try: + current = "\x00" * len(current) + except Exception: + pass + try: + new_one = "\x00" * len(new_one) + except Exception: + pass + try: + confirm = "\x00" * len(confirm) + except Exception: + pass + try: + del current, new_one, confirm + except Exception: + pass + return 1 + try: + storage.unlock_vault(current) + except Exception as exc: + print(f"Unlock failed: {exc}", file=sys.stderr) + return 1 + try: + storage.change_master_password(current, new_one) + except Exception as exc: + print(f"Password change failed: {exc}", file=sys.stderr) + return 1 + finally: + try: + current = "\x00" * len(current) + except Exception: + pass + try: + new_one = "\x00" * len(new_one) + except Exception: + pass + try: + confirm = "\x00" * len(confirm) + except Exception: + pass + try: + del current, new_one, confirm + except Exception: + pass + print("Master password changed.") + return 0 + + +def _maybe_copy_cli(text: str) -> None: + try: + import pyperclip # local import so tests without clipboard still work + pyperclip.copy(text) + print("(copied to clipboard)", file=sys.stderr) + except Exception: + pass + + +def _run_generate_password_cli(args: argparse.Namespace) -> int: + from .generator import generate_character_password, MIN_PASSWORD_CHARS, MAX_PASSWORD_CHARS + length = args.length if args.length is not None else 12 + if not MIN_PASSWORD_CHARS <= length <= MAX_PASSWORD_CHARS: + print(f"length must be between {MIN_PASSWORD_CHARS} and {MAX_PASSWORD_CHARS}", file=sys.stderr) + return 2 + use_letters = not args.no_letters + use_numbers = not args.no_numbers + use_special = bool(args.special) + try: + pwd = generate_character_password(length, use_letters=use_letters, use_numbers=use_numbers, use_special=use_special) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + print(pwd) + if args.copy: + _maybe_copy_cli(pwd) + return 0 + + +def _run_generate_passphrase_cli(args: argparse.Namespace) -> int: + from .generator import generate_passphrase, MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS + count = args.words if args.words is not None else 4 + if not MIN_PASSPHRASE_WORDS <= count <= MAX_PASSPHRASE_WORDS: + print(f"words must be between {MIN_PASSPHRASE_WORDS} and {MAX_PASSPHRASE_WORDS}", file=sys.stderr) + return 2 + try: + phrase = generate_passphrase(count, add_numbers=bool(args.add_numbers), add_special=bool(args.special)) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + print(phrase) + if args.copy: + _maybe_copy_cli(phrase) + return 0 + + +def _run_generate_username_cli(args: argparse.Namespace) -> int: + from .generator import generate_username_adjective_noun, generate_username_random, generate_username_words + style = args.username_style or "adjective" + separator = args.separator or "_" + add_numbers = bool(args.add_numbers) + try: + if style == "adjective": + username = generate_username_adjective_noun(add_numbers=add_numbers, separator=separator) + elif style == "random": + length = args.length if args.length is not None else 12 + sep_style = "underscore" if separator == "_" else "hyphen" if separator == "-" else "none" + # For random style, honour --length, default 12 + username = generate_username_random(length, separator_style=sep_style if args.separator else "none") + else: # words + count = args.words if args.words is not None else 2 + username = generate_username_words(count, add_numbers=add_numbers, separator=separator) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + print(username) + if args.copy: + _maybe_copy_cli(username) + return 0 def main(argv: list[str] | None = None) -> int: - """Run the TUI.""" + """Run the TUI or a headless subcommand.""" + parser = _build_parser() + if argv is None: + # Avoid parsing pytest's sys.argv when called programmatically (tests). + # Only parse real CLI invocation (argv contains generate-it or __main__). + prog = sys.argv[0] if sys.argv else "" + if "generate-it" in prog or "generate_it" in prog: + argv = sys.argv[1:] + else: + argv = [] + args = parser.parse_args(argv) + if args.check_vault: + return _run_check_vault(args) + if args.prune_backups is not None: + return _run_prune_backups(args) + if args.check_integrity: + return _run_check_integrity(args) + if args.change_password: + return _run_change_password_cli(args) + if args.generate_password: + return _run_generate_password_cli(args) + if args.generate_passphrase: + return _run_generate_passphrase_cli(args) + if args.generate_username: + return _run_generate_username_cli(args) return tui.run() diff --git a/generate_it/_crypto_v2.py b/generate_it/_crypto_v2.py index 193d59a..caf59cd 100644 --- a/generate_it/_crypto_v2.py +++ b/generate_it/_crypto_v2.py @@ -44,9 +44,12 @@ SALT_LEN: int = 32 # Argon2id min/max bounds for config validation. -MIN_ARGON2_MEMORY: int = 4096 # 4 MiB +# Hardened to OWASP 2023 low recommendation (19 MiB / ~19,456 KiB, time>=2) +# to prevent downgrade attacks via tampered vault config. Defaults remain +# at 64 MiB / 3 iter for stronger posture. +MIN_ARGON2_MEMORY: int = 19456 # 19 MiB (OWASP low) MAX_ARGON2_MEMORY: int = 1048576 # 1 GiB -MIN_ARGON2_TIME: int = 1 +MIN_ARGON2_TIME: int = 2 MAX_ARGON2_TIME: int = 100 MIN_ARGON2_PARALLELISM: int = 1 MAX_ARGON2_PARALLELISM: int = 64 diff --git a/generate_it/constants.py b/generate_it/constants.py index 295bbfc..4696d5b 100644 --- a/generate_it/constants.py +++ b/generate_it/constants.py @@ -7,6 +7,23 @@ AUTO_LOCK_SCREEN_OFF = "screen_off" SCREEN_OFF_LOCK_GAP_SECONDS = 20.0 +# 60 fps redraw / streaming constants (no hard-coded inline values). +TUI_TARGET_FPS = 60 +TUI_FRAME_INTERVAL_MS = 1000 // TUI_TARGET_FPS # ~16 ms +TUI_FRAME_INTERVAL_S = 1.0 / TUI_TARGET_FPS +TUI_CLOCK_REFRESH_S = 1.0 +TUI_MIN_WIDTH = 70 +TUI_MIN_HEIGHT = 20 +TUI_MIN_TERM_WIDTH = 40 +TUI_MIN_TERM_HEIGHT = 10 +TUI_RESIZE_HINT_WIDTH = 80 +TUI_RESIZE_HINT_HEIGHT = 24 +TUI_INPUT_TIMEOUT_MS = TUI_FRAME_INTERVAL_MS # streaming input poll +TUI_MODAL_TIMEOUT_MS = TUI_FRAME_INTERVAL_MS +TUI_RENDER_CACHE_TTL_S = TUI_FRAME_INTERVAL_S +TUI_OUTPUT_WRAP_CACHE_SIZE = 32 +TUI_ENTROPY_CACHE_SIZE = 64 + CLIPBOARD_AUTO_CLEAR_OPTIONS: tuple[tuple[str, int | None], ...] = ( ("No auto-clear", None), ("15 seconds", 15), @@ -42,6 +59,49 @@ _MAX_CSV_FILE_BYTES = 10 * 1024 * 1024 # 10 MB _MAX_CSV_ROWS = 10_000 _MAX_CSV_FIELD_BYTES = 500 +_MAX_URL_BYTES = 2048 + +# SQLite performance / durability pragmas (reusable, no hard-coded inline values). +_SQLITE_BUSY_TIMEOUT_MS = 5000 +_SQLITE_JOURNAL_MODE = "WAL" +_SQLITE_SYNCHRONOUS = "NORMAL" +_SQLITE_CACHE_SIZE_PAGES = -64000 # ~64 MiB (negative = KiB) +_SQLITE_TEMP_STORE = "MEMORY" +_SQLITE_FOREIGN_KEYS = "ON" + +# Batch settings fetch (reusable). +_APP_SETTINGS_BATCH_KEYS: tuple[str, ...] = ( + "clipboard_auto_clear_index", + "auto_lock_index", +) + +# Vault pagination / filtering (reusable, no inline limits). +_VAULT_PAGE_SIZE = 200 +_VAULT_FILTER_MAX_RESULTS = 500 +_VAULT_FUZZY_MAX_CANDIDATES = 500 + +# File picker cache (reusable). +_FILE_PICKER_MAX_FILES = 5000 +_FILE_PICKER_MAX_DEPTH = 8 +_FILE_PICKER_CACHE_TTL_SECONDS = 2.0 + +# Wordlist cache (reusable). +_WORDLIST_CACHE_MAX_SIZE = 8 +_WORDLIST_HASH_CHUNK_BYTES = 65536 +_WORDLIST_HASH_DIGEST_SIZE = 16 +_MAX_WORDLIST_FILE_BYTES = 5 * 1024 * 1024 # 5 MB — prevent DoS via huge wordlist +_MAX_WORDLIST_WORDS = 1_000_000 # sanity cap for unique words + +# Identity cache / search (flat, reusable). +_IDENTITY_CACHE_SIZE = 4096 +_VAULT_SEARCH_SQL_LIMIT = 500 # LIMIT for search_credential_metadata() LIKE query +_VAULT_SEARCH_SQL_LIKE_LIMIT = 2000 # threshold: preload identity map only when count <= this (import_from_csv) + +# TUI render caches (flat, reusable). +_TUI_MAX_BITS_CACHE_SIZE = 32 + +# Vault integrity streaming (flat, reusable). +_VAULT_INTEGRITY_BATCH_SIZE = 200 # Identity schema marker _IDENTITY_SCHEMA_VERSION = 1 diff --git a/generate_it/csv_formats.py b/generate_it/csv_formats.py index 7b5e7fa..f372094 100644 --- a/generate_it/csv_formats.py +++ b/generate_it/csv_formats.py @@ -60,12 +60,14 @@ "username": ("username", "login", "user", "email", "login_username"), "password": ("password", "pass", "login_password"), "note": ("note", "notes"), + "url": ("url", "login_uri", "website", "address"), }, "bitwarden": { "service": ("name", "title", "service"), "username": ("login_username", "username", "login", "user"), "password": ("login_password", "password", "pass"), "note": ("notes", "note"), + "url": ("login_uri", "url", "website"), "type": ("type",), }, "apple": { @@ -73,12 +75,14 @@ "username": ("username", "login", "user", "email"), "password": ("password", "pass"), "note": ("notes", "note"), + "url": ("url", "website", "address"), }, "nordpass": { "service": ("name", "title", "service"), "username": ("username", "login", "user", "email"), "password": ("password", "pass"), "note": ("note", "notes"), + "url": ("url", "website", "address"), "type": ("type",), }, } @@ -130,18 +134,20 @@ def normalize_header_name(value: str) -> str: return value.strip().lower().replace(" ", "_").replace("-", "_") -_FORMULA_TRIGGERS: frozenset[str] = frozenset({"=", "+", "-", "@"}) +_FORMULA_TRIGGERS: frozenset[str] = frozenset({"=", "+", "-", "@", "|", "%"}) def _escape_formula(value: str) -> str: """Prefix values that start with formula-triggering characters with a single quote. Spreadsheet applications like Excel, LibreOffice, and Google Sheets interpret - cells starting with ``=``, ``+``, ``-``, or ``@`` as formulas. Prepending a - single-quote character escapes the cell so it is treated as literal text. + cells starting with ``=``, ``+``, ``-``, ``@``, ``|``, ``%`` as formulas + (OWASP CSV Injection). Prepending a single-quote escapes the cell so it + is treated as literal text. - Leading whitespace is stripped before checking for triggers, per the CSV - Injection Prevention OWASP guidance. + Leading whitespace is stripped before checking for triggers, per OWASP. + Tab-prefixed formulas like ``\\t+SUM`` are still caught because ``lstrip`` + removes the tab and exposes the ``+``. """ stripped = value.lstrip() if stripped and stripped[0] in _FORMULA_TRIGGERS: @@ -228,13 +234,14 @@ def build_export_row( username: str, password: str, note: str = "", + url: str = "", ) -> list[str]: normalized = normalize_export_format(export_format) if normalized == "generic": return [ _escape_formula(service), - "", + _escape_formula(url), _escape_formula(username), _escape_formula(password), _escape_formula(note), @@ -243,7 +250,7 @@ def build_export_row( if normalized == "spreadsheet-safe": return [ _escape_formula(service), - "", + _escape_formula(url), _escape_formula(username), _escape_formula(password), _escape_formula(note), @@ -258,7 +265,7 @@ def build_export_row( _escape_formula(note), # notes "", # fields "0", # reprompt - "", # login_uri + _escape_formula(url), # login_uri _escape_formula(username), # login_username _escape_formula(password), # login_password "", # login_totp @@ -267,7 +274,7 @@ def build_export_row( if normalized == "apple": return [ _escape_formula(service), # Title - "", # URL + _escape_formula(url), # URL _escape_formula(username), # Username _escape_formula(password), # Password _escape_formula(note), # Notes @@ -277,7 +284,7 @@ def build_export_row( # nordpass return [ _escape_formula(service), # name - "", # url + _escape_formula(url), # url _escape_formula(username), # username _escape_formula(password), # password _escape_formula(note), # note @@ -300,6 +307,44 @@ def build_export_row( ] +def _normalize_row_flat(row: Mapping[str, str | None]) -> Dict[str, str]: + """Flat helper: normalize a CSV row's keys/values once, reusable.""" + return { + normalize_header_name(k): (v or "").strip() + for k, v in row.items() + if k is not None + } + + +def _normalized_aliases_flat( + aliases: Mapping[str, Sequence[str]], +) -> Dict[str, tuple[str, ...]]: + """Flat helper: pre-normalize alias keys for fast lookup, reusable.""" + return { + field: tuple(normalize_header_name(k) for k in keys) + for field, keys in aliases.items() + } + + +def _validate_row_type_flat( + normalized_row: Mapping[str, str], + aliases: Mapping[str, Sequence[str]], + normalized_format: str, + row_num: int, +) -> str | None: + """Flat helper: validate type field, return error or None, reusable.""" + if "type" not in aliases: + return None + type_value = _first_present(normalized_row, aliases["type"]).lower() + if normalized_format == "bitwarden": + if type_value not in ("", "login", "1"): + return f"Row {row_num}: Unsupported item type '{type_value or 'unknown'}'" + elif normalized_format == "nordpass": + if type_value and type_value not in ("password", "login", "credential"): + return f"Row {row_num}: Unsupported item type '{type_value}'" + return None + + def parse_import_row( row: Mapping[str, str | None], *, @@ -309,28 +354,17 @@ def parse_import_row( normalized_format = normalize_import_format(import_format) if normalized_format == "auto": raise ValueError("parse_import_row() requires a resolved import format, not 'auto'.") - - aliases = _IMPORT_FIELD_ALIASES[normalized_format] - normalized_row = { - normalize_header_name(k): (v or "").strip() - for k, v in row.items() - if k is not None - } - - if "type" in aliases: - type_value = _first_present(normalized_row, aliases["type"]).lower() - if normalized_format == "bitwarden": - if type_value not in ("", "login", "1"): - return None, f"Row {row_num}: Unsupported item type '{type_value or 'unknown'}'" - elif normalized_format == "nordpass": - if type_value and type_value not in ("password", "login", "credential"): - return None, f"Row {row_num}: Unsupported item type '{type_value}'" - + raw_aliases = _IMPORT_FIELD_ALIASES[normalized_format] + aliases = _normalized_aliases_flat(raw_aliases) + normalized_row = _normalize_row_flat(row) + err = _validate_row_type_flat(normalized_row, aliases, normalized_format, row_num) + if err: + return None, err service = _first_present(normalized_row, aliases["service"]) username = _first_present(normalized_row, aliases["username"]) password = _first_present(normalized_row, aliases["password"]) note = _first_present(normalized_row, aliases.get("note", ("note", "notes"))) - + url = _first_present(normalized_row, aliases.get("url", ("url", "login_uri"))) missing = [] if not service: missing.append("service/name") @@ -338,11 +372,10 @@ def parse_import_row( missing.append("username/login") if not password: missing.append("password") - if missing: return None, f"Row {row_num}: Missing required field(s): {', '.join(missing)}" + return {"service": service, "username": username, "password": password, "note": note, "url": url}, None - return {"service": service, "username": username, "password": password, "note": note}, None def extract_row_identity( row: Mapping[str, str | None], @@ -352,22 +385,17 @@ def extract_row_identity( normalized_format = normalize_import_format(import_format) if normalized_format == "auto": raise ValueError("extract_row_identity() requires a resolved import format, not 'auto'.") - - aliases = _IMPORT_FIELD_ALIASES[normalized_format] - normalized_row = { - normalize_header_name(k): (v or "").strip() - for k, v in row.items() - if k is not None - } + aliases = _normalized_aliases_flat(_IMPORT_FIELD_ALIASES[normalized_format]) + normalized_row = _normalize_row_flat(row) service = _first_present(normalized_row, aliases["service"]) username = _first_present(normalized_row, aliases["username"]) return service, username def _first_present(row: Mapping[str, str], keys: Sequence[str]) -> str: + """Flat helper: first present key, assumes keys already normalized.""" for key in keys: - normalized_key = normalize_header_name(key) - value = row.get(normalized_key, "") + value = row.get(key, "") if value: return value return "" diff --git a/generate_it/generator.py b/generate_it/generator.py index 8469e85..0082458 100644 --- a/generate_it/generator.py +++ b/generate_it/generator.py @@ -8,32 +8,56 @@ from pathlib import Path import functools import hashlib +import logging import math import os import secrets import string -MIN_PASSWORD_CHARS = 8 -MAX_PASSWORD_CHARS = 64 - -MIN_PASSPHRASE_WORDS = 3 -MAX_PASSPHRASE_WORDS = 10 - -MIN_USERNAME_LENGTH = 3 -MAX_USERNAME_LENGTH = 64 -MIN_USERNAME_WORDS = 1 -MAX_USERNAME_WORDS = 3 - -LETTERS = string.ascii_letters -NUMBERS = string.digits -SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{};:,.?/" - -# Used when the user asks to add special characters to a passphrase. -PASSPHRASE_SPECIALS = "!@#$%&*?" # nosec B105 — character pool, not a credential - -# Username-related character sets. -USERNAME_ALPHANUMERIC = string.ascii_lowercase + string.digits -USERNAME_SEPARATORS = frozenset(["_", "-"]) +_log = logging.getLogger("generator") + +from .constants import ( + DEFAULT_WORDLIST, + LETTERS, + MAX_PASSPHRASE_WORDS, + MAX_PASSWORD_CHARS, + MAX_USERNAME_LENGTH, + MAX_USERNAME_WORDS, + MIN_PASSPHRASE_WORDS, + MIN_PASSWORD_CHARS, + MIN_USERNAME_LENGTH, + MIN_USERNAME_WORDS, + NUMBERS, + PASSPHRASE_SPECIALS, + SPECIAL_CHARACTERS, + USERNAME_ALPHANUMERIC, + USERNAME_SEPARATORS, + _MAX_WORDLIST_FILE_BYTES, + _MAX_WORDLIST_WORDS, + _MIN_PASSPHRASE_ENTROPY_BITS, + _WORDLIST_CACHE_MAX_SIZE as _CONST_WORDLIST_CACHE_MAX_SIZE, + _WORDLIST_HASH_CHUNK_BYTES as _CONST_WORDLIST_HASH_CHUNK_BYTES, + _WORDLIST_HASH_DIGEST_SIZE as _CONST_WORDLIST_HASH_DIGEST_SIZE, +) + +# Re-export for backwards compatibility; constants.py is the single source of truth. +__all__ = [ + "DEFAULT_WORDLIST", + "MIN_PASSWORD_CHARS", + "MAX_PASSWORD_CHARS", + "MIN_PASSPHRASE_WORDS", + "MAX_PASSPHRASE_WORDS", + "MIN_USERNAME_LENGTH", + "MAX_USERNAME_LENGTH", + "MIN_USERNAME_WORDS", + "MAX_USERNAME_WORDS", + "LETTERS", + "NUMBERS", + "SPECIAL_CHARACTERS", + "PASSPHRASE_SPECIALS", + "USERNAME_ALPHANUMERIC", + "USERNAME_SEPARATORS", +] # Wordlist lookup order: # 1) explicit `path` argument @@ -41,39 +65,6 @@ # 3) packaged default: generate_it/wordlist.txt PACKAGED_WORDLIST_PATH = Path(__file__).with_name("wordlist.txt") -DEFAULT_WORDLIST = [ - # Small built-in fallback list (you can expand by editing wordlist.txt). - "apple", - "brisk", - "candle", - "delta", - "ember", - "forest", - "glacier", - "harbor", - "island", - "jupiter", - "kitten", - "lantern", - "meadow", - "nebula", - "ocean", - "pepper", - "quartz", - "river", - "sunrise", - "tiger", - "umbrella", - "violet", - "willow", - "xenon", - "yellow", - "zephyr", -] - -# Minimum entropy the wordlist must provide for a 4-word passphrase. -_MIN_PASSPHRASE_ENTROPY_BITS = 50.0 - class WordlistSecurityError(ValueError): """Raised when a custom wordlist is too small for secure passphrases.""" @@ -82,15 +73,13 @@ class WordlistSecurityError(ValueError): def _ordered_sample_entropy_bits(n: int, k: int) -> float: """Entropy (bits) of selecting k items without replacement from n. - - bits = log2(n! / (n-k)!) = sum(log2(i)) for i in range(n-k+1, n+1) + + bits = log2(n! / (n-k)!) — uses lgamma for O(1) without loop. """ if k > n or n <= 0 or k <= 0: return 0.0 - total = 0.0 - for i in range(n - k + 1, n + 1): - total += math.log2(i) - return total + # lgamma(n+1) = ln(n!), so bits = (ln(n!) - ln((n-k)!)) / ln2 + return (math.lgamma(n + 1) - math.lgamma(n - k + 1)) / math.log(2) # Adjectives for username generation. @@ -337,11 +326,46 @@ def secure_shuffle(items: list[str]) -> None: def _dedupe_preserve_order(items: list[str]) -> list[str]: + """Flat helper: dedupe while preserving order, reusable.""" return list(dict.fromkeys(items)) def _secure_sample_without_replacement(words: list[str], count: int) -> list[str]: - """Select distinct words with CSPRNG indices and no shifting removals.""" + """Select distinct words with CSPRNG indices — O(k) allocations, reusable. + + Avoids copying the entire wordlist. Uses a flat, modular partial-sample + with a dict mapping for swapped indices, so memory travel stays constant + for large wordlists (2B-scale). + """ + if count <= 0: + return [] + if count > len(words): + raise ValueError("count exceeds wordlist size") + # Flat O(k) sampling without full pool copy. + index_map: dict[int, int] = {} + result: list[str] = [] + n = len(words) + for position in range(count): + # Random index in [position, n-1] + rand_offset = secrets.randbelow(n - position) + selected_index = position + rand_offset + # Resolve actual indices via swap map + pos_val = index_map.get(position, position) + sel_val = index_map.get(selected_index, selected_index) + # Record selection and update map + result.append(words[sel_val]) + index_map[selected_index] = pos_val + # No need to keep position entry beyond this iteration + if selected_index != position: + index_map[position] = sel_val + else: + # When we swapped with itself, keep map consistent + index_map[position] = pos_val + return result + + +def _secure_sample_without_replacement_pool(words: list[str], count: int) -> list[str]: + """Legacy pool-copy sampler kept for reference — not used in hot path.""" pool = list(words) for position in range(count): selected_index = position + secrets.randbelow(len(pool) - position) @@ -367,7 +391,9 @@ def resolve_wordlist_source(path: Path | None = None) -> tuple[Path | None, bool _WORDLIST_CACHE: dict[Path | None, tuple[int, int, bytes, tuple[str, ...]]] = {} -_WORDLIST_CACHE_MAX_SIZE = 8 +_WORDLIST_CACHE_MAX_SIZE = _CONST_WORDLIST_CACHE_MAX_SIZE +_WORDLIST_HASH_CHUNK_BYTES = _CONST_WORDLIST_HASH_CHUNK_BYTES +_WORDLIST_HASH_DIGEST_SIZE = _CONST_WORDLIST_HASH_DIGEST_SIZE def clear_wordlist_cache() -> None: @@ -375,26 +401,177 @@ def clear_wordlist_cache() -> None: _WORDLIST_CACHE.clear() +def _validate_custom_wordlist_path(path: Path) -> None: + """Reject symlinks, non-regular files, and oversize files.""" + try: + lst = path.lstat() + except OSError as exc: + raise WordlistSecurityError(f"Cannot stat wordlist: {exc}") from exc + import stat as _stat + if _stat.S_ISLNK(lst.st_mode): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") + if not _stat.S_ISREG(lst.st_mode): + raise WordlistSecurityError("Custom wordlist is not a regular file.") + if lst.st_size > _MAX_WORDLIST_FILE_BYTES: + raise WordlistSecurityError( + f"Custom wordlist too large ({lst.st_size} bytes). Maximum: {_MAX_WORDLIST_FILE_BYTES} bytes." + ) + + def _get_file_signature(path: Path | None) -> tuple[Path | None, int, int]: - """Return resolved path and metadata used for the wordlist fast path.""" + """Return resolved path and metadata used for the wordlist fast path. + + Uses ``lstat`` so a symlink is not followed — the caller must have already + validated the path via :func:`_validate_custom_wordlist_path`. If the + path is a symlink at this point it is treated as missing (0, 0) so the + cache cannot be poisoned via a TOCTOU swap. + """ if path is None or not path.exists(): return (None, 0, 0) try: - st = path.stat() - return (path.resolve(), st.st_mtime_ns, st.st_size) + import stat as _stat + + lst = path.lstat() + if _stat.S_ISLNK(lst.st_mode): + return (path.resolve(), 0, 0) + return (path.resolve(), lst.st_mtime_ns, lst.st_size) except OSError: return (path.resolve(), 0, 0) def _hash_wordlist(path: Path) -> bytes: - """Hash a wordlist only after its filesystem metadata changes.""" - digest = hashlib.blake2b(digest_size=16) + """Hash a wordlist only after its filesystem metadata changes — reusable. + + On POSIX opens the file with ``O_NOFOLLOW`` so a symlink swap between + validation and hashing cannot be exploited. Falls back to ``lstat`` + + regular ``open`` on platforms without ``O_NOFOLLOW``. + """ + digest = hashlib.blake2b(digest_size=_WORDLIST_HASH_DIGEST_SIZE) + no_follow = getattr(os, "O_NOFOLLOW", None) + if os.name == "posix" and no_follow is not None: + try: + fd = os.open(str(path), os.O_RDONLY | no_follow) + except OSError as exc: + # ELOOP (Too many levels of symbolic links) means O_NOFOLLOW + # blocked a symlink — normalize to WordlistSecurityError. + import errno as _errno + + if exc.errno in (_errno.ELOOP, _errno.EMLINK) or "symlink" in str(exc).lower() or "Too many levels" in str(exc): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") from exc + raise WordlistSecurityError(f"Cannot open wordlist: {exc}") from exc + try: + import stat as _stat + + st = os.fstat(fd) + if _stat.S_ISLNK(st.st_mode): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") + if not _stat.S_ISREG(st.st_mode): + raise WordlistSecurityError("Custom wordlist is not a regular file.") + if st.st_size > _MAX_WORDLIST_FILE_BYTES: + raise WordlistSecurityError( + f"Custom wordlist too large ({st.st_size} bytes). Maximum: {_MAX_WORDLIST_FILE_BYTES} bytes." + ) + # Stream from the already-validated fd to avoid a second open race. + while True: + chunk = os.read(fd, _WORDLIST_HASH_CHUNK_BYTES) + if not chunk: + break + digest.update(chunk) + finally: + os.close(fd) + return digest.digest() + # Fallback for non-POSIX or missing O_NOFOLLOW. + import stat as _stat + + try: + lst = path.lstat() + if _stat.S_ISLNK(lst.st_mode): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") + if not _stat.S_ISREG(lst.st_mode): + raise WordlistSecurityError("Custom wordlist is not a regular file.") + except OSError as exc: + raise WordlistSecurityError(f"Cannot stat wordlist: {exc}") from exc with path.open("rb") as wordlist_file: - for chunk in iter(lambda: wordlist_file.read(65536), b""): + for chunk in iter(lambda: wordlist_file.read(_WORDLIST_HASH_CHUNK_BYTES), b""): digest.update(chunk) return digest.digest() +def _read_wordlist_text_secure(path: Path) -> str: + """Read wordlist text without following symlinks (TOCTOU defense). + + On POSIX uses ``O_NOFOLLOW``; on other platforms falls back to + ``lstat`` symlink check before read. + """ + no_follow = getattr(os, "O_NOFOLLOW", None) + if os.name == "posix" and no_follow is not None: + try: + fd = os.open(str(path), os.O_RDONLY | no_follow) + except OSError as exc: + import errno as _errno + + if exc.errno in (_errno.ELOOP, _errno.EMLINK) or "symlink" in str(exc).lower() or "Too many levels" in str(exc): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") from exc + raise WordlistSecurityError(f"Cannot open wordlist: {exc}") from exc + try: + import stat as _stat + + st = os.fstat(fd) + if _stat.S_ISLNK(st.st_mode): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") + if not _stat.S_ISREG(st.st_mode): + raise WordlistSecurityError("Custom wordlist is not a regular file.") + if st.st_size > _MAX_WORDLIST_FILE_BYTES: + raise WordlistSecurityError( + f"Custom wordlist too large ({st.st_size} bytes)." + ) + # Read via fd to avoid TOCTOU between fstat and open. + chunks: list[bytes] = [] + while True: + chunk = os.read(fd, _WORDLIST_HASH_CHUNK_BYTES) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks).decode("utf-8", errors="ignore") + finally: + os.close(fd) + # Fallback + import stat as _stat + + try: + lst = path.lstat() + if _stat.S_ISLNK(lst.st_mode): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") + if not _stat.S_ISREG(lst.st_mode): + raise WordlistSecurityError("Custom wordlist is not a regular file.") + except OSError as exc: + raise WordlistSecurityError(f"Cannot stat wordlist: {exc}") from exc + return path.read_text(encoding="utf-8", errors="ignore") + + +def _is_cache_valid( + cached: tuple[int, int, bytes, tuple[str, ...]] | None, + mtime_ns: int, + file_size: int, +) -> bool: + """Flat helper: check metadata match without hashing — fast data travel.""" + if cached is None: + return False + cached_mtime_ns, cached_size, _, _ = cached + return (cached_mtime_ns, cached_size) == (mtime_ns, file_size) + + +def _parse_wordlist_lines(text: str) -> tuple[str, ...]: + """Flat helper: parse wordlist text into deduped tuple, reusable.""" + raw_words: list[str] = [] + for line in text.splitlines(): + w = line.strip() + if not w or w.startswith("#"): + continue + raw_words.append(w) + return tuple(_dedupe_preserve_order(raw_words)) + + def load_wordlist(path: Path | None = None) -> list[str]: """Load passphrase words. @@ -412,6 +589,15 @@ def load_wordlist(path: Path | None = None) -> list[str]: Returns a defensive copy so caller mutations do not corrupt shared cached data. """ resolved_path, is_custom = resolve_wordlist_source(path) + # Security: validate custom wordlist path before any read. + if is_custom and resolved_path is not None and resolved_path.exists(): + _validate_custom_wordlist_path(resolved_path) + elif is_custom and resolved_path is not None and not resolved_path.exists(): + _log.warning( + "Custom wordlist %s not found — falling back to bundled default. " + "Check GENERATE_IT_WORDLIST or the --wordlist path.", + resolved_path, + ) cache_path, mtime_ns, file_size = _get_file_signature(resolved_path) cached = _WORDLIST_CACHE.get(cache_path) if cached is not None: @@ -419,6 +605,8 @@ def load_wordlist(path: Path | None = None) -> list[str]: if (cached_mtime_ns, cached_size) == (mtime_ns, file_size): if cache_path is None: return list(cached_words) + # Flat helper would skip hash, but correctness requires hash verification + # when metadata collides (e.g., same size/mtime after rapid rewrite). if _hash_wordlist(cache_path) == cached_hash: return list(cached_words) @@ -426,14 +614,32 @@ def load_wordlist(path: Path | None = None) -> list[str]: words_tuple = tuple(DEFAULT_WORDLIST) content_hash = b"" else: + # Re-validate symlink + size after signature (TOCTOU defense) and + # before read. Uses lstat so a swapped-in symlink is caught even if + # the original validation passed. Size is also checked inside + # _hash_wordlist / _read_wordlist_text_secure via fstat. + if is_custom: + import stat as _stat + + try: + lst = cache_path.lstat() + if _stat.S_ISLNK(lst.st_mode): + raise WordlistSecurityError("Custom wordlist path is a symlink — refused for security.") + if not _stat.S_ISREG(lst.st_mode): + raise WordlistSecurityError("Custom wordlist is not a regular file.") + if lst.st_size > _MAX_WORDLIST_FILE_BYTES: + raise WordlistSecurityError( + f"Custom wordlist too large ({lst.st_size} bytes)." + ) + except OSError as exc: + raise WordlistSecurityError(f"Cannot stat wordlist: {exc}") from exc content_hash = _hash_wordlist(cache_path) - raw_words: list[str] = [] - for line in cache_path.read_text(encoding="utf-8", errors="ignore").splitlines(): - w = line.strip() - if not w or w.startswith("#"): - continue - raw_words.append(w) - words_tuple = tuple(_dedupe_preserve_order(raw_words)) + text = _read_wordlist_text_secure(cache_path) + words_tuple = _parse_wordlist_lines(text) + if len(words_tuple) > _MAX_WORDLIST_WORDS: + raise WordlistSecurityError( + f"Custom wordlist has {len(words_tuple)} unique words, exceeding maximum {_MAX_WORDLIST_WORDS}." + ) # Validate only a file that was actually loaded as a custom wordlist. if is_custom and cache_path is not None: diff --git a/generate_it/identity.py b/generate_it/identity.py index fd816c5..88d08b2 100644 --- a/generate_it/identity.py +++ b/generate_it/identity.py @@ -29,14 +29,17 @@ from __future__ import annotations +import functools import unicodedata +from .constants import _IDENTITY_CACHE_SIZE + _ZW_RANGE = range(0x200B, 0x2010) # 0x200B..0x200F _ZW_EXTRAS: frozenset[int] = frozenset({0xFEFF}) def _remove_zero_width(value: str) -> str: - """Return *value* with zero-width format characters removed.""" + """Flat helper: remove zero-width, reusable.""" return "".join( character for character in value @@ -44,6 +47,13 @@ def _remove_zero_width(value: str) -> str: ) +def _canonical_stripped_uncached(value: str) -> str: + """Flat helper: uncached core, reusable.""" + normalized = unicodedata.normalize("NFC", value) + stripped = _remove_zero_width(normalized).strip().casefold() + return stripped + + def canonical_identity(value: str) -> str: """Frozen canonical identity (crypto-bound for AAD v3). @@ -58,16 +68,28 @@ def canonical_identity(value: str) -> str: def canonical_identity_stripped(value: str) -> str: - """Return the canonical identity with zero-width format chars removed. + """Return the canonical identity with zero-width format chars removed — 60 fps / 2B path. - ``unicodedata.normalize("NFC", value)``, then strip zero-width - characters (U+200B..U+200F and U+FEFF), then ``strip()``, then - ``casefold()``. This is the canonical form used for new data writes, - index lookups, and AAD v4. + Cached via LRU bounded by _IDENTITY_CACHE_SIZE so repeated service/username + normalization during vault scans, CSV import, and TUI filtering travels + without per-call NFC overhead. Flat, reusable. """ - normalized = unicodedata.normalize("NFC", value) - stripped = _remove_zero_width(normalized).strip().casefold() - return stripped + return _canonical_stripped_cached(value) + + +@functools.lru_cache(maxsize=_IDENTITY_CACHE_SIZE) +def _canonical_stripped_cached(value: str) -> str: + """LRU-cached wrapper around stripped canonicalization, reusable.""" + return _canonical_stripped_uncached(value) + + +def clear_identity_cache() -> None: + """Clear LRU cache — reusable for tests.""" + _canonical_stripped_cached.cache_clear() + + +# Backward-compat alias for callers checking cache_clear on the public function +canonical_identity_stripped.cache_clear = clear_identity_cache # type: ignore[attr-defined] def canonical_service_username(service: str, username: str) -> tuple[str, str]: diff --git a/generate_it/storage.py b/generate_it/storage.py index a2c2d6b..0877c63 100644 --- a/generate_it/storage.py +++ b/generate_it/storage.py @@ -27,13 +27,29 @@ APP_NAME = "generate-it" APP_AUTHOR = "j-kemble" -# Default PBKDF2 parameters for newly created vaults. -# Persisted per-vault in the config table so existing vaults keep unlocking. -_DEFAULT_PBKDF2_ITERATIONS = 480_000 -_DEFAULT_SALT_LENGTH = 32 - -# Legacy defaults for vaults created before these params were persisted. -_LEGACY_PBKDF2_ITERATIONS = 100_000 +# Single source of truth for vault/crypto limits — see generate_it/constants.py +from .constants import ( # noqa: E402 (import after _log is intentional) + _DEFAULT_PBKDF2_ITERATIONS, + _DEFAULT_SALT_LENGTH, + _IDENTITY_SCHEMA_VERSION, + _IDX_IDENTITY_ORDER, + _IDX_IDENTITY_UNIQUE, + _LEGACY_PBKDF2_ITERATIONS, + _MAX_CSV_FIELD_BYTES, + _MAX_CSV_FILE_BYTES, + _MAX_CSV_ROWS, + _MAX_MASTER_PASSWORD_LENGTH, + _MAX_URL_BYTES, + _SQLITE_BUSY_TIMEOUT_MS, + _SQLITE_CACHE_SIZE_PAGES, + _SQLITE_FOREIGN_KEYS, + _VAULT_SEARCH_SQL_LIMIT, + _VAULT_SEARCH_SQL_LIKE_LIMIT, + _SQLITE_JOURNAL_MODE, + _SQLITE_SYNCHRONOUS, + _SQLITE_TEMP_STORE, + _VAULT_PAGE_SIZE, +) class StorageError(Exception): """Base exception for storage errors.""" @@ -97,20 +113,6 @@ def __init__(self, message: str, conflicts: Optional[List[Dict[str, object]]] = "welcome1!", "admin123!", }) -_MAX_MASTER_PASSWORD_LENGTH = 1024 - -# CSV import resource limits -_MAX_CSV_FILE_BYTES = 10 * 1024 * 1024 # 10 MB -_MAX_CSV_ROWS = 10_000 -_MAX_CSV_FIELD_BYTES = 500 - -# Identity schema marker stored in the config table once the credentials -# table carries canonical identity columns + indexes. -_IDENTITY_SCHEMA_VERSION = 1 -# Index names (also asserted by tests). -_IDX_IDENTITY_UNIQUE = "idx_credentials_identity" -_IDX_IDENTITY_ORDER = "idx_credentials_order" - def _validate_master_password(password: str) -> None: """Validate a master password against the security policy. @@ -166,6 +168,34 @@ def _validate_field_size(password: str, note: str) -> None: if len(note.encode("utf-8")) > MAX_NOTE_BYTES: raise StorageError(f"note exceeds {MAX_NOTE_BYTES} bytes.") + +def _validate_url(url: str) -> None: + """Reject url that exceeds byte limit.""" + if len(url.encode("utf-8")) > _MAX_URL_BYTES: + raise StorageError(f"url exceeds {_MAX_URL_BYTES} bytes.") + + +def _sanitize_url(url: str) -> str: + """Trim whitespace from url; empty string normalized to ''.""" + return url.strip() + + +def _has_url_column(cursor: sqlite3.Cursor) -> bool: + cursor.execute("PRAGMA table_info(credentials)") + return any(row["name"] == "url" for row in cursor.fetchall()) + + +def _ensure_url_column(conn: sqlite3.Connection) -> None: + """Add url column if missing (flat helper, idempotent).""" + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='credentials'") + if cursor.fetchone() is None: + return + if not _has_url_column(cursor): + cursor.execute("ALTER TABLE credentials ADD COLUMN url TEXT DEFAULT ''") + conn.commit() + _log.info("url column added to credentials") + class StorageManager: def __init__(self, db_path: Optional[Path] = None): if db_path: @@ -178,7 +208,7 @@ def __init__(self, db_path: Optional[Path] = None): self.data_dir.mkdir(parents=True, exist_ok=True) self._ensure_private_permissions(self.data_dir, 0o700) self.db_path = self.data_dir / "vault.db" - + self._fernet: Optional[Fernet] = None self._db_connection: Optional[sqlite3.Connection] = None @@ -255,14 +285,37 @@ def _create_secure_backup(self, backup_suffix: str) -> Path: os.replace(str(backup_tmp), str(backup_path)) return backup_path + def _configure_connection(self, conn: sqlite3.Connection) -> None: + """Apply reusable SQLite pragmas for fast, safe data travel.""" + conn.row_factory = sqlite3.Row + conn.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}") + conn.execute(f"PRAGMA journal_mode={_SQLITE_JOURNAL_MODE}") + conn.execute(f"PRAGMA synchronous={_SQLITE_SYNCHRONOUS}") + conn.execute(f"PRAGMA cache_size={_SQLITE_CACHE_SIZE_PAGES}") + conn.execute(f"PRAGMA temp_store={_SQLITE_TEMP_STORE}") + conn.execute(f"PRAGMA foreign_keys={_SQLITE_FOREIGN_KEYS}") + def _get_conn(self) -> sqlite3.Connection: if not self._db_connection: self._db_connection = sqlite3.connect(self.db_path) - self._db_connection.row_factory = sqlite3.Row - self._db_connection.execute("PRAGMA busy_timeout=5000") + self._configure_connection(self._db_connection) self._ensure_private_permissions(self.db_path, 0o600) return self._db_connection + def _cached_has_url_column(self, cursor: sqlite3.Cursor) -> bool: + """Cached wrapper around _has_url_column to avoid repeated PRAGMA.""" + cached = getattr(self, "_has_url_column_cache", None) + if cached is not None: + return bool(cached) + result = _has_url_column(cursor) + self._has_url_column_cache = result + return result + + def _invalidate_url_column_cache(self) -> None: + """Clear cached url-column presence after schema migration.""" + if hasattr(self, "_has_url_column_cache"): + delattr(self, "_has_url_column_cache") + def set_app_setting(self, key: str, value: str) -> None: """Persist a non-sensitive app preference in the config table.""" conn = self._get_conn() @@ -275,26 +328,144 @@ def set_app_setting(self, key: str, value: str) -> None: ) conn.commit() - def get_app_setting(self, key: str, default: Optional[str] = None) -> Optional[str]: - """Read a persisted non-sensitive app preference from the config table.""" - conn = self._get_conn() - cursor = conn.cursor() - stored_key = f"app_setting:{key}" - cursor.execute("SELECT value FROM config WHERE key = ?", (stored_key,)) - row = cursor.fetchone() - if not row: + @staticmethod + def _decode_app_setting_value(value: Any, default: Optional[str]) -> Optional[str]: + """Flat helper: decode a config value to str, reusable.""" + if value is None: return default - - value = row["value"] if isinstance(value, bytes): try: return value.decode("utf-8") except UnicodeDecodeError: return default - if value is None: - return default return str(value) + def get_app_setting(self, key: str, default: Optional[str] = None) -> Optional[str]: + """Read a persisted non-sensitive app preference from the config table.""" + result = self.get_app_settings([key], {key: default}) + return result.get(key, default) + + def get_app_settings( + self, keys: List[str], defaults: Optional[Dict[str, Optional[str]]] = None + ) -> Dict[str, Optional[str]]: + """Batch fetch multiple app settings in one query — reusable.""" + if not keys: + return {} + defaults = defaults or {} + conn = self._get_conn() + cursor = conn.cursor() + stored_keys = [f"app_setting:{k}" for k in keys] + placeholders = ",".join("?" for _ in stored_keys) + cursor.execute( + f"SELECT key, value FROM config WHERE key IN ({placeholders})", # nosec B608 — placeholders are '?' only, values are parameterized + stored_keys, + ) + found: Dict[str, Any] = {row["key"]: row["value"] for row in cursor.fetchall()} + out: Dict[str, Optional[str]] = {} + for key in keys: + stored_key = f"app_setting:{key}" + if stored_key in found: + out[key] = self._decode_app_setting_value(found[stored_key], defaults.get(key)) + else: + out[key] = defaults.get(key) + return out + + def set_app_settings(self, items: Dict[str, str]) -> None: + """Batch persist multiple app settings in one transaction — reusable.""" + if not items: + return + conn = self._get_conn() + cursor = conn.cursor() + rows = [(f"app_setting:{k}", v.encode("utf-8")) for k, v in items.items()] + cursor.executemany( + "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", + rows, + ) + conn.commit() + + # --- Persistent lockout (survives restarts) ----------------------------- + _LOCKOUT_ATTEMPTS_KEY = "lockout_failed_attempts" + _LOCKOUT_UNTIL_KEY = "lockout_until_epoch" + + def _read_optional_float_config(self, cursor: sqlite3.Cursor, key: str) -> Optional[float]: + """Read a float config value, returning None if absent.""" + cursor.execute("SELECT value FROM config WHERE key=?", (key,)) + row = cursor.fetchone() + if row is None: + return None + raw = row["value"] + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + try: + return float(str(raw)) + except (TypeError, ValueError): + raise StorageError(f"Config key '{key}' has malformed value: {raw!r}") + + def get_persistent_lockout_state(self) -> Tuple[int, Optional[float]]: + """Return (failed_attempts, lockout_until_epoch) from config table. + + Reads without requiring the vault to be unlocked. Returns (0, None) + if the vault does not exist or keys are absent. Malformed values + are treated as (0, None) and cleared. + + Note: ``lockout_until_epoch`` is a wall-clock epoch (``time.time()``) + so it survives restarts, but is therefore subject to forward system- + clock jumps that can clear the deadline early. The attempt counter is + preserved even after expiry so escalation is not reset by a jump + (see ``tui_security._sync_persistent_lockout_from_storage``). + """ + if not self.vault_exists(): + return 0, None + try: + conn = self._get_conn() + cursor = conn.cursor() + attempts = self._read_optional_int_config(cursor, self._LOCKOUT_ATTEMPTS_KEY) + until = self._read_optional_float_config(cursor, self._LOCKOUT_UNTIL_KEY) + except StorageError: + # Malformed lockout state — clear it. + try: + self.clear_persistent_lockout_state() + except Exception: + pass + return 0, None + except sqlite3.Error: + return 0, None + return (attempts if attempts is not None else 0, until) + + def set_persistent_lockout_state(self, attempts: int, lockout_until_epoch: Optional[float]) -> None: + """Persist lockout state to the config table (plaintext).""" + if not self.vault_exists(): + return + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", + (self._LOCKOUT_ATTEMPTS_KEY, str(max(0, attempts))), + ) + if lockout_until_epoch is None: + cursor.execute("DELETE FROM config WHERE key=?", (self._LOCKOUT_UNTIL_KEY,)) + else: + cursor.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", + (self._LOCKOUT_UNTIL_KEY, str(float(lockout_until_epoch))), + ) + conn.commit() + + def clear_persistent_lockout_state(self) -> None: + """Clear persisted lockout state after a successful unlock.""" + if not self.vault_exists(): + return + try: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute( + "DELETE FROM config WHERE key IN (?, ?)", + (self._LOCKOUT_ATTEMPTS_KEY, self._LOCKOUT_UNTIL_KEY), + ) + conn.commit() + except sqlite3.Error: + pass + def _read_optional_int_config(self, cursor: sqlite3.Cursor, key: str) -> Optional[int]: """Read an integer config value, returning None if absent. @@ -340,13 +511,13 @@ def initialize_vault(self, master_password: str) -> None: salt = os.urandom(_DEFAULT_SALT_LENGTH) key = self._derive_key(master_password, salt, _DEFAULT_PBKDF2_ITERATIONS) fernet = Fernet(key) - + # Encrypt a known value to verify password later verification_token = fernet.encrypt(b"VERIFICATION_TOKEN") conn = self._get_conn() cursor = conn.cursor() - + # Create tables cursor.execute(""" CREATE TABLE IF NOT EXISTS config ( @@ -354,7 +525,7 @@ def initialize_vault(self, master_password: str) -> None: value BLOB ) """) - + cursor.execute(""" CREATE TABLE IF NOT EXISTS credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -365,6 +536,7 @@ def initialize_vault(self, master_password: str) -> None: note_is_hidden INTEGER DEFAULT 0, service_key TEXT, username_key TEXT, + url TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) @@ -374,6 +546,12 @@ def initialize_vault(self, master_password: str) -> None: except sqlite3.OperationalError: cursor.execute("ALTER TABLE credentials ADD COLUMN note_is_hidden INTEGER DEFAULT 0") + if not _has_url_column(cursor): + try: + cursor.execute("ALTER TABLE credentials ADD COLUMN url TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass + self._create_identity_indexes(cursor) # Store configuration @@ -382,7 +560,7 @@ def initialize_vault(self, master_password: str) -> None: cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", ("pbkdf2_iterations", str(_DEFAULT_PBKDF2_ITERATIONS))) cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", ("salt_length", str(_DEFAULT_SALT_LENGTH))) cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", ("identity_schema_version", str(_IDENTITY_SCHEMA_VERSION))) - + conn.commit() # Defense in depth: ensure the file is owner-only after creation. @@ -396,7 +574,7 @@ def initialize_vault(self, master_password: str) -> None: def vault_exists(self) -> bool: if not self.db_path.exists(): return False - + try: conn = self._get_conn() cursor = conn.cursor() @@ -431,11 +609,11 @@ def unlock_vault(self, master_password: str) -> None: ) # ── v1 unlock ──────────────────────────────────────────────── - + try: cursor.execute("SELECT value FROM config WHERE key=?", ("salt",)) salt = cursor.fetchone()["value"] - + cursor.execute("SELECT value FROM config WHERE key=?", ("verification",)) verification_token = cursor.fetchone()["value"] except TypeError: @@ -469,6 +647,17 @@ def unlock_vault(self, master_password: str) -> None: self._fernet = fernet self._vault_version = 1 + # Legacy iteration detection — warn and flag for upgrade. + if stored_iters < _DEFAULT_PBKDF2_ITERATIONS: + _log.warning( + "v1 vault uses legacy PBKDF2 iterations=%d (recommended %d). " + "Consider migrating to v2 (Argon2id) via vault health menu.", + stored_iters, _DEFAULT_PBKDF2_ITERATIONS, + ) + # Expose via attribute so UI can prompt upgrade. + self._legacy_pbkdf2_needs_upgrade = True + else: + self._legacy_pbkdf2_needs_upgrade = False self._ensure_identity_schema() _log.info("vault unlocked") @@ -596,6 +785,9 @@ def _ensure_identity_schema(self) -> None: """ self.identity_conflict = None conn = self._get_conn() + # Ensure url column exists before any other schema work. + _ensure_url_column(conn) + self._invalidate_url_column_cache() cursor = conn.cursor() cursor.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='credentials'" @@ -949,9 +1141,15 @@ def initialize_vault_v2(self, master_password: str) -> None: note_is_hidden INTEGER DEFAULT 0, service_key TEXT, username_key TEXT, + url TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) + if not _has_url_column(cursor): + try: + cursor.execute("ALTER TABLE credentials ADD COLUMN url TEXT DEFAULT ''") + except sqlite3.OperationalError: + pass self._create_identity_indexes(cursor) # Write v2 config. @@ -1057,6 +1255,23 @@ def _unlock_vault_v2(self, master_password: str) -> None: except ValueError as exc: raise StorageError(f"Invalid KDF configuration: {exc}") from exc + # Downgrade detection — any non-default KDF params may indicate + # tampering. This is a *detection* control (log only); the *prevention* + # control is _validate_kdf_config() above which rejects anything below + # the OWASP 2023 low minima (19 MiB / 2 iter / 1 lane) before Argon2id + # runs. Values between minima and defaults (e.g. 19 MiB) still unlock + # but emit this warning so the user/health check can investigate. + if ( + memory != _crypto_v2.DEFAULT_ARGON2_MEMORY + or time != _crypto_v2.DEFAULT_ARGON2_TIME + or parallelism != _crypto_v2.DEFAULT_ARGON2_PARALLELISM + ): + _log.warning( + "vault KDF params differ from defaults (memory=%d time=%d parallelism=%d) — " + "possible downgrade/tampering if not intentionally changed", + memory, time, parallelism, + ) + # Validate vault metadata before crypto operations. try: _crypto_v2._validate_vault_metadata( @@ -1371,14 +1586,271 @@ def migrate_aad_to_v4(self) -> None: _log.exception("AAD migration failed; v2 vault is intact") raise + # ------------------------------------------------------------------ + # Master password change (DEK re-wrap) — P0 flat helpers + # ------------------------------------------------------------------ + + def _verify_current_password(self, current_password: str) -> None: + """Raise InvalidPasswordError if *current_password* does not unlock vault.""" + if self._vault_version == 2: + self._verify_current_password_v2(current_password) + elif self._vault_version == 1: + self._verify_current_password_v1(current_password) + else: + raise StorageError("Vault is not unlocked.") + + def _verify_current_password_v2(self, current_password: str) -> None: + conn = self._get_conn() + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM config WHERE key='kdf_salt'") + kdf_salt = cursor.fetchone()["value"] + cursor.execute("SELECT value FROM config WHERE key='wrapped_dek'") + wrapped_dek = cursor.fetchone()["value"] + cursor.execute("SELECT value FROM config WHERE key='vault_uuid'") + vault_uuid = cursor.fetchone()["value"] + cursor.execute("SELECT value FROM config WHERE key='verification'") + verification_ct = cursor.fetchone()["value"] + except (TypeError, KeyError) as exc: + raise StorageError("Vault v2 configuration corrupted.") from exc + memory = self._read_optional_int_config(cursor, "kdf_memory_cost") + memory = memory if memory is not None else _crypto_v2.DEFAULT_ARGON2_MEMORY + time_cost = self._read_optional_int_config(cursor, "kdf_time_cost") + time_cost = time_cost if time_cost is not None else _crypto_v2.DEFAULT_ARGON2_TIME + paral = self._read_optional_int_config(cursor, "kdf_parallelism") + paral = paral if paral is not None else _crypto_v2.DEFAULT_ARGON2_PARALLELISM + cursor.execute("SELECT value FROM config WHERE key='kdf_algorithm'") + row = cursor.fetchone() + kdf_algorithm = row["value"].decode("utf-8") if row and isinstance(row["value"], bytes) else (str(row["value"]) if row else "argon2id") + cursor.execute("SELECT value FROM config WHERE key='aead_algorithm'") + aead_row = cursor.fetchone() + aead_algorithm = aead_row["value"].decode("utf-8") if aead_row and isinstance(aead_row["value"], bytes) else (str(aead_row["value"]) if aead_row else _crypto_v2.AEAD_AES_256_GCM) + kek = _crypto_v2.derive_kek(current_password, kdf_salt, memory=memory, time=time_cost, parallelism=paral) + try: + dek = _crypto_v2.unwrap_dek(kek, wrapped_dek) + except _crypto_v2.InvalidUnwrap as exc: + raise InvalidPasswordError("Current master password is incorrect.") from exc + if not _crypto_v2.verify_token(dek, vault_uuid, verification_ct, aead_algorithm=aead_algorithm): + raise InvalidPasswordError("Current master password is incorrect.") + + def _verify_current_password_v1(self, current_password: str) -> None: + conn = self._get_conn() + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM config WHERE key='salt'") + salt = cursor.fetchone()["value"] + cursor.execute("SELECT value FROM config WHERE key='verification'") + verification_token = cursor.fetchone()["value"] + except TypeError as exc: + raise StorageError("Vault configuration corrupted.") from exc + stored_iters = self._read_optional_int_config(cursor, "pbkdf2_iterations") + stored_iters = stored_iters if stored_iters is not None else _LEGACY_PBKDF2_ITERATIONS + key = self._derive_key(current_password, salt, stored_iters) + fernet = Fernet(key) + try: + decrypted = fernet.decrypt(verification_token) + if decrypted != b"VERIFICATION_TOKEN": + raise InvalidPasswordError("Current master password is incorrect.") + except InvalidToken as exc: + raise InvalidPasswordError("Current master password is incorrect.") from exc + + def change_master_password(self, current_password: str, new_password: str) -> None: + """Change master password without re-encrypting credentials. + + Validates *new_password* against the strength policy, verifies + *current_password* against the existing vault, then atomically + re-wraps the DEK (v2) or re-encrypts the verification token (v1). + + Requires an unlocked vault. + """ + if self._vault_version is None: + raise StorageError("Vault is locked.") + _validate_field_size(new_password, "") + _validate_master_password(new_password) + self._verify_current_password(current_password) + if self._vault_version == 2: + self._change_master_password_v2(new_password) + else: + self._change_master_password_v1(new_password) + + def _change_master_password_v2(self, new_password: str) -> None: + if self._dek is None or self._vault_uuid is None: + raise StorageError("Vault DEK is not available.") + dek = self._dek + vault_uuid = self._vault_uuid + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute("SELECT value FROM config WHERE key='aead_algorithm'") + row = cursor.fetchone() + aead_algorithm = row["value"].decode("utf-8") if row and isinstance(row["value"], bytes) else (str(row["value"]) if row else _crypto_v2.AEAD_AES_256_GCM) + new_salt = os.urandom(_crypto_v2.SALT_LEN) + new_kek = _crypto_v2.derive_kek(new_password, new_salt) + new_wrapped_dek = _crypto_v2.wrap_dek(new_kek, dek) + try: + conn.execute("BEGIN EXCLUSIVE") + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('kdf_salt', ?)", (new_salt,)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('wrapped_dek', ?)", (new_wrapped_dek,)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('kdf_algorithm', 'argon2id')") + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('kdf_memory_cost', ?)", (str(_crypto_v2.DEFAULT_ARGON2_MEMORY),)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('kdf_time_cost', ?)", (str(_crypto_v2.DEFAULT_ARGON2_TIME),)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('kdf_parallelism', ?)", (str(_crypto_v2.DEFAULT_ARGON2_PARALLELISM),)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('aead_algorithm', ?)", (aead_algorithm,)) + conn.commit() + _log.info("master password changed (v2 re-wrap)") + except BaseException: + try: + conn.rollback() + except Exception as rollback_exc: # nosec B110 — best-effort cleanup + _log.debug("rollback failed during password change: %s", rollback_exc) + raise + + def rotate_dek(self, current_password: str) -> None: + """Rotate DEK: generate new DEK, re-encrypt all credentials atomically. + + Requires an unlocked v2 vault and the current master password to + re-wrap the new DEK. Increments ``dek_generation`` and refreshes + the verification token. Credentials keep their UUIDs and metadata. + """ + if self._vault_version != 2: + raise StorageError("DEK rotation requires an unlocked v2 vault.") + if self._dek is None or self._vault_uuid is None: + raise StorageError("Vault DEK is not available.") + self._verify_current_password_v2(current_password) + self._execute_dek_rotation(current_password) + + def _read_dek_generation(self, cursor: sqlite3.Cursor) -> int: + raw = self._read_optional_int_config(cursor, "dek_generation") + return raw if raw is not None else 1 + + def _execute_dek_rotation(self, current_password: str) -> None: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute("SELECT value FROM config WHERE key='kdf_salt'") + kdf_salt = cursor.fetchone()["value"] + cursor.execute("SELECT value FROM config WHERE key='kdf_algorithm'") + row = cursor.fetchone() + kdf_algorithm = row["value"].decode("utf-8") if row and isinstance(row["value"], bytes) else (str(row["value"]) if row else "argon2id") + memory = self._read_optional_int_config(cursor, "kdf_memory_cost") + memory = memory if memory is not None else _crypto_v2.DEFAULT_ARGON2_MEMORY + time_cost = self._read_optional_int_config(cursor, "kdf_time_cost") + time_cost = time_cost if time_cost is not None else _crypto_v2.DEFAULT_ARGON2_TIME + paral = self._read_optional_int_config(cursor, "kdf_parallelism") + paral = paral if paral is not None else _crypto_v2.DEFAULT_ARGON2_PARALLELISM + cursor.execute("SELECT value FROM config WHERE key='aead_algorithm'") + aead_row = cursor.fetchone() + aead_algorithm = aead_row["value"].decode("utf-8") if aead_row and isinstance(aead_row["value"], bytes) else (str(aead_row["value"]) if aead_row else _crypto_v2.AEAD_AES_256_GCM) + vault_uuid: bytes = self._vault_uuid # type: ignore[assignment] + old_dek: bytes = self._dek # type: ignore[assignment] + dek_generation = self._read_dek_generation(cursor) + kek = _crypto_v2.derive_kek(current_password, kdf_salt, memory=memory, time=time_cost, parallelism=paral) + new_dek = _crypto_v2.generate_dek() + new_wrapped_dek = _crypto_v2.wrap_dek(kek, new_dek) + new_verification = _crypto_v2.create_verification_token(new_dek, vault_uuid, aead_algorithm=aead_algorithm) + backup_path = self._create_secure_backup(".dek.bak") + _log.info("DEK rotation backup at %s", backup_path) + try: + conn.execute("BEGIN EXCLUSIVE") + read_cursor = conn.cursor() + write_cursor = conn.cursor() + read_cursor.execute("SELECT id, credential_uuid, service, username, encrypted_password, encrypted_note FROM credentials") + for row in read_cursor: + cred_uuid: bytes = row["credential_uuid"] + svc: str = row["service"] + usr: str = row["username"] + password_ad = self._make_credential_aad(cred_uuid, "password", svc, usr) + note_ad = self._make_credential_aad(cred_uuid, "note", svc, usr) + try: + password = _crypto_v2.decrypt_field(old_dek, password_ad, row["encrypted_password"], aead_algorithm=aead_algorithm) + except Exception as exc: + raise StorageError(f"Failed to decrypt credential id={row['id']} during DEK rotation: {exc}") from exc + note = "" + if row["encrypted_note"]: + try: + note = _crypto_v2.decrypt_field(old_dek, note_ad, row["encrypted_note"], aead_algorithm=aead_algorithm) + except Exception as exc: + raise StorageError(f"Failed to decrypt note id={row['id']} during DEK rotation: {exc}") from exc + _validate_field_size(password, note) + new_password_ct = _crypto_v2.encrypt_field(new_dek, password_ad, password, aead_algorithm=aead_algorithm, max_plaintext_bytes=MAX_PASSWORD_BYTES, field_name="password") + new_note_ct: bytes | None = None + if note: + new_note_ct = _crypto_v2.encrypt_field(new_dek, note_ad, note, aead_algorithm=aead_algorithm, max_plaintext_bytes=MAX_NOTE_BYTES, field_name="note") + write_cursor.execute("UPDATE credentials SET encrypted_password=?, encrypted_note=? WHERE id=?", (new_password_ct, new_note_ct, row["id"])) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('wrapped_dek', ?)", (new_wrapped_dek,)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('verification', ?)", (new_verification,)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('dek_generation', ?)", (str(dek_generation + 1),)) + conn.commit() + self._dek = new_dek + _log.info("DEK rotated: generation %d -> %d", dek_generation, dek_generation + 1) + except BaseException: + try: + conn.rollback() + except Exception as rollback_exc: # nosec B110 — best-effort cleanup + _log.debug("rollback failed during DEK rotation: %s", rollback_exc) + _log.exception("DEK rotation failed; vault unchanged") + raise + + def _change_master_password_v1(self, new_password: str) -> None: + if self._fernet is None: + raise StorageError("Vault is not unlocked.") + old_fernet: Fernet = self._fernet + new_salt = os.urandom(_DEFAULT_SALT_LENGTH) + new_key = self._derive_key(new_password, new_salt, _DEFAULT_PBKDF2_ITERATIONS) + new_fernet = Fernet(new_key) + new_verification = new_fernet.encrypt(b"VERIFICATION_TOKEN") + backup_path = self._create_secure_backup(".pwd.bak") + _log.info("v1 password-change backup at %s", backup_path) + conn = self._get_conn() + cursor = conn.cursor() + try: + conn.execute("BEGIN EXCLUSIVE") + # Re-encrypt every credential field from old key to new key. + read_cursor = conn.cursor() + write_cursor = conn.cursor() + read_cursor.execute("SELECT id, encrypted_password, encrypted_note FROM credentials") + for row in read_cursor: + try: + password = old_fernet.decrypt(row["encrypted_password"]).decode() + except Exception as exc: + raise StorageError(f"Failed to decrypt v1 password id={row['id']}: {exc}") from exc + note = "" + if row["encrypted_note"]: + try: + note = old_fernet.decrypt(row["encrypted_note"]).decode() + except Exception as exc: + raise StorageError(f"Failed to decrypt v1 note id={row['id']}: {exc}") from exc + _validate_field_size(password, note) + new_password_ct = new_fernet.encrypt(password.encode()) + new_note_ct = new_fernet.encrypt(note.encode()) if note else None + write_cursor.execute( + "UPDATE credentials SET encrypted_password=?, encrypted_note=? WHERE id=?", + (new_password_ct, new_note_ct, row["id"]), + ) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('salt', ?)", (new_salt,)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('verification', ?)", (new_verification,)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('pbkdf2_iterations', ?)", (str(_DEFAULT_PBKDF2_ITERATIONS),)) + cursor.execute("INSERT OR REPLACE INTO config (key, value) VALUES ('salt_length', ?)", (str(_DEFAULT_SALT_LENGTH),)) + conn.commit() + self._fernet = new_fernet + _log.info("master password changed (v1 re-encrypt)") + except BaseException: + try: + conn.rollback() + except Exception as rollback_exc: # nosec B110 — best-effort cleanup + _log.debug("rollback failed during password change: %s", rollback_exc) + raise + def close(self): if self._db_connection: - self._db_connection.close() + try: + self._db_connection.close() + except Exception: + pass self._db_connection = None self._fernet = None self._vault_version = None self._dek = None self._vault_uuid = None + self._invalidate_url_column_cache() _log.info("vault closed") def __enter__(self) -> "StorageManager": @@ -1567,13 +2039,15 @@ def _validated_identity_keys(self, service: str, username: str) -> Tuple[str, st except ValueError as exc: raise StorageError(str(exc)) from exc - def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> int: + def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> int: self._require_unlocked() service_key, username_key = self._validated_identity_keys(service, username) _validate_field_size(password, note) + url = _sanitize_url(url) + _validate_url(url) if self._vault_version == 2: - return self._save_credential_v2(service, username, password, note, note_is_hidden, service_key, username_key) + return self._save_credential_v2(service, username, password, note, note_is_hidden, service_key, username_key, url) fernet = self._require_unlocked() encrypted_password, encrypted_note = self._encrypt_fields_v1(fernet, password, note) @@ -1582,8 +2056,8 @@ def save_credential(self, service: str, username: str, password: str, note: str cursor = conn.cursor() try: cursor.execute( - "INSERT INTO credentials (service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key) VALUES (?, ?, ?, ?, ?, ?, ?)", - (service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key) + "INSERT INTO credentials (service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key, url) ) except sqlite3.IntegrityError as exc: conn.rollback() @@ -1597,10 +2071,11 @@ def save_credential(self, service: str, username: str, password: str, note: str def _save_credential_v2( self, service: str, username: str, password: str, note: str, note_is_hidden: bool, - service_key: str, username_key: str, + service_key: str, username_key: str, url: str = "", ) -> int: """Save a credential in a v2 vault.""" _validate_field_size(password, note) + _validate_url(url) credential_uuid = uuid.uuid4().bytes encrypted_password, encrypted_note = self._encrypt_fields_v2( password, note, credential_uuid, service, username @@ -1610,8 +2085,8 @@ def _save_credential_v2( cursor = conn.cursor() try: cursor.execute( - "INSERT INTO credentials (credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (credential_uuid, service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key), + "INSERT INTO credentials (credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (credential_uuid, service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key, url), ) except sqlite3.IntegrityError as exc: conn.rollback() @@ -1632,7 +2107,7 @@ def find_credential_by_identity( decisions are indexed database lookups rather than Python scans of the full vault. ``exclude_id`` skips one row (credential edits). - Returns a dict with id/service/username/created_at keys, or None. + Returns a dict with id/service/username/created_at/url keys, or None. """ self._require_unlocked() service_key, username_key = canonical_service_username(service, username) @@ -1640,15 +2115,18 @@ def find_credential_by_identity( return None conn = self._get_conn() cursor = conn.cursor() + # url column may be missing in legacy vaults — handle gracefully. + has_url = self._cached_has_url_column(cursor) + url_expr = ", url" if has_url else ", '' as url" # allow-list, not user input if exclude_id is None: cursor.execute( - "SELECT id, service, username, created_at FROM credentials" + f"SELECT id, service, username, created_at{url_expr} FROM credentials" # nosec B608 — url_expr is allow-list " WHERE service_key = ? AND username_key = ?", (service_key, username_key), ) else: cursor.execute( - "SELECT id, service, username, created_at FROM credentials" + f"SELECT id, service, username, created_at{url_expr} FROM credentials" # nosec B608 — url_expr is allow-list " WHERE service_key = ? AND username_key = ? AND id != ?", (service_key, username_key, exclude_id), ) @@ -1660,46 +2138,189 @@ def find_credential_by_identity( "service": row["service"], "username": row["username"], "created_at": row["created_at"], + "url": row["url"] if "url" in row.keys() and row["url"] is not None else "", + } + + def _fetch_credential_metadata_rows( + self, cursor: sqlite3.Cursor, limit: Optional[int] = None, offset: Optional[int] = None + ) -> List[sqlite3.Row]: + """Flat helper: fetch metadata rows with optional pagination, reusable.""" + has_url = self._cached_has_url_column(cursor) + url_expr = ", url" if has_url else ", '' as url" # allow-list + query = f"SELECT id, service, username, created_at{url_expr} FROM credentials ORDER BY service_key, username_key, id" # nosec B608 — url_expr is allow-list + params: List[Any] = [] + if limit is not None: + query += " LIMIT ?" + params.append(limit) + if offset is not None: + query += " OFFSET ?" + params.append(offset) + cursor.execute(query, params) + return cursor.fetchall() + + @staticmethod + def _row_to_metadata(row: sqlite3.Row) -> dict: + """Flat helper: convert a DB row to metadata dict, reusable.""" + return { + "id": row["id"], + "service": row["service"], + "username": row["username"], + "url": row["url"] if "url" in row.keys() and row["url"] is not None else "", + "created_at": row["created_at"], } def list_credential_metadata(self) -> list[dict]: """Return metadata for all credentials without decrypting passwords/notes. - Returns list of dicts with keys: id, service, username, created_at + Returns list of dicts with keys: id, service, username, url, created_at """ self._require_unlocked() conn = self._get_conn() cursor = conn.cursor() - cursor.execute( - "SELECT id, service, username, created_at FROM credentials" - " ORDER BY service_key, username_key, id" - ) - return [ - { - "id": row["id"], - "service": row["service"], - "username": row["username"], - "created_at": row["created_at"], - } - for row in cursor.fetchall() - ] + rows = self._fetch_credential_metadata_rows(cursor) + return [self._row_to_metadata(row) for row in rows] + + def list_credential_metadata_paginated( + self, limit: int = _VAULT_PAGE_SIZE, offset: int = 0 + ) -> list[dict]: + """Paginated metadata fetch for large vaults — reusable. + + Uses constants for default page size, no hard-coded inline limits. + For large offsets (>1000) prefer ``list_credential_metadata_keyset`` to + avoid OFFSET scan. + """ + self._require_unlocked() + conn = self._get_conn() + cursor = conn.cursor() + rows = self._fetch_credential_metadata_rows(cursor, limit=limit, offset=offset) + return [self._row_to_metadata(row) for row in rows] + + def list_credential_metadata_keyset( + self, + limit: int = _VAULT_PAGE_SIZE, + after: Tuple[str, str, int] | None = None, + ) -> list[dict]: + """Keyset pagination — O(log n) for large vaults, no OFFSET scan. + + ``after`` is the last tuple ``(service_key, username_key, id)`` from the + previous page. ``None`` returns the first page. + """ + self._require_unlocked() + conn = self._get_conn() + cursor = conn.cursor() + has_url = self._cached_has_url_column(cursor) + url_expr = ", url" if has_url else ", '' as url" # allow-list + if after is None: + cursor.execute( + f"SELECT id, service, username, created_at{url_expr}, service_key, username_key " # nosec B608 — url_expr allow-list + "FROM credentials ORDER BY service_key, username_key, id LIMIT ?", + (limit,), + ) + else: + sk, uk, last_id = after + cursor.execute( + f"SELECT id, service, username, created_at{url_expr}, service_key, username_key " # nosec B608 — url_expr allow-list + "FROM credentials WHERE (service_key, username_key, id) > (?, ?, ?) " + "ORDER BY service_key, username_key, id LIMIT ?", + (sk, uk, last_id, limit), + ) + rows = cursor.fetchall() + return [self._row_to_metadata(row) for row in rows] + + def count_credentials(self) -> int: + """Return total credential count — reusable for pagination.""" + self._require_unlocked() + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) as cnt FROM credentials") + row = cursor.fetchone() + return int(row["cnt"]) if row else 0 + + def _build_search_filter_clause(self, query: str) -> tuple[str, List[Any]]: + """Flat helper: build WHERE clause for 2B-scale indexed search, reusable.""" + q = query.strip().lower() + if not q: + return "", [] + # Use canonical stripped form to match service_key/username_key indexes + from .identity import canonical_identity_stripped + + cq = canonical_identity_stripped(q) + if not cq: + return "", [] + # Escape LIKE wildcards in query + escaped = cq.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like = f"%{escaped}%" + clause = " WHERE service_key LIKE ? ESCAPE '\\' OR username_key LIKE ? ESCAPE '\\' OR service LIKE ? ESCAPE '\\' OR username LIKE ? ESCAPE '\\' OR url LIKE ? ESCAPE '\\'" + params = [like, like, like, like, like] + return clause, params + + def search_credential_metadata( + self, + query: str, + limit: int = _VAULT_SEARCH_SQL_LIMIT, + offset: int = 0, + ) -> list[dict]: + """DB-side vault search — 60 fps streaming for 2B vaults, flat & reusable. + + Uses an indexed ``LIKE`` pre-filter (escaped, parameterized) ordered by + ``service_key/username_key`` and capped by the ``limit`` argument + (default ``_VAULT_SEARCH_SQL_LIMIT`` = 500). The constant + ``_VAULT_SEARCH_SQL_LIKE_LIMIT`` (2000) is *not* the ``LIMIT`` for + this query; it only gates the ``import_from_csv`` identity-map preload + decision (``count_credentials() <= _VAULT_SEARCH_SQL_LIKE_LIMIT``). + No hard-coded inline limits. + """ + self._require_unlocked() + conn = self._get_conn() + cursor = conn.cursor() + has_url = self._cached_has_url_column(cursor) + url_expr = ", url" if has_url else ", '' as url" # allow-list + base = f"SELECT id, service, username, created_at{url_expr} FROM credentials" # nosec B608 — url_expr is allow-list + clause, params = self._build_search_filter_clause(query) + if not clause: + # Empty query — fall back to paginated list + return self.list_credential_metadata_paginated(limit=limit, offset=offset) + # Result size is bounded by the caller's ``limit`` (default 500); + # full FTS5 would be needed for true 2B-scale, but current indexed + # service_key/username_key ORDER BY keeps scan bounded for typical vaults. + query_sql = f"{base}{clause} ORDER BY service_key, username_key, id LIMIT ? OFFSET ?" # nosec B608 — clause is fixed LIKE with parameterized ? + params.extend([limit, offset]) + cursor.execute(query_sql, params) + return [self._row_to_metadata(row) for row in cursor.fetchall()] + + def count_search_results(self, query: str) -> int: + """Count matching rows for DB-side search, reusable.""" + self._require_unlocked() + conn = self._get_conn() + cursor = conn.cursor() + clause, params = self._build_search_filter_clause(query) + if not clause: + return self.count_credentials() + cursor.execute(f"SELECT COUNT(*) as cnt FROM credentials{clause}", params) # nosec B608 — clause is fixed LIKE + row = cursor.fetchone() + return int(row["cnt"]) if row else 0 + + def _load_existing_identity_map(self, cursor: sqlite3.Cursor) -> Dict[Tuple[str, str], int]: + """Flat helper: preload service_key->id map for batched import, reusable.""" + cursor.execute("SELECT service_key, username_key, id FROM credentials") + return {(row["service_key"], row["username_key"]): row["id"] for row in cursor.fetchall()} def get_credential_secret(self, credential_id: int) -> dict: """Decrypt and return the password and note for one credential. - Returns dict with keys: password, note, note_is_hidden + Returns dict with keys: password, note, note_is_hidden, url """ fernet = self._require_unlocked() conn = self._get_conn() cursor = conn.cursor() if self._vault_version == 2: cursor.execute( - "SELECT credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden FROM credentials WHERE id=?", + "SELECT credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden, url FROM credentials WHERE id=?", (credential_id,), ) else: cursor.execute( - "SELECT encrypted_password, encrypted_note, note_is_hidden FROM credentials WHERE id=?", + "SELECT encrypted_password, encrypted_note, note_is_hidden, url FROM credentials WHERE id=?", (credential_id,), ) row = cursor.fetchone() @@ -1707,65 +2328,85 @@ def get_credential_secret(self, credential_id: int) -> dict: raise StorageError(f"Credential {credential_id} not found.") password, note = self._decrypt_credential_fields(row, fernet) note_is_hidden = bool(row["note_is_hidden"]) if row["note_is_hidden"] is not None else False - return {"password": password, "note": note, "note_is_hidden": note_is_hidden} + url = row["url"] if "url" in row.keys() and row["url"] is not None else "" + return {"password": password, "note": note, "note_is_hidden": note_is_hidden, "url": url} # Kept for CSV export/import and tests. Prefer list_credential_metadata() # + get_credential_secret() for UI operations. - def list_credentials(self) -> List[dict]: - """Returns a list of credentials with decrypted passwords and notes.""" - fernet = self._require_unlocked() + def _row_to_credential(self, row: sqlite3.Row, fernet: Fernet) -> dict: + """Flat helper: decrypt row to credential dict, reusable for streaming.""" + try: + password, note = self._decrypt_credential_fields(row, fernet) + note_is_hidden = bool(row["note_is_hidden"]) if row["note_is_hidden"] is not None else False + url = row["url"] if "url" in row.keys() and row["url"] is not None else "" + return { + "id": row["id"], + "service": row["service"], + "username": row["username"], + "url": url, + "password": password, + "note": note, + "note_is_hidden": note_is_hidden, + "created_at": row["created_at"], + } + except (InvalidToken, InvalidTag, UnicodeDecodeError): + return { + "id": row["id"], + "service": row["service"], + "username": row["username"], + "url": row["url"] if "url" in row.keys() and row["url"] is not None else "", + "password": "", # nosec B105 — error sentinel + "note": "", # nosec B105 — error sentinel + "note_is_hidden": False, + "created_at": row["created_at"], + } + def iter_credentials(self, batch_size: int = _VAULT_PAGE_SIZE): + """Streaming iterator over decrypted credentials — 60 fps / 2B friendly. + + Yields dicts one-by-one using a server-side cursor so data travels + without fetchall materialization. Reusable, flat, bounded by constants. + """ + fernet = self._require_unlocked() conn = self._get_conn() cursor = conn.cursor() if self._vault_version == 2: - cursor.execute("SELECT id, credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden, created_at FROM credentials ORDER BY service_key, username_key, id") + cursor.execute( + "SELECT id, credential_uuid, service, username, url, encrypted_password, encrypted_note, note_is_hidden, created_at " + "FROM credentials ORDER BY service_key, username_key, id" + ) else: - cursor.execute("SELECT id, service, username, encrypted_password, encrypted_note, note_is_hidden, created_at FROM credentials ORDER BY service_key, username_key, id") - - results = [] - for row in cursor.fetchall(): - try: - password, note = self._decrypt_credential_fields(row, fernet) - note_is_hidden = bool(row["note_is_hidden"]) if row["note_is_hidden"] is not None else False - results.append({ - "id": row["id"], - "service": row["service"], - "username": row["username"], - "password": password, - "note": note, - "note_is_hidden": note_is_hidden, - "created_at": row["created_at"] - }) - except (InvalidToken, InvalidTag, UnicodeDecodeError): - results.append({ - "id": row["id"], - "service": row["service"], - "username": row["username"], - "password": "", # nosec B105 — error sentinel - "note": "", # nosec B105 — error sentinel - "note_is_hidden": False, - "created_at": row["created_at"] - }) - - return results + cursor.execute( + "SELECT id, service, username, url, encrypted_password, encrypted_note, note_is_hidden, created_at " + "FROM credentials ORDER BY service_key, username_key, id" + ) + for row in cursor: + yield self._row_to_credential(row, fernet) + + def list_credentials(self) -> List[dict]: + """Returns a list of credentials with decrypted passwords and notes.""" + # Flat: delegate to streaming iterator for 2B-scale data travel. + return list(self.iter_credentials()) def delete_credential(self, credential_id: int) -> None: self._require_unlocked() - + conn = self._get_conn() cursor = conn.cursor() cursor.execute("DELETE FROM credentials WHERE id = ?", (credential_id,)) conn.commit() _log.info("credential deleted: id=%d", credential_id) - def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> None: + def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> None: """Update an existing credential by id.""" self._require_unlocked() service_key, username_key = self._validated_identity_keys(service, username) _validate_field_size(password, note) + url = _sanitize_url(url) + _validate_url(url) if self._vault_version == 2: - self._update_credential_v2(credential_id, service, username, password, note, note_is_hidden, service_key, username_key) + self._update_credential_v2(credential_id, service, username, password, note, note_is_hidden, service_key, username_key, url) return fernet = self._require_unlocked() @@ -1775,8 +2416,8 @@ def update_credential(self, credential_id: int, service: str, username: str, pas cursor = conn.cursor() try: cursor.execute( - "UPDATE credentials SET service = ?, username = ?, encrypted_password = ?, encrypted_note = ?, note_is_hidden = ?, service_key = ?, username_key = ? WHERE id = ?", - (service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key, credential_id), + "UPDATE credentials SET service = ?, username = ?, encrypted_password = ?, encrypted_note = ?, note_is_hidden = ?, service_key = ?, username_key = ?, url = ? WHERE id = ?", + (service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key, url, credential_id), ) except sqlite3.IntegrityError as exc: conn.rollback() @@ -1792,10 +2433,11 @@ def update_credential(self, credential_id: int, service: str, username: str, pas def _update_credential_v2( self, credential_id: int, service: str, username: str, password: str, note: str, note_is_hidden: bool, - service_key: str, username_key: str, + service_key: str, username_key: str, url: str = "", ) -> None: """Update an existing credential in a v2 vault.""" _validate_field_size(password, note) + _validate_url(url) conn = self._get_conn() cursor = conn.cursor() cursor.execute( @@ -1813,8 +2455,8 @@ def _update_credential_v2( try: cursor.execute( - "UPDATE credentials SET service = ?, username = ?, encrypted_password = ?, encrypted_note = ?, note_is_hidden = ?, service_key = ?, username_key = ? WHERE id = ?", - (service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key, credential_id), + "UPDATE credentials SET service = ?, username = ?, encrypted_password = ?, encrypted_note = ?, note_is_hidden = ?, service_key = ?, username_key = ?, url = ? WHERE id = ?", + (service, username, encrypted_password, encrypted_note, 1 if note_is_hidden else 0, service_key, username_key, url, credential_id), ) except sqlite3.IntegrityError as exc: conn.rollback() @@ -1830,7 +2472,7 @@ def export_to_csv( export_format: str = "generic", ) -> Tuple[int, List[Dict[str, str]]]: """Export credentials to CSV file in provider-compatible format. - + Returns: Tuple of (exported_count, skipped_rows) skipped_rows is a list of dicts with 'service', 'username', 'error' keys @@ -1850,10 +2492,13 @@ def export_to_csv( conn = self._get_conn() cursor = conn.cursor() + # Ensure url column exists even for legacy queries. + has_url = self._cached_has_url_column(cursor) + url_select = ", url" if has_url else ", '' as url" # allow-list if self._vault_version == 2: - cursor.execute("SELECT id, credential_uuid, service, username, encrypted_password, encrypted_note, created_at FROM credentials ORDER BY service_key, username_key, id") + cursor.execute(f"SELECT id, credential_uuid, service, username, encrypted_password, encrypted_note, created_at{url_select} FROM credentials ORDER BY service_key, username_key, id") # nosec B608 — url_select is allow-list else: - cursor.execute("SELECT id, service, username, encrypted_password, encrypted_note, created_at FROM credentials ORDER BY service_key, username_key, id") + cursor.execute(f"SELECT id, service, username, encrypted_password, encrypted_note, created_at{url_select} FROM credentials ORDER BY service_key, username_key, id") # nosec B608 — url_select is allow-list exported = 0 skipped = [] @@ -1870,6 +2515,7 @@ def export_to_csv( for row in cursor: try: password, _note = self._decrypt_credential_fields(row, fernet) + url_val = row["url"] if "url" in row.keys() and row["url"] is not None else "" writer.writerow( csv_formats.build_export_row( normalized_format, @@ -1877,6 +2523,7 @@ def export_to_csv( username=row["username"], password=password, note=_note, + url=url_val, ) ) exported += 1 @@ -1894,6 +2541,10 @@ def export_to_csv( # Atomic rename (os.replace is atomic on POSIX and Windows). os.replace(str(tmp_path), str(csv_path)) + # Defense in depth: ensure final file is owner-only even if + # the predecessor had broader permissions or the filesystem + # preserved mode bits. + self._ensure_private_permissions(csv_path, 0o600) except BaseException: # Clean up temp file on any failure. if tmp_path.exists(): @@ -1914,17 +2565,17 @@ def import_from_csv( import_format: str = "auto", ) -> Tuple[int, int, List[Dict[str, str]]]: """Import credentials from CSV file. - + Supports format presets for common providers. - + Detects duplicates by case-insensitive (service, username) match. - + Args: csv_path: Path to CSV file merge_duplicates: If True, overwrite existing duplicates. If False, skip them. dry_run: If True, do not modify the database; just report counts/issues. import_format: One of auto/generic/bitwarden/apple/nordpass. - + Returns: Tuple of (imported_count, skipped_count, duplicate_list) duplicate_list contains dicts with 'service', 'username', 'reason' keys @@ -1997,6 +2648,18 @@ def import_from_csv( conn.execute("BEGIN") transaction_opened = True + # Flat helper: preload identity map for 2B-scale batched import, reusable. + existing_map: Dict[Tuple[str, str], int] = {} + use_preload = False + if not dry_run: + try: + # Avoid 2B fetch: only preload for modest vaults + if self.count_credentials() <= _VAULT_SEARCH_SQL_LIKE_LIMIT: + existing_map = self._load_existing_identity_map(cursor) + use_preload = True + except StorageError: + use_preload = False + for row_num, row in enumerate(reader, start=2): # Start at 2 (header is row 1) total_rows += 1 if total_rows > _MAX_CSV_ROWS: @@ -2023,18 +2686,21 @@ def import_from_csv( if not parsed: continue - # Field length limits + # Field length limits (url allows larger limit) for field_name, value in parsed.items(): - if len(value.encode('utf-8')) > _MAX_CSV_FIELD_BYTES: + limit = _MAX_URL_BYTES if field_name == "url" else _MAX_CSV_FIELD_BYTES + if len(value.encode('utf-8')) > limit: raise StorageError( f"Field '{field_name}' in row {row_num} exceeds " - f"{_MAX_CSV_FIELD_BYTES} bytes." + f"{limit} bytes." ) service = parsed["service"] username = parsed["username"] password = parsed["password"] note = parsed.get("note", "") + url = _sanitize_url(parsed.get("url", "")) + _validate_url(url) # Canonical identity for duplicate detection. The indexed # lookup also sees rows inserted earlier in this same @@ -2043,14 +2709,19 @@ def import_from_csv( # during a preview). service_key, username_key = self._validated_identity_keys(service, username) identity_key = (service_key, username_key) - existing = self.find_credential_by_identity(service, username) + # Flat: use preloaded map for 2B batched path, else indexed lookup + if use_preload: + _cid = existing_map.get(identity_key) + existing = {"id": _cid} if _cid is not None else None + else: + existing = self.find_credential_by_identity(service, username) if existing is not None or identity_key in seen_keys: if merge_duplicates and not dry_run and existing is not None: - # Update existing credential + # Update existing credential — preserve url if provided, else keep existing. cred_id = existing["id"] if self._vault_version == 2: cursor.execute( - "SELECT credential_uuid FROM credentials WHERE id = ?", + "SELECT credential_uuid, url FROM credentials WHERE id = ?", (cred_id,), ) cred_uuid_row = cursor.fetchone() @@ -2058,15 +2729,22 @@ def import_from_csv( encrypted_password, encrypted_note = self._encrypt_fields_v2( password, note, cred_uuid_row["credential_uuid"], service, username ) + # If import row has no url, keep existing url. + existing_url = cred_uuid_row["url"] if "url" in cred_uuid_row.keys() and cred_uuid_row["url"] else "" + url_to_store = url if url else _sanitize_url(existing_url) cursor.execute( - "UPDATE credentials SET encrypted_password = ?, encrypted_note = ?, note_is_hidden = 0 WHERE id = ?", - (encrypted_password, encrypted_note, cred_id) + "UPDATE credentials SET encrypted_password = ?, encrypted_note = ?, note_is_hidden = 0, url = ? WHERE id = ?", + (encrypted_password, encrypted_note, url_to_store, cred_id) ) else: encrypted_password, encrypted_note = self._encrypt_fields_v1(fernet, password, note) + cursor.execute("SELECT url FROM credentials WHERE id=?", (cred_id,)) + existing_row = cursor.fetchone() + existing_url = existing_row["url"] if existing_row and "url" in existing_row.keys() and existing_row["url"] else "" + url_to_store = url if url else _sanitize_url(existing_url) cursor.execute( - "UPDATE credentials SET encrypted_password = ?, encrypted_note = ?, note_is_hidden = 0 WHERE id = ?", - (encrypted_password, encrypted_note, cred_id) + "UPDATE credentials SET encrypted_password = ?, encrypted_note = ?, note_is_hidden = 0, url = ? WHERE id = ?", + (encrypted_password, encrypted_note, url_to_store, cred_id) ) imported += 1 else: @@ -2085,20 +2763,28 @@ def import_from_csv( password, note, cred_uuid, service, username ) cursor.execute( - "INSERT INTO credentials (credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (cred_uuid, service, username, encrypted_password, encrypted_note, 0, service_key, username_key) + "INSERT INTO credentials (credential_uuid, service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (cred_uuid, service, username, encrypted_password, encrypted_note, 0, service_key, username_key, url) ) else: fernet = self._require_unlocked() encrypted_password, encrypted_note = self._encrypt_fields_v1(fernet, password, note) cursor.execute( - "INSERT INTO credentials (service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key) VALUES (?, ?, ?, ?, ?, ?, ?)", - (service, username, encrypted_password, encrypted_note, 0, service_key, username_key) + "INSERT INTO credentials (service, username, encrypted_password, encrypted_note, note_is_hidden, service_key, username_key, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (service, username, encrypted_password, encrypted_note, 0, service_key, username_key, url) ) imported += 1 # Track canonical identities handled by this import so # duplicate rows later in the same file are skipped. seen_keys.add(identity_key) + if use_preload: + # Keep preload map in sync for later rows in same file + try: + _new_id = int(cursor.lastrowid or 0) + if _new_id: + existing_map[identity_key] = _new_id + except Exception: + pass # Commit the real import atomically. An empty import commits a # no-op transaction, matching the previous behavior. @@ -2119,3 +2805,143 @@ def import_from_csv( _log.info("imported %d credentials from %s", imported, csv_path) return imported, skipped, duplicates + + # ------------------------------------------------------------------ + # Vault health / status / backup pruning — flat helpers + # ------------------------------------------------------------------ + + def list_backups(self) -> List[Path]: + """Return sorted list of backup files next to the vault.""" + if not self.db_path.parent.exists(): + return [] + pattern = f"{self.db_path.name}.*.bak" + backups = list(self.db_path.parent.glob(pattern)) + # Exclude the original db itself and non-files. + backups = [p for p in backups if p.is_file() and p != self.db_path] + + def _backup_ctime(path: Path) -> float: + """Sort key for backup files. + + On POSIX ``st_ctime`` is the inode *change* time (updated when file + content or metadata changes), not creation time; on Windows it is + creation time. In either case it cannot be set with ``touch -m`` + (which only changes *mtime*), so it is harder to forge than + ``st_mtime``, though an attacker with write access to the backup + directory can still affect it by rewriting the file. Within the + same second ``st_ctime`` may collide, so ``sort()`` stability + preserves ``glob()`` order for ties. + """ + try: + st = path.stat() + return getattr(st, "st_ctime", st.st_mtime) + except OSError: + return 0 + + backups.sort(key=_backup_ctime) + return backups + + def prune_backups(self, keep_latest: int = 1) -> List[Path]: + """Delete oldest backups keeping *keep_latest* newest files. + + Returns list of deleted paths. ``keep_latest=0`` deletes all. + """ + backups = self.list_backups() + if keep_latest < 0: + keep_latest = 0 + if len(backups) <= keep_latest: + return [] + to_delete = backups[: len(backups) - keep_latest] + deleted: List[Path] = [] + for path in to_delete: + try: + if path.is_symlink(): + continue + path.unlink() + deleted.append(path) + except OSError: + _log.warning("Could not prune backup %s", path) + if deleted: + _log.info("pruned %d backup(s)", len(deleted)) + return deleted + + def get_vault_status(self) -> Dict[str, object]: + """Return non-sensitive vault status for health checks.""" + status: Dict[str, object] = { + "vault_path": str(self.db_path), + "exists": self.vault_exists(), + "is_unlocked": self._vault_version is not None, + "version": self._vault_version, + "aad_version": self._aad_version if self._vault_version == 2 else None, + "credential_count": 0, + "backup_count": len(self.list_backups()), + "backups": [str(p) for p in self.list_backups()], + } + if self._vault_version is not None: + try: + conn = self._get_conn() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM credentials") + row = cursor.fetchone() + status["credential_count"] = int(row[0]) if row else 0 + if self._vault_version == 2: + dek_gen = self._read_optional_int_config(cursor, "dek_generation") + status["dek_generation"] = dek_gen if dek_gen is not None else 1 + except sqlite3.Error: + pass + else: + # Not unlocked — try to detect version without unlocking. + try: + conn = self._get_conn() + cursor = conn.cursor() + version = self._detect_vault_version(cursor) + status["version"] = version + except Exception: + pass + return status + + def _iter_integrity_rows(self): + """Flat helper: streaming rows for integrity check, reusable.""" + conn = self._get_conn() + cursor = conn.cursor() + if self._vault_version == 2: + cursor.execute( + "SELECT id, credential_uuid, service, username, encrypted_password, encrypted_note FROM credentials" + ) + else: + cursor.execute("SELECT id, service, username, encrypted_password, encrypted_note FROM credentials") + for row in cursor: + yield row + + def iter_integrity_issues(self, batch_size: int = 0): + """Streaming integrity check — 60 fps / 2B friendly, flat & reusable. + + Yields issues one-by-one without materializing all rows. If batch_size >0, + yields in chunks for progress UI. No hard-coded inline limits. + """ + from .constants import _VAULT_INTEGRITY_BATCH_SIZE as _BATCH + + if batch_size == 0: + batch_size = _BATCH + self._require_unlocked() + fernet = self._fernet + batch: List[Dict[str, object]] = [] + for row in self._iter_integrity_rows(): + try: + self._decrypt_credential_fields(row, fernet) # type: ignore[arg-type] + except Exception as exc: + issue = {"id": row["id"], "service": row["service"], "error": str(exc)} + if batch_size <= 1: + yield issue + else: + batch.append(issue) + if len(batch) >= batch_size: + for item in batch: + yield item + batch.clear() + if batch: + for item in batch: + yield item + + def check_vault_integrity(self) -> List[Dict[str, object]]: + """Decrypt every credential and report failures — flat delegation to streaming.""" + return list(self.iter_integrity_issues()) diff --git a/generate_it/tui.py b/generate_it/tui.py index 4f1adf5..362ad0b 100644 --- a/generate_it/tui.py +++ b/generate_it/tui.py @@ -15,6 +15,9 @@ - e: export CSV - a: add credential manually - t: security settings +- p: change master password +- k: rotate DEK (v2) +- m: vault health / prune backups - ?: hotkey legend - b: jump focus to mode """ @@ -45,31 +48,26 @@ ) APP_NAME = "Generate It" -ESC_QUIT_WINDOW_SECONDS = 1.0 -AUTO_LOCK_SCREEN_OFF = "screen_off" -SCREEN_OFF_LOCK_GAP_SECONDS = 20.0 - -CLIPBOARD_AUTO_CLEAR_OPTIONS: tuple[tuple[str, int | None], ...] = ( - ("No auto-clear", None), - ("15 seconds", 15), - ("30 seconds", 30), - ("45 seconds", 45), - ("1 minute", 60), - ("2 minutes", 120), - ("3 minutes", 180), +# Single source of truth — see generate_it/constants.py +from .constants import ( # noqa: E402 + AUTO_LOCK_OPTIONS, + AUTO_LOCK_SCREEN_OFF, + CLIPBOARD_AUTO_CLEAR_OPTIONS, + ESC_QUIT_WINDOW_SECONDS, + SCREEN_OFF_LOCK_GAP_SECONDS, + SETTING_KEY_AUTO_LOCK_INDEX, + SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX, + TUI_CLOCK_REFRESH_S, + TUI_FRAME_INTERVAL_MS, + TUI_FRAME_INTERVAL_S, + TUI_INPUT_TIMEOUT_MS, + TUI_MIN_HEIGHT, + TUI_MIN_TERM_HEIGHT, + TUI_MIN_TERM_WIDTH, + TUI_MIN_WIDTH, + TUI_MODAL_TIMEOUT_MS, ) -AUTO_LOCK_OPTIONS: tuple[tuple[str, int | str | None], ...] = ( - ("No auto-lock", None), - ("Lock when screen off", AUTO_LOCK_SCREEN_OFF), - ("5 minutes", 5 * 60), - ("10 minutes", 10 * 60), - ("15 minutes", 15 * 60), -) - -SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX = "clipboard_auto_clear_index" -SETTING_KEY_AUTO_LOCK_INDEX = "auto_lock_index" - class QuitApp(Exception): """Raised when the user requests to quit from anywhere in the TUI.""" @@ -83,6 +81,7 @@ def _save_credential_duplicate_safe( password: str, note: str = "", note_is_hidden: bool = False, + url: str = "", ) -> str: """Save credential; prompt to overwrite if a duplicate exists.""" if not state.storage: @@ -99,15 +98,66 @@ def _save_credential_duplicate_safe( ) if not confirm or confirm.strip().lower() != "overwrite": return "cancelled" - - state.storage.update_credential(existing["id"], service, username, password, note, note_is_hidden) + # Preserve existing url if new url empty. + existing_url = existing.get("url", "") or "" + url_to_store = url if url else existing_url + state.storage.update_credential(existing["id"], service, username, password, note, note_is_hidden, url_to_store) state.vault_credentials = state.storage.list_credential_metadata() return "overwritten" - state.storage.save_credential(service, username, password, note, note_is_hidden) + state.storage.save_credential(service, username, password, note, note_is_hidden, url) state.vault_credentials = state.storage.list_credential_metadata() return "saved" + +def _vault_header(inner_w: int) -> str: + """Return column header; include URL column when width permits.""" + if inner_w >= 70: + return f"{'Service':<18} {'Username':<18} {'URL':<20}" + return f"{'Service':<20} {'Username':<20}" + + +def _vault_row(cred: dict, inner_w: int) -> str: + """Format a vault row; include URL when width permits (flat helper).""" + svc = str(cred.get("service", "")) + usr = str(cred.get("username", "")) + url = str(cred.get("url", "") or "") + if inner_w >= 70 and url: + # 18 + 1 + 18 + 1 + remainder for URL + url_w = max(12, inner_w - 38) + svc_col = svc[:18].ljust(18) + usr_col = usr[:18].ljust(18) + url_col = _truncate_middle(url, url_w) + return f"{svc_col} {usr_col} {url_col}" + return f"{svc:<20} {usr:<20}" + + +def _get_filtered_vault_credentials(state: AppState, query: str) -> list[dict]: + """Flat helper: DB-side search for large vaults (2B/60fps), else in-memory fuzzy, reusable.""" + from .constants import _VAULT_FILTER_MAX_RESULTS, _VAULT_PAGE_SIZE + + # Use DB for large vaults to avoid materializing 2B list scan at 60 fps + if state.storage and state.vault_unlocked: + try: + total = len(state.vault_credentials) + except Exception: + total = 0 + if total > _VAULT_PAGE_SIZE and query.strip(): + try: + return state.storage.search_credential_metadata( + query, limit=_VAULT_FILTER_MAX_RESULTS + ) + except StorageError: + pass + # Fallback: in-memory fuzzy with streaming cap + from .constants import _VAULT_FILTER_MAX_RESULTS as _LIMIT + + try: + return _filter_vault_credentials(state.vault_credentials, query, limit=_LIMIT) + except TypeError: + return _filter_vault_credentials(state.vault_credentials, query) + + def _resolve_start_dir(path_text: str) -> Path: if not path_text.strip(): return Path.cwd() @@ -139,6 +189,20 @@ def _run_fuzzy_file_picker( cached_query: str | None = None matches: list[tuple[int, str, Path]] = [] + def _score_files_flat(q: str, file_list: list[Path]) -> list[tuple[int, str, Path]]: + """Flat helper: score files for streaming, reusable, no hard-coded limit.""" + from .constants import _VAULT_FUZZY_MAX_CANDIDATES as _LIMIT + + scored: list[tuple[int, str, Path]] = [] + for p in file_list: + rel = str(p.relative_to(root_dir)) + score = _fuzzy_score(q, rel) + if score is None: + continue + scored.append((score, rel, p)) + scored.sort(key=lambda item: (item[0], len(item[1]), item[1])) + return scored[:_LIMIT] + while True: h, w = stdscr.getmaxyx() box_h = min(max(14, int(h * 0.8)), max(12, h - 2)) @@ -146,6 +210,7 @@ def _run_fuzzy_file_picker( y, x = (h - box_h) // 2, (w - box_w) // 2 win = window_cache.get(box_h, box_w, y, x) + win.timeout(TUI_MODAL_TIMEOUT_MS) win.erase() win.box() @@ -167,15 +232,7 @@ def _run_fuzzy_file_picker( pass if cached_query != query: - scored: list[tuple[int, str, Path]] = [] - for p in files: - rel = str(p.relative_to(root_dir)) - score = _fuzzy_score(query, rel) - if score is None: - continue - scored.append((score, rel, p)) - scored.sort(key=lambda item: (item[0], len(item[1]), item[1])) - matches = scored[:500] + matches = _score_files_flat(query, files) cached_query = query content_y = 4 @@ -210,6 +267,8 @@ def _run_fuzzy_file_picker( win.refresh() key = win.getch() + if key == -1: + continue if key in (27, ord('q'), ord('Q')): return None @@ -259,6 +318,7 @@ def _run_file_browser_modal( y, x = (h - box_h) // 2, (w - box_w) // 2 win = window_cache.get(box_h, box_w, y, x) + win.timeout(TUI_MODAL_TIMEOUT_MS) win.erase() win.box() @@ -340,6 +400,8 @@ def _run_file_browser_modal( win.refresh() key = win.getch() + if key == -1: + continue if key in (27, ord('q'), ord('Q')): return None @@ -424,6 +486,7 @@ def _run_path_modal( y, x = (h - box_h) // 2, (w - box_w) // 2 win = window_cache.get(box_h, box_w, y, x) + win.timeout(TUI_MODAL_TIMEOUT_MS) win.erase() win.box() @@ -454,6 +517,8 @@ def _run_path_modal( win.refresh() key = win.getch() + if key == -1: + continue if key == 27: return None @@ -535,7 +600,7 @@ def _run_security_settings_modal( inner_w = max(10, box_w - 4) win = window_cache.get(box_h, box_w, y, x) - win.timeout(250) + win.timeout(TUI_MODAL_TIMEOUT_MS) win.erase() win.box() @@ -619,32 +684,34 @@ def _auto_lock_setting(state: AppState) -> int | str | None: return AUTO_LOCK_OPTIONS[state.auto_lock_index][1] def _persist_security_settings(state: AppState) -> None: + """Flat helper: batch persist security settings, reusable.""" if not state.storage: return try: - state.storage.set_app_setting( - SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX, - str(state.clipboard_auto_clear_index), - ) - state.storage.set_app_setting( - SETTING_KEY_AUTO_LOCK_INDEX, - str(state.auto_lock_index), + state.storage.set_app_settings( + { + SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX: str(state.clipboard_auto_clear_index), + SETTING_KEY_AUTO_LOCK_INDEX: str(state.auto_lock_index), + } ) except StorageError: pass + def _load_security_settings(state: AppState) -> None: + """Flat helper: batch load security settings, reusable.""" if not state.storage: return try: - clip_raw = state.storage.get_app_setting( - SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX, - str(state.clipboard_auto_clear_index), - ) - lock_raw = state.storage.get_app_setting( - SETTING_KEY_AUTO_LOCK_INDEX, - str(state.auto_lock_index), + raw = state.storage.get_app_settings( + [SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX, SETTING_KEY_AUTO_LOCK_INDEX], + { + SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX: str(state.clipboard_auto_clear_index), + SETTING_KEY_AUTO_LOCK_INDEX: str(state.auto_lock_index), + }, ) + clip_raw = raw.get(SETTING_KEY_CLIPBOARD_AUTO_CLEAR_INDEX) + lock_raw = raw.get(SETTING_KEY_AUTO_LOCK_INDEX) state.clipboard_auto_clear_index = _coerce_index( clip_raw, len(CLIPBOARD_AUTO_CLEAR_OPTIONS), @@ -686,6 +753,16 @@ def _auto_lock_reason_text(state: AppState) -> str: return "Vault auto-locked after screen-off/sleep detection." return f"Vault auto-locked after {_auto_lock_label(state)} of inactivity." +def _should_render_frame(now: float, last_render_at: float) -> bool: + """Flat helper: check if a 60 fps frame is due, reusable.""" + return (now - last_render_at) >= TUI_FRAME_INTERVAL_S + + +def _should_refresh_clock(now: float, last_clock_at: float) -> bool: + """Flat helper: clock needs 1 Hz update, reusable.""" + return (now - last_clock_at) >= TUI_CLOCK_REFRESH_S + + def _maybe_auto_clear_clipboard(state: AppState, now: float | None = None) -> bool: if state.clipboard_clear_due_at is None: return False @@ -738,6 +815,76 @@ def _lock_vault(state: AppState) -> None: state.vault_scroll_y = 0 state.revealed_secret = None state.revealed_secret_id = None + # Clear caches that may retain sensitive material (passwords, service + # names) or user file-system traces after lock — defense in depth for + # memory retention. Wordlist and file-picker caches are not secret + # per se, but their contents (which wordlists/files the user browsed) + # are sensitive on a shared machine and are evicted on lock. + try: + from . import tui_render as _R + _R._OUTPUT_WRAP_CACHE.clear() + _R._ENTROPY_CACHE.clear() + except Exception: + pass + try: + from .identity import clear_identity_cache + clear_identity_cache() + except Exception: + pass + try: + from . import generator as _gen + _gen.clear_wordlist_cache() + except Exception: + pass + try: + from . import tui_files as _tf + _tf.clear_file_picker_cache() + except Exception: + pass + + +def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: + """Flat helper: render footer bar, reusable, no hard-coded inline widths.""" + h, w = stdscr.getmaxyx() + msg = message[: max(0, w - 1)] + combined = "Tab/↑/↓: Move • Enter/g: Generate • s: Save • t: Security • p: Pass • k: DEK • m: Health • /:Search • i/e:CSV • v:Vault • a:Add • Esc×2:Quit" + R._addstr_safe(stdscr, h - 2, 0, " " * max(0, w - 1), theme.dim) + R._addstr_safe(stdscr, h - 2, 1, msg, theme.accent) + R._addstr_safe(stdscr, h - 1, 0, " " * max(0, w - 1), theme.dim) + R._addstr_safe(stdscr, h - 1, 1, combined[: max(0, w - 2)], theme.dim) + + +def _render_dashboard( + stdscr: curses.window, + theme: Theme, + state: AppState, + words: list[str], + header_end: int, +) -> None: + """Flat helper: render all dashboard boxes, reusable for 60 fps streaming.""" + h, w = stdscr.getmaxyx() + footer_h = 2 + body_y = header_end + body_h = max(1, h - body_y - footer_h) + gap = 1 + left_w = max(34, min((w - gap) // 2, w - gap - 30)) + right_x = left_w + gap + right_w = max(1, w - right_x) + mode_h = 6 + actions_h = 7 + settings_h = max(6, body_h - mode_h - actions_h - 2 * gap) + info_h = 8 + output_h = max(6, body_h - info_h - gap) + info_h = max(6, body_h - output_h - gap) + focus_items = _get_cached_focus_items(state) + state.focus_index = max(0, min(state.focus_index, len(focus_items) - 1)) + focus_id = focus_items[state.focus_index] + R._render_mode_box(stdscr, theme, y=body_y, x=0, h=mode_h, w=left_w, state=state, focus_id=focus_id) + R._render_settings_box(stdscr, theme, y=body_y + mode_h + gap, x=0, h=settings_h, w=left_w, state=state, focus_id=focus_id) + R._render_actions_box(stdscr, theme, y=body_y + mode_h + gap + settings_h + gap, x=0, h=actions_h, w=left_w, state=state, focus_id=focus_id) + R._render_output_box(stdscr, theme, y=body_y, x=right_x, h=output_h, w=right_w, state=state) + R._render_info_box(stdscr, theme, y=body_y + output_h + gap, x=right_x, h=info_h, w=right_w, state=state, wordlist_size=len(words)) + _render_footer(stdscr, theme, state.message) def _focus_items(state: AppState) -> list[str]: items = ["mode_chars", "mode_words", "mode_username"] @@ -755,14 +902,14 @@ def _focus_items(state: AppState) -> list[str]: else: # words items += ["username_word_count", "username_separator", "username_add_numbers"] items += ["generate"] - + # Manual add is always available when vault is unlocked if state.vault_unlocked: items.append("manual_add") # Add Save button if there is output if state.output and state.vault_unlocked: items.append("save") - + return items @@ -926,6 +1073,8 @@ def _gen_user(): state.message = "Save cancelled." return + url = tui_modal._run_modal(stdscr, theme, "SAVE", "URL (optional, Enter to skip):", max_length=500) + url = (url or "").strip() note = tui_modal._run_modal(stdscr, theme, "SAVE", "Note (optional, Enter to skip):", max_length=500) note_is_hidden = False if note: @@ -941,6 +1090,7 @@ def _gen_user(): password=final_password, note=note or "", note_is_hidden=note_is_hidden, + url=url or "", ) if result == "saved": state.message = f"Saved credential for {service}." @@ -962,12 +1112,12 @@ def _run_details_modal( ) -> None: """Runs a modal to show credential details and allow copying.""" h, w = stdscr.getmaxyx() - box_h, box_w = 14, 60 + box_h, box_w = 16, 60 y, x = (h - box_h) // 2, (w - box_w) // 2 - + win = curses.newwin(box_h, box_w, y, x) win.keypad(True) - win.timeout(250) + win.timeout(TUI_MODAL_TIMEOUT_MS) feedback_text = "" feedback_attr = theme.dim feedback_until = 0.0 @@ -978,7 +1128,7 @@ def _run_details_modal( if state.storage and state.revealed_secret_id != credential['id']: state.revealed_secret = state.storage.get_credential_secret(credential['id']) state.revealed_secret_id = credential['id'] - + while True: if _maybe_auto_clear_clipboard(state): state.message = "Clipboard auto-cleared." @@ -989,17 +1139,17 @@ def _run_details_modal( return win.erase() win.box() - + # Title win.addstr(0, 2, " CREDENTIAL DETAILS ", theme.title) - + # Content # We use safe addstr to avoid crashing if strings are too long row = 2 - + label_attr = theme.dim val_attr = curses.A_BOLD - + win.addstr(row, 2, "Service:", label_attr) win.addstr(row, 12, R._sanitize_terminal_text(credential['service'][:box_w-14]), val_attr) row += 2 @@ -1007,7 +1157,14 @@ def _run_details_modal( win.addstr(row, 2, "Username:", label_attr) win.addstr(row, 12, R._sanitize_terminal_text(credential['username'][:box_w-14]), val_attr) row += 2 - + + # URL if present + url_text = state.revealed_secret.get('url', '') if state.revealed_secret else credential.get('url', '') + if url_text: + win.addstr(row, 2, "URL:", label_attr) + win.addstr(row, 12, R._sanitize_terminal_text(url_text[:box_w-14]), val_attr) + row += 2 + # Note note_text = state.revealed_secret.get('note', '') if state.revealed_secret else '' note_is_hidden = state.revealed_secret.get('note_is_hidden', False) if state.revealed_secret else False @@ -1038,22 +1195,24 @@ def _run_details_modal( masked = "*" * min(len(state.revealed_secret['password']), 20) win.addstr(row, 12, masked[:box_w-14], val_attr) row += 2 - + win.addstr(row, 2, "Created:", label_attr) win.addstr(row, 12, R._sanitize_terminal_text(str(credential['created_at'])[:box_w-14])) - + # Footer - stacked on two lines for better readability line1 = "c: Copy Pass u: Copy User" + if url_text: + line1 += " o: Copy URL" if note_text: line1 += " n: Copy Note" line1 += " r: Hide" if password_revealed else " r: Reveal" - + line2_parts = [] if note_text: line2_parts.append("h: Show/Hide Note") line2_parts.append("Esc: Close") line2 = " ".join(line2_parts) - + footer_text = line2 footer_attr = theme.dim if feedback_text and time.monotonic() < feedback_until: @@ -1061,20 +1220,20 @@ def _run_details_modal( footer_attr = feedback_attr win.addstr(box_h - 3, 2, R._sanitize_terminal_text(line1[:box_w-4]), theme.dim) win.addstr(box_h - 2, 2, R._sanitize_terminal_text(footer_text[:box_w-4]), footer_attr) - + win.refresh() - + key = win.getch() if key == -1: continue _record_user_activity(state) - + if key in (27, ord('q'), ord('Q')): # Esc/q return - + elif key in (ord('r'), ord('R')): password_revealed = not password_revealed - + elif key in (ord('c'), ord('C')): try: if state.revealed_secret: @@ -1117,19 +1276,40 @@ def _run_details_modal( feedback_attr = theme.warn feedback_until = time.monotonic() + 0.5 + elif key in (ord('o'), ord('O')): + url_to_copy = url_text + if url_to_copy: + try: + msg = tui_flow._copy_to_clipboard_with_policy(state, url_to_copy) + feedback_text = " COPIED URL! " + feedback_attr = theme.ok + feedback_until = time.monotonic() + 0.5 + state.message = msg + except (StorageError, PyperclipException): + feedback_text = " CLIPBOARD COPY FAILED " + feedback_attr = theme.warn + feedback_until = time.monotonic() + 1.5 + else: + feedback_text = " NO URL TO COPY! " + feedback_attr = theme.warn + feedback_until = time.monotonic() + 0.5 + elif key in (ord('h'), ord('H')): if note_text: try: cred_id = credential['id'] current_hidden = note_is_hidden if state.storage and state.revealed_secret: + # Preserve url on hide toggle + url_to_preserve = url_text if 'url_text' in locals() else (state.revealed_secret.get('url', '') or credential.get('url','')) state.storage.update_credential( cred_id, credential['service'], credential['username'], state.revealed_secret['password'], state.revealed_secret['note'], - not current_hidden + not current_hidden, + url_to_preserve, ) state.vault_credentials = state.storage.list_credential_metadata() state.revealed_secret['note_is_hidden'] = not current_hidden @@ -1158,7 +1338,7 @@ def _run_vault_modal( if not state.vault_unlocked or not state.storage: tui_modal._run_modal(stdscr, theme, "ERROR", "Vault locked or unavailable.") return - + # Reload credentials state.vault_credentials = state.storage.list_credential_metadata() vault_filter = "" @@ -1166,7 +1346,7 @@ def _run_vault_modal( window_cache = tui_modal._WindowCache() cached_filter_key: tuple[int, str] | None = None filtered_credentials: list[dict] = [] - + while True: if _maybe_auto_clear_clipboard(state): state.message = "Clipboard auto-cleared." @@ -1176,37 +1356,40 @@ def _run_vault_modal( tui_security._prompt_unlock_vault(stdscr, theme, state, reason=reason) return h, w = stdscr.getmaxyx() - + # Calculate box dimensions (80% of screen) box_h = max(10, int(h * 0.8)) box_w = max(40, int(w * 0.8)) y = (h - box_h) // 2 x = (w - box_w) // 2 - + # Draw background shadow/dimming? # Standard curses doesn't support transparency easily, so just draw the box. - - # We need to clear the area or redraw the whole screen behind it? + + # We need to clear the area or redraw the whole screen behind it? # Easier to just draw a solid box on top. win = window_cache.get(box_h, box_w, y, x) - win.timeout(250) + win.timeout(TUI_MODAL_TIMEOUT_MS) win.erase() win.box() - + # Title title = " VAULT EXPLORER " try: win.addstr(0, 2, title, theme.title) except curses.error: pass - + inner_h = box_h - 2 inner_w = box_w - 4 list_y = 1 filter_key = (id(state.vault_credentials), vault_filter) if cached_filter_key != filter_key: - filtered_credentials = _filter_vault_credentials(state.vault_credentials, vault_filter) + # Flat helper: choose DB vs in-memory path for 2B/60fps, reusable. + filtered_credentials = _get_filtered_vault_credentials( + state, vault_filter + ) cached_filter_key = filter_key if not filtered_credentials: @@ -1223,17 +1406,17 @@ def _run_vault_modal( except curses.error: pass list_y += 1 - + # Header - headers = f"{'Service':<20} {'Username':<20}" + headers = _vault_header(inner_w) try: win.addstr(list_y, 2, headers[:inner_w], theme.dim | curses.A_UNDERLINE) except curses.error: pass - + list_y += 2 content_h = max(1, inner_h - 4) # Reserve space for filter line + footer - + if not filtered_credentials: try: win.addstr(list_y, 2, "No matching credentials.", theme.dim) @@ -1241,28 +1424,25 @@ def _run_vault_modal( pass else: total = len(filtered_credentials) - + # Scrolling if state.vault_selected_idx < state.vault_scroll_y: state.vault_scroll_y = state.vault_selected_idx elif state.vault_selected_idx >= state.vault_scroll_y + content_h: state.vault_scroll_y = state.vault_selected_idx - content_h + 1 - + # Clamp scroll state.vault_scroll_y = max(0, min(state.vault_scroll_y, total - 1)) - + start = state.vault_scroll_y end = min(total, start + content_h) - + for i in range(start, end): cred = filtered_credentials[i] is_selected = (i == state.vault_selected_idx) - + attr = theme.focus if is_selected else 0 - s_serv = cred['service'] - s_user = cred['username'] - - line = f"{s_serv:<20} {s_user:<20}" + line = _vault_row(cred, inner_w) try: win.addstr(list_y + (i - start), 2, R._sanitize_terminal_text(line[:inner_w]), attr) except curses.error: @@ -1276,7 +1456,7 @@ def _run_vault_modal( ] else: footer_lines = [ - "Enter: details e: edit c: copy pass u: copy user", + "Enter: details e: edit c: copy pass u: copy user o: copy url", "d: delete /: search Esc/v: close", ] try: @@ -1284,9 +1464,9 @@ def _run_vault_modal( win.addstr(box_h - 3 + i, 2, line[:inner_w], theme.dim) except curses.error: pass - + win.refresh() - + key = win.getch() if key == -1: continue @@ -1297,10 +1477,10 @@ def _run_vault_modal( search_mode = False continue return - + if key in (ord('v'), ord('V'), ord('q'), ord('Q')): # v/q return - + if key == ord('/'): search_mode = True continue @@ -1328,7 +1508,7 @@ def _run_vault_modal( elif key in (curses.KEY_DOWN, ord('j')): if filtered_credentials: state.vault_selected_idx = min(len(filtered_credentials) - 1, state.vault_selected_idx + 1) - + elif key in (ord('e'), ord('E')): if filtered_credentials: cred = filtered_credentials[state.vault_selected_idx] @@ -1376,26 +1556,31 @@ def _run_vault_modal( continue try: + existing_url = secret.get("url", "") or cred.get("url", "") + url = tui_modal._run_modal(stdscr, theme, "EDIT", "URL (optional):", max_length=500, initial_value=existing_url) + if url is None: + continue + url = url.strip() # Get existing note for the credential existing_note = secret.get("note", "") existing_hidden = secret.get("note_is_hidden", False) note = tui_modal._run_modal(stdscr, theme, "EDIT", "Note (optional):", max_length=500, initial_value=existing_note) if note is None: continue - + # Ask if user wants to hide the note hide_option = "y" if existing_hidden else "n" hide_note = tui_modal._run_modal( - stdscr, theme, "EDIT", - f"Hide note? (y/n) [{hide_option}]:", - max_length=1, + stdscr, theme, "EDIT", + f"Hide note? (y/n) [{hide_option}]:", + max_length=1, initial_value=hide_option ) if hide_note is None: continue note_is_hidden = hide_note.lower() == "y" - - state.storage.update_credential(cred["id"], service, username, password, note, note_is_hidden) + + state.storage.update_credential(cred["id"], service, username, password, note, note_is_hidden, url) state.vault_credentials = state.storage.list_credential_metadata() refreshed_filtered = _filter_vault_credentials(state.vault_credentials, vault_filter) @@ -1412,7 +1597,7 @@ def _run_vault_modal( tui_modal._run_modal(stdscr, theme, "SUCCESS", "Credential updated.") except StorageError as e: tui_modal._run_modal(stdscr, theme, "ERROR", f"Update failed: {e}") - + elif key in (ord('c'), ord('C')): if filtered_credentials: cred = filtered_credentials[state.vault_selected_idx] @@ -1433,11 +1618,30 @@ def _run_vault_modal( except (StorageError, PyperclipException) as e: tui_modal._run_modal(stdscr, theme, "ERROR", f"Copy failed: {e}") + elif key in (ord('o'), ord('O')): + if filtered_credentials: + cred = filtered_credentials[state.vault_selected_idx] + url_val = cred.get("url") or "" + if not url_val and state.storage: + try: + secret = state.storage.get_credential_secret(cred["id"]) + url_val = secret.get("url", "") or "" + except StorageError: + url_val = "" + if url_val: + try: + msg = tui_flow._copy_to_clipboard_with_policy(state, url_val) + tui_modal._run_modal(stdscr, theme, "SUCCESS", msg) + except (StorageError, PyperclipException) as e: + tui_modal._run_modal(stdscr, theme, "ERROR", f"Copy failed: {e}") + else: + tui_modal._run_modal(stdscr, theme, "ERROR", "No URL to copy.") + elif key in (curses.KEY_ENTER, 10, 13): if filtered_credentials: cred = filtered_credentials[state.vault_selected_idx] _run_details_modal(stdscr, theme, state, cred) - + elif key in (ord('d'), ord('D')): if filtered_credentials: cred = filtered_credentials[state.vault_selected_idx] @@ -1471,10 +1675,10 @@ def _main(stdscr: curses.window) -> int: pass stdscr.keypad(True) - stdscr.timeout(250) + stdscr.timeout(TUI_INPUT_TIMEOUT_MS) h, w = stdscr.getmaxyx() - if h < 10 or w < 40: + if h < TUI_MIN_TERM_HEIGHT or w < TUI_MIN_TERM_WIDTH: stdscr.addstr(0, 0, "Terminal too small for Generate-It. Minimum: 40x10.") stdscr.refresh() curses.napms(2000) @@ -1482,7 +1686,7 @@ def _main(stdscr: curses.window) -> int: words = generator.load_wordlist() state = AppState() - + # --- Storage Initialization --- try: state.storage = StorageManager() @@ -1530,9 +1734,38 @@ def _main(stdscr: curses.window) -> int: break except StorageError as e: tui_modal._run_modal(stdscr, theme, "ERROR", f"Init failed: {e}. Press Enter.") + finally: + # Minimize lifetime of plaintext passwords in memory. + try: + # Overwrite local references (CPython strings are immutable; + # this at least drops the reference promptly). + pwd = "\x00" * len(pwd) + pwd2 = "\x00" * len(pwd2) + except Exception: + pass + try: + del pwd, pwd2 + except Exception: + pass else: tui_modal._run_modal(stdscr, theme, "ERROR", "Passwords do not match. Press Enter.") + # Clear promptly on mismatch as well. + try: + pwd = "\x00" * len(pwd) + pwd2 = "\x00" * len(pwd2) + except Exception: + pass + try: + del pwd, pwd2 + except Exception: + pass else: + # Sync persisted lockout (survives restarts) into in-memory state + # before the first unlock attempt. + try: + tui_security._sync_persistent_lockout_from_storage(state) + except Exception: + pass # Unlock existing vault while True: stdscr.erase() @@ -1540,7 +1773,7 @@ def _main(stdscr: curses.window) -> int: pwd = tui_modal._run_modal(stdscr, theme, "LOGIN", "Enter Master Password:", is_password=True) if pwd is None: # Cancelled return 0 - + setattr(state, tui_security._UNLOCK_RETRY_FLAG, False) setattr(state, tui_security._UNLOCK_CANCELLED_FLAG, False) try: @@ -1550,6 +1783,15 @@ def _main(stdscr: curses.window) -> int: cancelled = getattr(state, tui_security._UNLOCK_CANCELLED_FLAG, False) delattr(state, tui_security._UNLOCK_RETRY_FLAG) delattr(state, tui_security._UNLOCK_CANCELLED_FLAG) + # Minimize lifetime — clear plaintext pwd promptly. + try: + pwd = "\x00" * len(pwd) + except Exception: + pass + try: + del pwd + except Exception: + pass if unlocked: break if cancelled: @@ -1567,30 +1809,39 @@ def _main(stdscr: curses.window) -> int: _generate(state, words) last_esc_quit_at: float | None = None redraw = True + last_render_at = 0.0 + last_clock_at = time.monotonic() while True: + now = time.monotonic() if _maybe_auto_clear_clipboard(state): state.message = "Clipboard auto-cleared." + redraw = True if _should_auto_lock_now(state): reason = _auto_lock_reason_text(state) _lock_vault(state) tui_security._prompt_unlock_vault(stdscr, theme, state, reason=reason) stdscr.clear() redraw = True + last_render_at = 0.0 continue - if redraw: + needs_render = redraw or _should_render_frame(now, last_render_at) or _should_refresh_clock(now, last_clock_at) + if needs_render: stdscr.erase() header_end = R._render_header(stdscr, theme) h, w = stdscr.getmaxyx() - - min_w, min_h = 70, 20 - if w < min_w or h < min_h: + if w < TUI_MIN_WIDTH or h < TUI_MIN_HEIGHT: R._render_resize_hint(stdscr, theme) _render_footer(stdscr, theme, state.message) stdscr.refresh() + last_render_at = now + if _should_refresh_clock(now, last_clock_at): + last_clock_at = now + # Stream input at 60 fps — poll without blocking key = stdscr.getch() if key == -1: continue + redraw = True _record_user_activity(state) if key == 27: should_quit, last_esc_quit_at = tui_flow._handle_double_esc_quit( @@ -1605,109 +1856,17 @@ def _main(stdscr: curses.window) -> int: if key in (ord("q"), ord("Q")): state.message = "Press Esc twice to quit." continue - - footer_h = 2 - body_y = header_end - body_h = max(1, h - body_y - footer_h) - - gap = 1 - # Two columns - left_w = max(34, min((w - gap) // 2, w - gap - 30)) - right_x = left_w + gap - right_w = max(1, w - right_x) - - # Standard layout heights - mode_h = 6 - actions_h = 7 # Increased for Save button - settings_h = max(6, body_h - mode_h - actions_h - 2 * gap) - - # Right column: OUTPUT + INFO - info_h = 8 - output_h = max(6, body_h - info_h - gap) - info_h = max(6, body_h - output_h - gap) - - focus_items = _get_cached_focus_items(state) - state.focus_index = max(0, min(state.focus_index, len(focus_items) - 1)) - focus_id = focus_items[state.focus_index] - - # --- Rendering --- - - def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: - h, w = stdscr.getmaxyx() - - msg = message[: max(0, w - 1)] - - # Single combined hotkey line spanning the full width - combined = "Tab/↑/↓: Move • Enter/g: Generate • s: Save • t: Security • /: Search • i/e: CSV • v: Vault • a: Add • Esc×2: Quit" - - R._addstr_safe(stdscr, h - 2, 0, " " * max(0, w - 1), theme.dim) - R._addstr_safe(stdscr, h - 2, 1, msg, theme.accent) - - R._addstr_safe(stdscr, h - 1, 0, " " * max(0, w - 1), theme.dim) - R._addstr_safe(stdscr, h - 1, 1, combined[: max(0, w - 2)], theme.dim) - - # Mode box is always visible - R._render_mode_box( - stdscr, - theme, - y=body_y, - x=0, - h=mode_h, - w=left_w, - state=state, - focus_id=focus_id, - ) - - # Standard Generator Layout - R._render_settings_box( - stdscr, - theme, - y=body_y + mode_h + gap, - x=0, - h=settings_h, - w=left_w, - state=state, - focus_id=focus_id, - ) - R._render_actions_box( - stdscr, - theme, - y=body_y + mode_h + gap + settings_h + gap, - x=0, - h=actions_h, - w=left_w, - state=state, - focus_id=focus_id, - ) - - R._render_output_box( - stdscr, - theme, - y=body_y, - x=right_x, - h=output_h, - w=right_w, - state=state, - ) - R._render_info_box( - stdscr, - theme, - y=body_y + output_h + gap, - x=right_x, - h=info_h, - w=right_w, - state=state, - wordlist_size=len(words), - ) - - _render_footer(stdscr, theme, state.message) + _render_dashboard(stdscr, theme, state, words, header_end) stdscr.refresh() + last_render_at = now + if _should_refresh_clock(now, last_clock_at): + last_clock_at = now redraw = False key = stdscr.getch() - redraw = (key != -1) if key == -1: continue + redraw = True _record_user_activity(state) if key == 27: should_quit, last_esc_quit_at = tui_flow._handle_double_esc_quit( @@ -1727,6 +1886,10 @@ def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: redraw = True continue + focus_items = _get_cached_focus_items(state) + state.focus_index = max(0, min(state.focus_index, len(focus_items) - 1)) + focus_id = focus_items[state.focus_index] if focus_items else "" + # Navigation if key in (9,): # Tab state.focus_index = (state.focus_index + 1) % len(focus_items) @@ -1734,7 +1897,7 @@ def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: if key == curses.KEY_BTAB: # Shift-Tab state.focus_index = (state.focus_index - 1) % len(focus_items) continue - + # Up/Down navigation (Standard) if key in (curses.KEY_UP, ord("k")): state.focus_index = (state.focus_index - 1) % len(focus_items) @@ -1779,6 +1942,9 @@ def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: import_csv = key in (ord("i"), ord("I")) export_csv = key in (ord("e"), ord("E")) manual_add = key in (ord("a"), ord("A")) + change_password = key in (ord("p"), ord("P")) + rotate_dek = key in (ord("k"), ord("K")) + vault_health = key in (ord("m"), ord("M")) show_help = key == ord("?") if show_help: @@ -1787,6 +1953,9 @@ def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: "g : Generate new credential", "s : Save generated credential", "t : Security settings", + "p : Change master password", + "k : Rotate DEK (v2 vaults)", + "m : Vault health & backups", "/ : Quick vault search", "v : Open Vault Explorer", "i : Import credentials from CSV (choose format)", @@ -1827,6 +1996,21 @@ def _render_footer(stdscr: curses.window, theme: Theme, message: str) -> None: stdscr.clear() continue + if change_password: + tui_security.prompt_change_master_password(stdscr, theme, state) + stdscr.clear() + continue + + if rotate_dek: + tui_security.prompt_rotate_dek(stdscr, theme, state) + stdscr.clear() + continue + + if vault_health: + tui_security.prompt_vault_health(stdscr, theme, state) + stdscr.clear() + continue + if open_security_settings: _run_security_settings_modal(stdscr, theme, state) state.message = ( @@ -1903,13 +2087,15 @@ def _gen_pwd(): state.message = "Add cancelled." stdscr.clear() continue - + + url = tui_modal._run_modal(stdscr, theme, "ADD", "URL (optional, Enter to skip):", max_length=500) + url = (url or "").strip() note = tui_modal._run_modal(stdscr, theme, "ADD", "Note (optional, Enter to skip):", max_length=500) note_is_hidden = False if note: hide_note = tui_modal._run_modal(stdscr, theme, "ADD", "Hide note? (y/n) [n]:", max_length=1, initial_value="n") note_is_hidden = bool(hide_note and hide_note.lower() == "y") - + result = _save_credential_duplicate_safe( stdscr, theme, @@ -1919,6 +2105,7 @@ def _gen_pwd(): password=password, note=note or "", note_is_hidden=note_is_hidden, + url=url or "", ) if result == "saved": state.message = f"Added credential for {service}." @@ -1935,7 +2122,7 @@ def _gen_pwd(): if open_vault: _run_vault_modal(stdscr, theme, state) # Force full redraw after modal closes - stdscr.clear() + stdscr.clear() continue if generate_now: @@ -1948,7 +2135,7 @@ def _gen_pwd(): tui_modal._run_modal(stdscr, theme, "ERROR", "Vault is locked or unavailable.") stdscr.clear() continue - + selected_export_format = _prompt_csv_format(stdscr, theme, mode="export") if selected_export_format is None: state.message = "Export cancelled." @@ -1980,7 +2167,7 @@ def _gen_pwd(): tui_modal._run_modal(stdscr, theme, "ERROR", "Vault is locked or unavailable.") stdscr.clear() continue - + selected_import_format = _prompt_csv_format(stdscr, theme, mode="import") if selected_import_format is None: state.message = "Import cancelled." diff --git a/generate_it/tui_files.py b/generate_it/tui_files.py index 4603e27..2600c3d 100644 --- a/generate_it/tui_files.py +++ b/generate_it/tui_files.py @@ -1,27 +1,82 @@ from __future__ import annotations import os +import time from pathlib import Path +from .constants import ( + _FILE_PICKER_CACHE_TTL_SECONDS, + _FILE_PICKER_MAX_DEPTH, + _FILE_PICKER_MAX_FILES, +) -def _collect_files_for_fuzzy(root_dir: Path, max_files: int = 5000, max_depth: int = 8) -> list[Path]: - files: list[Path] = [] - root = root_dir.expanduser() - if not root.exists() or not root.is_dir(): - return files +# Flat modular cache — reusable, no hard-coded inline values. +_FILE_PICKER_CACHE: dict[Path, tuple[float, tuple[Path, ...]]] = {} + + +def _is_hidden_name(name: str) -> bool: + """Flat helper: check hidden file/dir, reusable.""" + return name.startswith(".") + +def _should_prune_depth(current_depth: int, max_depth: int) -> bool: + """Flat helper: depth pruning decision, reusable.""" + return current_depth >= max_depth + + +def _walk_files_flat( + root: Path, max_files: int, max_depth: int +) -> list[Path]: + """Flat helper: walk and collect files, reusable.""" + files: list[Path] = [] root_depth = len(root.parts) for dirpath, dirnames, filenames in os.walk(root): dir_path = Path(dirpath) depth = len(dir_path.parts) - root_depth - dirnames[:] = [d for d in dirnames if not d.startswith(".")] - if depth >= max_depth: + dirnames[:] = [d for d in dirnames if not _is_hidden_name(d)] + if _should_prune_depth(depth, max_depth): dirnames[:] = [] - for filename in filenames: - if filename.startswith("."): + if _is_hidden_name(filename): continue files.append(dir_path / filename) if len(files) >= max_files: return files return files + + +def _collect_files_for_fuzzy( + root_dir: Path, + max_files: int = _FILE_PICKER_MAX_FILES, + max_depth: int = _FILE_PICKER_MAX_DEPTH, +) -> list[Path]: + """Collect files for fuzzy picker with short TTL cache — flat & reusable. + + Defaults come from constants, no hard-coded inline limits. Cache keeps + data travel fast for repeated picker opens. + """ + root = root_dir.expanduser() + if not root.exists() or not root.is_dir(): + return [] + try: + resolved = root.resolve() + except OSError: + resolved = root + now = time.monotonic() + cached = _FILE_PICKER_CACHE.get(resolved) + if cached is not None: + cached_at, cached_files = cached + if (now - cached_at) < _FILE_PICKER_CACHE_TTL_SECONDS: + return list(cached_files) + files = _walk_files_flat(resolved, max_files, max_depth) + # Bounded cache: keep at most 8 roots + if len(_FILE_PICKER_CACHE) >= 8: + oldest = next(iter(_FILE_PICKER_CACHE)) + del _FILE_PICKER_CACHE[oldest] + _FILE_PICKER_CACHE[resolved] = (now, tuple(files)) + return files + + +def clear_file_picker_cache() -> None: + """Clear fuzzy file cache — reusable for tests.""" + _FILE_PICKER_CACHE.clear() diff --git a/generate_it/tui_helpers.py b/generate_it/tui_helpers.py index 361a1ba..6eb482e 100644 --- a/generate_it/tui_helpers.py +++ b/generate_it/tui_helpers.py @@ -10,46 +10,42 @@ from .tui_state import AppState -def _sanitize_terminal_text(text: str) -> str: - """Replace control characters with visible escaped representations. +def _build_sanitize_translate_table() -> dict[int, str | None]: + """Flat helper: build translate table for sanitize, reusable, no hard-coded inline loops elsewhere.""" + table: dict[int, str | None] = {} + # Explicit escapes + table[0x0A] = "\\n" + table[0x09] = "\\t" + table[0x0D] = "\\r" + table[0x08] = "\\b" + table[0x1B] = "\\e" + # Remaining C0 controls 0x00-0x1F + for cp in range(0x00, 0x20): + if cp not in table: + table[cp] = f"\\x{cp:02x}" + # DEL + C1 0x7F-0x9F + for cp in range(0x7F, 0xA0): + table[cp] = f"\\x{cp:02x}" + # Zero-width strip + for cp in range(0x200B, 0x2010): + table[cp] = None + table[0xFEFF] = None + # Bidi -> "?" + for cp in range(0x202A, 0x2030): + table[cp] = "?" + return table + + +_SANITIZE_TABLE: dict[int, str | None] = _build_sanitize_translate_table() + - Prevents control-character injection from user-controlled strings - (service, username, note, import results) reaching curses.addstr() - unchanged. Printable Unicode (including non-ASCII) is left alone. +def _sanitize_terminal_text(text: str) -> str: + """Replace control characters with visible escaped representations — 60 fps path. - Also strips Unicode zero-width characters (U+200B–200F, U+FEFF) - that are invisible and can be used for homograph/phishing attacks, - and replaces bidi control characters (U+202A–202F) with a visible - "?" so the user is aware something was there. + Uses a pre-built translate table for 2B-scale streaming so data travels + without per-char branching. Flat, reusable via _SANITIZE_TABLE. """ - result: list[str] = [] - for ch in text: - cp = ord(ch) - if cp == 0x0A: # \n - result.append("\\n") - elif cp == 0x09: # \t - result.append("\\t") - elif cp == 0x0D: # \r - result.append("\\r") - elif cp == 0x08: # \b (backspace) - result.append("\\b") - elif cp == 0x1B: # ESC - result.append("\\e") - elif cp < 0x20: # other C0 controls (0x00–0x1F) - result.append(f"\\x{cp:02x}") - elif 0x7F <= cp <= 0x9F: # DEL (0x7F) + C1 controls (0x80–0x9F) - result.append(f"\\x{cp:02x}") - elif 0x200B <= cp <= 0x200F: # zero-width chars: ZWSP, ZWNJ, ZWJ, ZWNBS, etc. - # Strip entirely — invisible, serve no legitimate purpose in credential names. - continue - elif 0x202A <= cp <= 0x202F: # bidi controls: LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI - # Replace with visible "?" so the user is aware something was there. - result.append("?") - elif cp == 0xFEFF: # BOM / zero-width no-break space - continue - else: - result.append(ch) - return "".join(result) + return text.translate(_SANITIZE_TABLE) def _truncate_middle(text: str, max_len: int) -> str: @@ -62,33 +58,61 @@ def _truncate_middle(text: str, max_len: int) -> str: return f"{text[:keep_left]}...{text[-keep_right:]}" -def _filter_vault_credentials(credentials: list[dict], query: str) -> list[dict]: - """Filter and rank vault credentials by fuzzy score on service/username.""" +def _credential_fields_lower(cred: dict) -> tuple[str, str, str, str]: + """Flat helper: extract lowercased fields for filtering, reusable.""" + service = str(cred.get("service", "")).lower() + username = str(cred.get("username", "")).lower() + url = str(cred.get("url", "")).lower() + combined = f"{service} {username} {url}".strip() + return service, username, url, combined + + +def _best_fuzzy_score(query: str, fields: tuple[str, ...]) -> int | None: + """Flat helper: return best (lowest) fuzzy score across fields, reusable.""" + best: int | None = None + for field in fields: + score = _fuzzy_score(query, field) + if score is None: + continue + if best is None or score < best: + best = score + if best == 0: + break + return best + + +def _filter_vault_credentials( + credentials: list[dict], query: str, limit: int | None = None +) -> list[dict]: + """Filter and rank vault credentials by fuzzy score — flat, reusable. + + Uses modular helpers for field extraction and scoring so data travels + with minimal allocations. Optional limit uses constants, no hard-coded inline. + """ + import heapq + q = query.strip().lower() if not q: + if limit is not None: + return list(credentials[:limit]) return list(credentials) ranked: list[tuple[int, str, str, dict]] = [] for cred in credentials: - service = str(cred.get("service", "")).lower() - username = str(cred.get("username", "")).lower() - combined = f"{service} {username}".strip() - - scores = [ - s - for s in ( - _fuzzy_score(q, service), - _fuzzy_score(q, username), - _fuzzy_score(q, combined), - ) - if s is not None - ] - if not scores: + service, username, url, combined = _credential_fields_lower(cred) + best = _best_fuzzy_score(q, (service, username, url, combined)) + if best is None: continue - - ranked.append((min(scores), service, username, cred)) - - ranked.sort(key=lambda item: (item[0], item[1], item[2])) - return [item[3] for item in ranked] + ranked.append((best, service, username, cred)) + if limit is not None and len(ranked) > limit * 4: + # Avoid full sort for large vaults when only top-k needed + ranked = heapq.nsmallest(limit, ranked, key=lambda item: (item[0], item[1], item[2])) + ranked.sort(key=lambda item: (item[0], item[1], item[2])) + else: + ranked.sort(key=lambda item: (item[0], item[1], item[2])) + result = [item[3] for item in ranked] + if limit is not None: + return result[:limit] + return result def _find_duplicate_credential( diff --git a/generate_it/tui_render.py b/generate_it/tui_render.py index 6402a39..54ff6fe 100644 --- a/generate_it/tui_render.py +++ b/generate_it/tui_render.py @@ -16,7 +16,13 @@ from typing import TYPE_CHECKING from . import generator -from .constants import AUTO_LOCK_OPTIONS, CLIPBOARD_AUTO_CLEAR_OPTIONS +from .constants import ( + AUTO_LOCK_OPTIONS, + CLIPBOARD_AUTO_CLEAR_OPTIONS, + TUI_ENTROPY_CACHE_SIZE, + TUI_OUTPUT_WRAP_CACHE_SIZE, + _TUI_MAX_BITS_CACHE_SIZE, +) from .tui_helpers import _estimate_entropy_bits, _sanitize_terminal_text, _strength_label if TYPE_CHECKING: @@ -37,6 +43,13 @@ def _auto_lock_label(state: AppState) -> str: HEADER_SMALL = ["Generate It"] +# Flat reusable caches for 60 fps streaming — no hard-coded inline sizes. +_PIXEL_BANNER_CACHE: dict[str, list[str]] = {} +_HEADER_LINES_CACHE: dict[int, list[str]] = {} +_OUTPUT_WRAP_CACHE: dict[tuple[str, int], list[str]] = {} +_ENTROPY_CACHE: dict[tuple[str, int, bool, bool, bool, int, bool, bool, int], tuple[float, str, str]] = {} +_MAX_BITS_CACHE: dict[int, float] = {} + # A compact 5-row pixel font (only the glyphs we need). _FONT_H = 5 @@ -106,13 +119,24 @@ def _auto_lock_label(state: AppState) -> str: ], } -def _pixel_banner(text: str) -> list[str]: +def _get_cached_pixel_banner(text: str) -> list[str]: + """Flat helper: cached pixel banner, reusable for 60 fps.""" + key = text.upper() + cached = _PIXEL_BANNER_CACHE.get(key) + if cached is not None: + return cached lines = [""] * _FONT_H - for ch in text.upper(): + for ch in key: glyph = _PIXEL_FONT.get(ch, _PIXEL_FONT["?"]) for i in range(_FONT_H): lines[i] += glyph[i] + " " - return [ln.rstrip() for ln in lines] + result = [ln.rstrip() for ln in lines] + _PIXEL_BANNER_CACHE[key] = result + return result + + +def _pixel_banner(text: str) -> list[str]: + return _get_cached_pixel_banner(text) # --- Low-level drawing helpers --------------------------------------------- @@ -195,20 +219,42 @@ def _draw_box( def _bar(value: float, max_value: float, width: int) -> str: + """Flat helper: btop-ish bar, reusable.""" if width <= 0: return "" if max_value <= 0: frac = 0.0 else: frac = max(0.0, min(1.0, value / max_value)) - fill = int(round(frac * width)) fill = max(0, min(width, fill)) - - # Using simple block/shade characters for a btop-ish vibe. return "█" * fill + "░" * (width - fill) +def _get_cached_max_bits(wordlist_size: int, mode: str) -> float: + """Flat helper: cached max entropy, reusable, no hard-coded recompute.""" + key = wordlist_size if mode == "words" else -1 + cached = _MAX_BITS_CACHE.get(key) + if cached is not None: + return cached + if mode == "chars": + value = float(generator.MAX_PASSWORD_CHARS) * math.log2( + len(generator.LETTERS) + len(generator.NUMBERS) + len(generator.SPECIAL_CHARACTERS) + ) + else: + value = ( + float(generator.MAX_PASSPHRASE_WORDS) * math.log2(max(2, wordlist_size)) + + 3.0 * math.log2(10.0) + + math.log2(len(generator.PASSPHRASE_SPECIALS)) + ) + if len(_MAX_BITS_CACHE) >= _TUI_MAX_BITS_CACHE_SIZE: + oldest = next(iter(_MAX_BITS_CACHE)) + del _MAX_BITS_CACHE[oldest] + # Username mode reuses words branch for bar-less path; still cache + _MAX_BITS_CACHE[key] = value + return value + + @dataclass(frozen=True) class Theme: border: int @@ -351,15 +397,19 @@ def _add_gradient( def _header_lines_for_width(w: int) -> list[str]: - # Large: pixel banner - large = _pixel_banner("Generate It") + # Flat helper: cached by width, reusable for 60 fps streaming. + cached = _HEADER_LINES_CACHE.get(w) + if cached is not None: + return cached + large = _get_cached_pixel_banner("Generate It") needed = max((len(line) for line in large), default=0) - - if w >= needed + 2: - return large - - # Small fallback - return HEADER_SMALL + result = large if w >= needed + 2 else HEADER_SMALL + # Bounded cache via constants-friendly size (reuses TUI_OUTPUT_WRAP_CACHE_SIZE) + if len(_HEADER_LINES_CACHE) >= TUI_OUTPUT_WRAP_CACHE_SIZE: + oldest = next(iter(_HEADER_LINES_CACHE)) + del _HEADER_LINES_CACHE[oldest] + _HEADER_LINES_CACHE[w] = result + return result def _render_header(stdscr: curses.window, theme: Theme) -> int: @@ -444,7 +494,7 @@ def _render_settings_box( ) -> None: if state.mode == "vault": # Vault mode renders its own full-height panel, so settings box might be unused or reused. - # We will handle this in the main loop by hiding settings/actions/output/info + # We will handle this in the main loop by hiding settings/actions/output/info # and showing a big vault box instead. return @@ -668,7 +718,7 @@ def _render_vault_box( ) -> None: """Renders the full-screen vault list.""" _draw_box(stdscr, y, x, h, w, title="VAULT", border_attr=theme.border, title_attr=theme.title) - + if not state.vault_unlocked: msg = "Vault is locked." _addstr_safe(stdscr, y + h//2, x + (w-len(msg))//2, msg, theme.warn) @@ -678,13 +728,13 @@ def _render_vault_box( inner_h = max(0, h - 2) list_x = x + 2 list_y = y + 1 - + # Headers headers = f"{'Service':<20} {'Username':<20}" _addstr_safe(stdscr, list_y, list_x, headers[:inner_w], theme.dim | curses.A_UNDERLINE) list_y += 1 inner_h -= 1 - + if not state.vault_credentials: _addstr_safe(stdscr, list_y + 1, list_x, "No credentials saved yet.", theme.dim) return @@ -692,26 +742,26 @@ def _render_vault_box( # Scrolling logic visible_count = inner_h total_count = len(state.vault_credentials) - + # Ensure selection is visible if state.vault_selected_idx < state.vault_scroll_y: state.vault_scroll_y = state.vault_selected_idx elif state.vault_selected_idx >= state.vault_scroll_y + visible_count: state.vault_scroll_y = state.vault_selected_idx - visible_count + 1 - + start_idx = state.vault_scroll_y end_idx = min(total_count, start_idx + visible_count) - + for i in range(start_idx, end_idx): cred = state.vault_credentials[i] is_selected = (i == state.vault_selected_idx) and (focus_id == "vault_list") - + attr = theme.focus if is_selected else 0 - + # Format row s_serv = cred['service'] s_user = cred['username'] - + row_str = f"{s_serv:<20} {s_user:<20}" _addstr_safe(stdscr, list_y + (i - start_idx), list_x, row_str[:inner_w], attr) @@ -724,6 +774,20 @@ def _render_vault_box( +def _get_cached_wrapped_output(output: str, width: int) -> list[str]: + """Flat helper: cached wrap for 60 fps, reusable.""" + key = (output, width) + cached = _OUTPUT_WRAP_CACHE.get(key) + if cached is not None: + return cached + wrapped = textwrap.wrap(output, width=width, break_long_words=True, break_on_hyphens=False) + if len(_OUTPUT_WRAP_CACHE) >= TUI_OUTPUT_WRAP_CACHE_SIZE: + oldest = next(iter(_OUTPUT_WRAP_CACHE)) + del _OUTPUT_WRAP_CACHE[oldest] + _OUTPUT_WRAP_CACHE[key] = wrapped + return wrapped + + def _render_output_box( stdscr: curses.window, theme: Theme, @@ -743,12 +807,7 @@ def _render_output_box( _addstr_safe(stdscr, y + 1, x + 2, "(Press Enter or g to generate)"[:inner_w], theme.dim) return - lines = textwrap.wrap( - state.output, - width=max(10, inner_w), - break_long_words=True, - break_on_hyphens=False, - ) + lines = _get_cached_wrapped_output(state.output, max(10, inner_w)) row = y + 1 for line in lines[: max(0, inner_h - 1)]: @@ -829,8 +888,27 @@ def _render_info_box( row += 1 if state.mode != "username": - bits = _estimate_entropy_bits(state, wordlist_size) - label, kind = _strength_label(bits) + cache_key = ( + state.mode, + state.char_length, + state.use_letters, + state.use_numbers, + state.use_special, + state.word_count, + state.add_numbers, + state.add_special, + wordlist_size, + ) + cached = _ENTROPY_CACHE.get(cache_key) + if cached is not None: + bits, label, kind = cached + else: + bits = _estimate_entropy_bits(state, wordlist_size) + label, kind = _strength_label(bits) + if len(_ENTROPY_CACHE) >= TUI_ENTROPY_CACHE_SIZE: + oldest = next(iter(_ENTROPY_CACHE)) + del _ENTROPY_CACHE[oldest] + _ENTROPY_CACHE[cache_key] = (bits, label, kind) if kind == "bad": kind_attr = theme.bad @@ -839,22 +917,13 @@ def _render_info_box( else: kind_attr = theme.ok - # Strength bar + # Strength bar — flat helper already cached bits/label/kind above _addstr_safe(stdscr, row, x + 2, f"Entropy: ~{bits:0.1f} bits"[:inner_w], theme.dim) row += 1 prefix = "Strength: [" suffix = f"] {label}" bar_w = max(0, inner_w - len(prefix) - len(suffix)) - if state.mode == "chars": - max_bits = float(generator.MAX_PASSWORD_CHARS) * math.log2( - len(generator.LETTERS) + len(generator.NUMBERS) + len(generator.SPECIAL_CHARACTERS) - ) - elif state.mode == "words": - max_bits = ( - float(generator.MAX_PASSPHRASE_WORDS) * math.log2(max(2, wordlist_size)) - + 3.0 * math.log2(10.0) - + math.log2(len(generator.PASSPHRASE_SPECIALS)) - ) + max_bits = _get_cached_max_bits(wordlist_size, state.mode) bar = _bar(min(bits, max_bits), max_bits, bar_w) _addstr_safe(stdscr, row, x + 2, f"{prefix}{bar}{suffix}"[:inner_w], kind_attr) diff --git a/generate_it/tui_security.py b/generate_it/tui_security.py index 6c8e3a1..4c6e437 100644 --- a/generate_it/tui_security.py +++ b/generate_it/tui_security.py @@ -8,6 +8,7 @@ CredentialIdentityConflictError, InvalidPasswordError, StorageError, + WeakMasterPasswordError, ) from .tui_state import AppState @@ -21,7 +22,7 @@ ) _UNLOCK_RETRY_FLAG = "_tui_security_unlock_retry" _UNLOCK_CANCELLED_FLAG = "_tui_security_unlock_cancelled" -LOCKOUT_DELAYS_SECONDS: tuple[int, ...] = (0, 30, 300, 1800) +from .constants import LOCKOUT_DELAYS_SECONDS # noqa: E402 single source def _now() -> float: @@ -81,6 +82,57 @@ def _maybe_show_identity_conflict( state.message = "Duplicate credentials need attention (vault explorer)." +_V1_LEGACY_MESSAGE = ( + "Your vault uses legacy PBKDF2 iterations (100k). " + "Recommended is 480k or migrating to v2 (Argon2id, 64 MiB, 3 iter). " + "A backup will be created before migration.\n\n" + "Migrate now to v2? (y/N)" +) + +def _maybe_prompt_v1_legacy_migration( + stdscr: curses.window, + theme: tui.Theme, + state: AppState, +) -> None: + """Prompt to migrate a legacy v1 vault to v2.""" + if not _storage_available(state) or state.storage is None: + return + storage = state.storage + if storage._vault_version != 1: + return + needs_upgrade = getattr(storage, "_legacy_pbkdf2_needs_upgrade", False) + if not needs_upgrade: + return + confirm = tui_modal._run_modal( + stdscr, theme, "SECURITY UPGRADE", _V1_LEGACY_MESSAGE, max_length=3, + ) + if confirm is None or confirm.strip().lower() not in {"y", "yes"}: + state.message = "v1 legacy upgrade deferred. Use health menu (m) to migrate." + return + # Need master password to re-derive keys. + pwd = tui_modal._run_modal( + stdscr, theme, "MIGRATE TO V2", "Enter master password to migrate:", is_password=True, max_length=200, + ) + if pwd is None: + state.message = "v1 migration cancelled." + return + try: + storage.migrate_v1_to_v2(pwd) + state.message = "Vault migrated to v2 (Argon2id)." + except InvalidPasswordError: + tui_modal._run_modal(stdscr, theme, "ERROR", "Master password incorrect — migration aborted.") + state.message = "v1 migration failed." + except StorageError as e: + tui_modal._run_modal(stdscr, theme, "ERROR", f"Migration failed: {e}") + state.message = "v1 migration failed." + finally: + # Explicitly clear password reference. + try: + del pwd + except Exception: + pass + + def _maybe_prompt_aad_migration( stdscr: curses.window, theme: tui.Theme, @@ -140,6 +192,57 @@ def _maybe_migrate_aad_v4( state.message = "AAD upgrade failed." +def _sync_persistent_lockout_from_storage(state: AppState) -> None: + """Load persisted lockout into in-memory state (survives restarts). + + Lockout persistence is inherently wall-clock based (``time.time()`` epoch + stored in the config table) so it survives restarts, while in-memory + enforcement uses ``time.monotonic()`` to resist intra-session clock + adjustments. A forward system-clock jump between sessions can make + ``until_epoch - time.time()`` negative and clear the persisted deadline + prematurely — the wall-clock value cannot be bound to monotonic time + across reboots. The ``failed_unlock_attempts`` counter is *preserved* + even when the deadline has expired, so escalation (30s → 5min → 30min) + is not reset by waiting out or by a clock jump; the next failure still + escalates. Within a single process the monotonic remaining is kept if + it exceeds the wall-clock remaining, providing best-effort resistance to + intra-session NTP adjustments. + """ + if not _storage_available(state) or state.storage is None: + return + try: + attempts, until_epoch = state.storage.get_persistent_lockout_state() + except Exception: + return + if attempts: + state.failed_unlock_attempts = max(state.failed_unlock_attempts, attempts) + if until_epoch is not None: + remaining = until_epoch - time.time() + if remaining > 0: + # Keep the longer of in-memory (monotonic) vs persisted (wall) + # remaining — defends against backward clock jumps within the + # same process lifetime. + in_mem_remaining = _get_lockout_remaining(state) + needed_monotonic = time.monotonic() + remaining + if in_mem_remaining <= 0 or remaining > in_mem_remaining: + state.lockout_until = needed_monotonic + else: + # Expired — keep attempt count (escalation) but clear the wall-clock deadline. + # Do not delete the persisted attempt counter, otherwise a restart after + # waiting out the lockout would reset escalation. Forward clock + # jumps are an inherent limitation of wall-clock persistence + # (see docstring). + try: + state.storage.set_persistent_lockout_state(attempts, None) + except Exception: + pass + # In-memory expiry. + if state.lockout_until is not None and _get_lockout_remaining(state) <= 0: + state.lockout_until = None + # Keep failed_unlock_attempts at max(attempts, current) — attempts + # only reset on successful unlock. + + def _try_unlock_vault( stdscr: curses.window, theme: tui.Theme, @@ -151,37 +254,54 @@ def _try_unlock_vault( state.message = "Vault unavailable." return False + _sync_persistent_lockout_from_storage(state) if not _wait_for_lockout(stdscr, theme, state): setattr(state, _UNLOCK_CANCELLED_FLAG, True) return False + # Ensure plaintext pwd does not linger in this frame beyond the attempt. try: - storage.unlock_vault(pwd) - state.vault_unlocked = True - state.vault_credentials = storage.list_credential_metadata() - state.failed_unlock_attempts = 0 - state.lockout_until = None - tui._record_user_activity(state) - state.message = "Vault unlocked." - _maybe_show_identity_conflict(stdscr, theme, state) - _maybe_prompt_aad_migration(stdscr, theme, state) - return True - except InvalidPasswordError: - _record_unlock_failure(state) - tui_modal._run_modal(stdscr, theme, "ERROR", "Invalid master password.") - setattr(state, _UNLOCK_RETRY_FLAG, True) - except StorageError as e: - state.vault_unlocked = False - state.vault_credentials = [] - # Post-authentication initialization failed (e.g. metadata load or a - # migration/conflict step) after a successful unlock. Fail closed on - # the storage manager itself, not just the UI state: clear the decrypted - # key material so no later code path can consult storage directly while - # the UI reports the vault locked. - storage.close() - tui_modal._run_modal(stdscr, theme, "ERROR", f"Unlock failed: {e}") - state.message = "Vault locked." - return False + try: + storage.unlock_vault(pwd) + state.vault_unlocked = True + state.vault_credentials = storage.list_credential_metadata() + state.failed_unlock_attempts = 0 + state.lockout_until = None + try: + storage.clear_persistent_lockout_state() + except Exception: + pass + tui._record_user_activity(state) + state.message = "Vault unlocked." + _maybe_show_identity_conflict(stdscr, theme, state) + _maybe_prompt_v1_legacy_migration(stdscr, theme, state) + _maybe_prompt_aad_migration(stdscr, theme, state) + return True + except InvalidPasswordError: + _record_unlock_failure(state) + tui_modal._run_modal(stdscr, theme, "ERROR", "Invalid master password.") + setattr(state, _UNLOCK_RETRY_FLAG, True) + except StorageError as e: + state.vault_unlocked = False + state.vault_credentials = [] + # Post-authentication initialization failed (e.g. metadata load or a + # migration/conflict step) after a successful unlock. Fail closed on + # the storage manager itself, not just the UI state: clear the decrypted + # key material so no later code path can consult storage directly while + # the UI reports the vault locked. + storage.close() + tui_modal._run_modal(stdscr, theme, "ERROR", f"Unlock failed: {e}") + state.message = "Vault locked." + return False + finally: + try: + pwd = "\x00" * len(pwd) + except Exception: + pass + try: + del pwd + except Exception: + pass def _lockout_delay_for_attempt(attempts: int) -> int: @@ -204,6 +324,15 @@ def _record_unlock_failure(state: AppState) -> None: state.failed_unlock_attempts += 1 delay = _lockout_delay_for_attempt(state.failed_unlock_attempts) state.lockout_until = _now() + delay if delay else None + # Persist across restarts — wall-clock epoch for cross-process survival. + if _storage_available(state) and state.storage is not None: + try: + until_epoch = (time.time() + delay) if delay else None + state.storage.set_persistent_lockout_state( + state.failed_unlock_attempts, until_epoch + ) + except Exception: + pass def _wait_for_lockout( @@ -212,6 +341,7 @@ def _wait_for_lockout( state: AppState, ) -> bool: """Wait out a lockout window, allowing Esc to cancel the unlock flow.""" + _sync_persistent_lockout_from_storage(state) while True: remaining = _get_lockout_remaining(state) if remaining <= 0: @@ -262,6 +392,15 @@ def _prompt_unlock_vault( cancelled = getattr(state, _UNLOCK_CANCELLED_FLAG, False) delattr(state, _UNLOCK_RETRY_FLAG) delattr(state, _UNLOCK_CANCELLED_FLAG) + # Minimize lifetime of plaintext master password. + try: + pwd = "\x00" * len(pwd) + except Exception: + pass + try: + del pwd + except Exception: + pass if unlocked: return True @@ -271,3 +410,258 @@ def _prompt_unlock_vault( if retry: continue return False + + +# ------------------------------------------------------------------ +# P0 — flat modular flows for password change / DEK rotation / health +# ------------------------------------------------------------------ + +def _ask_current_password(stdscr: curses.window, theme: tui.Theme) -> str | None: + return tui_modal._run_modal( + stdscr, theme, "CHANGE PASSWORD", "Enter CURRENT master password:", is_password=True, max_length=200, + ) + + +def _ask_new_password_pair( + stdscr: curses.window, theme: tui.Theme, +) -> tuple[str, str] | None: + first = tui_modal._run_modal( + stdscr, theme, "CHANGE PASSWORD", "Enter NEW master password:", is_password=True, max_length=200, + ) + if first is None: + return None + second = tui_modal._run_modal( + stdscr, theme, "CHANGE PASSWORD", "Confirm NEW master password:", is_password=True, max_length=200, + ) + if second is None: + return None + return first, second + + +def _confirm_action(stdscr: curses.window, theme: tui.Theme, title: str, prompt: str) -> bool: + answer = tui_modal._run_modal(stdscr, theme, title, prompt, max_length=3) + return answer is not None and answer.strip().lower() in {"y", "yes"} + + +def prompt_change_master_password( + stdscr: curses.window, theme: tui.Theme, state: AppState, +) -> None: + """Interactive master-password change (flat orchestration).""" + if not _storage_available(state) or state.storage is None or not state.vault_unlocked: + tui_modal._run_modal(stdscr, theme, "ERROR", "Vault must be unlocked to change password.") + return + current = _ask_current_password(stdscr, theme) + if current is None: + state.message = "Password change cancelled." + return + pair = _ask_new_password_pair(stdscr, theme) + if pair is None: + state.message = "Password change cancelled." + # Clear current promptly even on cancel. + try: + current = "\x00" * len(current) + except Exception: + pass + try: + del current + except Exception: + pass + return + new_pass, confirm = pair + if new_pass != confirm: + tui_modal._run_modal(stdscr, theme, "ERROR", "New passwords do not match.") + state.message = "Password change failed: mismatch." + try: + current = "\x00" * len(current) + except Exception: + pass + try: + new_pass = "\x00" * len(new_pass) + except Exception: + pass + try: + confirm = "\x00" * len(confirm) + except Exception: + pass + try: + del current, new_pass, confirm + except Exception: + pass + return + try: + _execute_password_change(stdscr, theme, state, current, new_pass) + finally: + try: + current = "\x00" * len(current) + except Exception: + pass + try: + new_pass = "\x00" * len(new_pass) + except Exception: + pass + try: + confirm = "\x00" * len(confirm) + except Exception: + pass + try: + del current, new_pass, confirm + except Exception: + pass + + +def _execute_password_change( + stdscr: curses.window, theme: tui.Theme, state: AppState, current: str, new_pass: str, +) -> None: + assert state.storage is not None + try: + state.storage.change_master_password(current, new_pass) + except WeakMasterPasswordError as exc: + tui_modal._run_modal(stdscr, theme, "ERROR", f"Weak password: {exc}") + state.message = "Password change failed: weak password." + except InvalidPasswordError: + tui_modal._run_modal(stdscr, theme, "ERROR", "Current password is incorrect.") + state.message = "Password change failed: incorrect current password." + except StorageError as exc: + tui_modal._run_modal(stdscr, theme, "ERROR", f"Password change failed: {exc}") + state.message = "Password change failed." + else: + tui_modal._run_modal(stdscr, theme, "SUCCESS", "Master password changed. Use the new password next unlock.") + state.message = "Master password changed." + finally: + # Ensure caller's references are not retained beyond this scope; + # actual wiping is done by caller, but clear local copies too. + try: + current = "\x00" * len(current) + new_pass = "\x00" * len(new_pass) + except Exception: + pass + + +def prompt_rotate_dek( + stdscr: curses.window, theme: tui.Theme, state: AppState, +) -> None: + """Interactive DEK rotation (v2 only, flat flow).""" + if not _storage_available(state) or state.storage is None or not state.vault_unlocked: + tui_modal._run_modal(stdscr, theme, "ERROR", "Vault must be unlocked.") + return + if state.storage._vault_version != 2: + tui_modal._run_modal(stdscr, theme, "ERROR", "DEK rotation requires a v2 vault.") + return + if not _confirm_action(stdscr, theme, "ROTATE DEK", + "Rotate encryption key? Re-encrypts all credentials and creates a backup. Continue? (y/N)"): + state.message = "DEK rotation cancelled." + return + current = tui_modal._run_modal( + stdscr, theme, "ROTATE DEK", "Enter current master password to authorize:", is_password=True, max_length=200, + ) + if current is None: + state.message = "DEK rotation cancelled." + return + try: + assert state.storage is not None + state.storage.rotate_dek(current) + except InvalidPasswordError: + tui_modal._run_modal(stdscr, theme, "ERROR", "Current password is incorrect.") + state.message = "DEK rotation failed." + except StorageError as exc: + tui_modal._run_modal(stdscr, theme, "ERROR", f"DEK rotation failed: {exc}") + state.message = "DEK rotation failed." + else: + tui_modal._run_modal(stdscr, theme, "SUCCESS", "DEK rotated. All credentials re-encrypted.") + state.message = "DEK rotated." + finally: + try: + current = "\x00" * len(current) + except Exception: + pass + try: + del current + except Exception: + pass + + +def prompt_vault_health( + stdscr: curses.window, theme: tui.Theme, state: AppState, +) -> None: + """Show vault status and offer backup prune / integrity check.""" + if not _storage_available(state) or state.storage is None: + tui_modal._run_modal(stdscr, theme, "ERROR", "Vault unavailable.") + return + assert state.storage is not None + status = state.storage.get_vault_status() + lines = _format_status_lines(status) + choice = tui_modal._run_modal( + stdscr, theme, "VAULT HEALTH", + "\n".join(lines) + "\n\nActions: (p)rune backups (c)heck integrity (Esc)close", + max_length=1, + ) + if choice is None: + return + action = choice.strip().lower() + if action == "p": + _handle_prune_backups(stdscr, theme, state) + elif action == "c": + _handle_integrity_check(stdscr, theme, state) + + +def _format_status_lines(status: dict) -> list[str]: + lines = [ + f"Vault: {status.get('vault_path', '(unknown)')}", + f"Exists: {status.get('exists')}", + f"Unlocked: {status.get('is_unlocked')}", + f"Version: {status.get('version')}", + f"AAD: {status.get('aad_version')}", + f"Credentials: {status.get('credential_count', 0)}", + f"Backups: {status.get('backup_count', 0)}", + ] + if status.get("dek_generation") is not None: + lines.append(f"DEK generation: {status.get('dek_generation')}") + for backup in status.get("backups", [])[:6]: + lines.append(f" - {backup}") + if len(status.get("backups", [])) > 6: + lines.append(f" ... and {len(status['backups']) - 6} more") + return lines + + +def _handle_prune_backups(stdscr: curses.window, theme: tui.Theme, state: AppState) -> None: + if state.storage is None: + return + backups = state.storage.list_backups() + if not backups: + tui_modal._run_modal(stdscr, theme, "PRUNE", "No backups to prune.") + return + if not _confirm_action(stdscr, theme, "PRUNE BACKUPS", + f"Found {len(backups)} backup(s). Keep newest 1 and delete the rest? (y/N)"): + state.message = "Prune cancelled." + return + deleted = state.storage.prune_backups(keep_latest=1) + tui_modal._run_modal(stdscr, theme, "PRUNE", f"Deleted {len(deleted)} backup(s).") + state.message = f"Pruned {len(deleted)} backup(s)." + + +def _handle_integrity_check(stdscr: curses.window, theme: tui.Theme, state: AppState) -> None: + """Flat helper: streaming integrity check with 60 fps progress, reusable.""" + if state.storage is None or not state.vault_unlocked: + tui_modal._run_modal(stdscr, theme, "ERROR", "Vault must be unlocked to check integrity.") + return + from .constants import _VAULT_INTEGRITY_BATCH_SIZE as _BATCH + + issues: list[dict] = [] + # Stream in batches so 2B vault does not block TUI at 60 fps + for issue in state.storage.iter_integrity_issues(batch_size=_BATCH): + issues.append(issue) + # Keep UI responsive every batch (non-blocking refresh) + if len(issues) % _BATCH == 0: + try: + stdscr.noutrefresh() + except curses.error: + pass + if not issues: + tui_modal._run_modal(stdscr, theme, "HEALTH", "Integrity OK: all credentials decrypt correctly.") + state.message = "Integrity check passed." + return + lines = [f"Found {len(issues)} issue(s):"] + for item in issues[:10]: + lines.append(f" id={item['id']} service={item['service']} err={item['error']}") + tui_modal._run_scrollable_modal(stdscr, theme, "HEALTH ISSUES", lines) + state.message = f"Integrity check: {len(issues)} issue(s)." diff --git a/generate_it/tui_state.py b/generate_it/tui_state.py index f87f703..30f70fa 100644 --- a/generate_it/tui_state.py +++ b/generate_it/tui_state.py @@ -34,14 +34,14 @@ class AppState: focus_index: int = 0 focus_items_cache_key: tuple[str, str, bool, bool] | None = None focus_items_cache: tuple[str, ...] = () - + # Vault / Storage storage: StorageManager | None = None vault_unlocked: bool = False vault_credentials: list[dict] = field(default_factory=list) vault_scroll_y: int = 0 vault_selected_idx: int = 0 - + # Security settings clipboard_auto_clear_index: int = 2 # 30 seconds auto_lock_index: int = 2 # 5 minutes diff --git a/pyproject.toml b/pyproject.toml index d34480d..b7f3c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" # SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) python -m build [project] name = "generate-it" -version = "0.2.3" +version = "0.2.4" description = "Terminal credential generator with a curses-based TUI." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_csv_formats.py b/tests/test_csv_formats.py index 7cb2d85..3fa5135 100644 --- a/tests/test_csv_formats.py +++ b/tests/test_csv_formats.py @@ -77,7 +77,7 @@ def test_parse_import_row_bitwarden_type_handling() -> None: row_num=2, ) assert issue is None - assert parsed == {"service": "GitHub", "username": "dev", "password": "pw", "note": ""} + assert parsed == {"service": "GitHub", "username": "dev", "password": "pw", "note": "", "url": ""} parsed, issue = csv_formats.parse_import_row( { @@ -137,16 +137,16 @@ def test_get_export_headers() -> None: assert "username" in generic_headers assert "password" in generic_headers assert "note" in generic_headers - + bitwarden_headers = csv_formats.get_export_headers("bitwarden") assert "name" in bitwarden_headers assert "login_username" in bitwarden_headers assert "login_password" in bitwarden_headers - + apple_headers = csv_formats.get_export_headers("apple") assert "Title" in apple_headers assert "Username" in apple_headers - + nordpass_headers = csv_formats.get_export_headers("nordpass") assert "name" in nordpass_headers assert "username" in nordpass_headers @@ -163,7 +163,7 @@ def test_build_export_row() -> None: assert row[0] == "GitHub" assert row[3] == "secret123" assert row[4] == "My note" - + bw_row = csv_formats.build_export_row( "bitwarden", service="GitHub", @@ -194,7 +194,7 @@ def test_parse_import_row_all_formats() -> None: ) assert parsed is not None assert parsed["note"] == "test note" - + parsed, issue = csv_formats.parse_import_row( {"title": "Test", "username": "user", "password": "pass"}, import_format="apple", @@ -202,7 +202,7 @@ def test_parse_import_row_all_formats() -> None: ) assert parsed is not None assert parsed["service"] == "Test" - + parsed, issue = csv_formats.parse_import_row( {"name": "Test", "username": "user", "password": "pass", "notes": "nordpass note"}, import_format="nordpass", diff --git a/tests/test_generator_perf.py b/tests/test_generator_perf.py index 0a95d2b..75a2cdb 100644 --- a/tests/test_generator_perf.py +++ b/tests/test_generator_perf.py @@ -45,13 +45,13 @@ def test_dedupe_edge_cases(): def test_signature_cache_actually_caches(): """The file read must happen exactly ONCE across two identical calls when the file is unchanged.""" read_count = {"n": 0} - original_read_text = generator.Path.read_text + original_secure_read = generator._read_wordlist_text_secure - def counting_read_text(self, *args, **kwargs): + def counting_secure_read(path, *args, **kwargs): read_count["n"] += 1 - return original_read_text(self, *args, **kwargs) + return original_secure_read(path, *args, **kwargs) - with mock.patch.object(generator.Path, "read_text", counting_read_text): + with mock.patch.object(generator, "_read_wordlist_text_secure", counting_secure_read): generator.clear_wordlist_cache() _ = generator.load_wordlist() # miss -> reads the file _ = generator.load_wordlist() # hit -> must NOT read again diff --git a/tests/test_logging_security_regressions.py b/tests/test_logging_security_regressions.py index a99d9f0..ed27196 100644 --- a/tests/test_logging_security_regressions.py +++ b/tests/test_logging_security_regressions.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys from pathlib import Path import pytest @@ -12,6 +13,7 @@ def _reset() -> None: app_logging._reset_logging() +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink/directory checks not applicable on Windows") def test_init_logging_rejects_existing_symlink(tmp_path: Path) -> None: _reset() victim = tmp_path / "victim.log" @@ -25,6 +27,7 @@ def test_init_logging_rejects_existing_symlink(tmp_path: Path) -> None: assert victim.read_text() == "original" +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX directory check not applicable on Windows") def test_init_logging_rejects_existing_directory(tmp_path: Path) -> None: _reset() log_path = tmp_path / "app.log" @@ -56,6 +59,7 @@ def fail_once(*args, **kwargs): assert "initialized after retry" in log_path.read_text() +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod not enforced on Windows") def test_permission_failure_is_not_silenced(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _reset() log_path = tmp_path / "app.log" diff --git a/tests/test_phase1_review_regressions.py b/tests/test_phase1_review_regressions.py index 49c1cd5..de5fccb 100644 --- a/tests/test_phase1_review_regressions.py +++ b/tests/test_phase1_review_regressions.py @@ -108,7 +108,7 @@ def test_details_modal_bounds_long_note_and_marks_truncation(monkeypatch: pytest tui._run_details_modal(stdscr, theme, state, credential) addstr_calls = [call for call in window.method_calls if call[0] == "addstr"] - assert all(call.args[0] < 14 for call in addstr_calls) + assert all(call.args[0] < 16 for call in addstr_calls) assert any("[truncated]" in str(call.args[2]) for call in addstr_calls) diff --git a/tests/test_security.py b/tests/test_security.py index 212f5ef..5ecaeaa 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -292,7 +292,7 @@ class TestWeakPatternResistance: def test_no_sequential_characters(self) -> None: """Check that sequential characters are rare (not completely eliminated). - + Note: Truly random passwords CAN contain sequential characters by chance. This test verifies they are rare (<10% of passwords), not impossible. """ @@ -324,7 +324,7 @@ def test_no_sequential_characters(self) -> None: def test_no_repeated_character_sequences(self) -> None: """Check that 3+ repeated characters are rare (not completely eliminated). - + Note: Truly random passwords CAN contain repeated characters by chance. This test verifies they are rare (<5% of passwords), not impossible. """ diff --git a/tests/test_security_tui.py b/tests/test_security_tui.py index cd85f58..b48e6bc 100644 --- a/tests/test_security_tui.py +++ b/tests/test_security_tui.py @@ -61,7 +61,7 @@ def _fake_run_modal( def test_vault_list_render_never_contains_password(monkeypatch) -> None: """Vault list rendering must NOT include raw passwords.""" import curses - + state = tui.AppState() state.vault_unlocked = True state.vault_credentials = [ @@ -69,11 +69,11 @@ def test_vault_list_render_never_contains_password(monkeypatch) -> None: ] state.vault_selected_idx = 0 state.vault_scroll_y = 0 - + theme = SimpleNamespace(title=0, dim=0, ok=1, warn=2, border=0, focus=0, bad=0) stdscr = MagicMock(name="stdscr") stdscr.getmaxyx.return_value = (24, 80) - + # Mock curses module used by tui_render (for ACS_ULCORNER etc.) fake_curses = MagicMock(name="curses") fake_curses.ACS_ULCORNER = 0 @@ -84,15 +84,15 @@ def test_vault_list_render_never_contains_password(monkeypatch) -> None: fake_curses.ACS_VLINE = 0 fake_curses.A_UNDERLINE = 0 monkeypatch.setattr(tui_render, "curses", fake_curses) - + # Collect all addstr calls addstr_calls: list[str] = [] def _capture_addstr(win, y, x, s, *args): addstr_calls.append(str(s)) monkeypatch.setattr(tui_render, "_addstr_safe", _capture_addstr) - + tui_render._render_vault_box(stdscr, theme, y=0, x=0, h=20, w=70, state=state, focus_id="vault_list") - + all_text = " ".join(addstr_calls) assert "SUPER_SECRET_PASSWORD" not in all_text, ( f"Password found in vault list render output: {all_text}" @@ -102,12 +102,12 @@ def _capture_addstr(win, y, x, s, *args): def test_details_modal_masks_password_by_default(monkeypatch) -> None: """The details modal must mask the password unless explicitly revealed.""" import curses - + state = tui.AppState() state.vault_unlocked = True state.storage = MagicMock(name="StorageManager") state.storage.get_credential_secret.return_value = {"password": "my-secret-password", "note": "", "note_is_hidden": False} - + credential = { "id": 1, "service": "GitHub", @@ -116,28 +116,28 @@ def test_details_modal_masks_password_by_default(monkeypatch) -> None: "note_is_hidden": False, "created_at": "2026-01-01", } - + theme = SimpleNamespace(title=0, dim=0, ok=1, warn=2, border=0, focus=0, bad=0) stdscr = MagicMock(name="stdscr") stdscr.getmaxyx.return_value = (40, 120) - + window = MagicMock(name="details_window") window.getch.side_effect = [27] # Esc to close immediately newwin = MagicMock(return_value=window) - + monkeypatch.setattr(tui.curses, "newwin", newwin) monkeypatch.setattr(tui, "_maybe_auto_clear_clipboard", lambda state: False) monkeypatch.setattr(tui, "_should_auto_lock_now", lambda state: False) monkeypatch.setattr(tui, "_record_user_activity", lambda state: None) - + tui._run_details_modal(stdscr, theme, state, credential) - + # Collect all addstr calls to the window addstr_texts: list[str] = [] for call in window.method_calls: if call[0] == "addstr" and len(call.args) >= 3: addstr_texts.append(str(call.args[2])) - + all_text = " ".join(addstr_texts) assert "my-secret-password" not in all_text, ( f"Raw password appeared in details modal without reveal: {all_text}" @@ -846,4 +846,3 @@ def _fake_modal(stdscr_, theme_, title, prompt, **kwargs): error_shown = True assert error_shown, "StorageError should have triggered an error modal" - diff --git a/tests/test_storage.py b/tests/test_storage.py index ca52dc9..e37f9a2 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -13,10 +13,10 @@ def temp_storage(tmp_path): def test_vault_initialization(temp_storage): assert not temp_storage.vault_exists() - + temp_storage.initialize_vault("A-Strong-Passw0rd!") assert temp_storage.vault_exists() - + # Verify tables exist conn = sqlite3.connect(temp_storage.db_path) cursor = conn.cursor() @@ -26,21 +26,21 @@ def test_vault_initialization(temp_storage): def test_vault_unlock(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + # Test correct unlock # Re-instantiate or just reuse (initialize_vault unlocks it) - + # Let's close and re-open to simulate fresh start temp_storage.close() - + storage2 = StorageManager(db_path=temp_storage.db_path) try: assert storage2.vault_exists() - + # Wrong password with pytest.raises(InvalidPasswordError): storage2.unlock_vault("wrong") - + # Correct password storage2.unlock_vault("A-Very-Secret-Key1!") assert storage2._fernet is not None @@ -49,11 +49,11 @@ def test_vault_unlock(temp_storage): def test_credential_ops(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + # Save temp_storage.save_credential("Google", "user@gmail.com", "password123") temp_storage.save_credential("GitHub", "dev", "gh_token") - + # List creds = temp_storage.list_credentials() assert len(creds) == 2 @@ -61,7 +61,7 @@ def test_credential_ops(temp_storage): assert creds[0]["password"] == "gh_token" assert creds[1]["service"] == "Google" assert creds[1]["password"] == "password123" - + # Delete temp_storage.delete_credential(creds[0]["id"]) creds = temp_storage.list_credentials() @@ -357,11 +357,11 @@ def test_csv_export_invalid_format_raises(temp_storage, tmp_path): def test_save_credential_with_note_and_hidden_flag(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + cred_id = temp_storage.save_credential( "GitHub", "dev", "password", "This is a secret note", note_is_hidden=True ) - + creds = temp_storage.list_credentials() assert len(creds) == 1 assert creds[0]["note"] == "This is a secret note" @@ -370,11 +370,11 @@ def test_save_credential_with_note_and_hidden_flag(temp_storage): def test_save_credential_with_note_visible(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + cred_id = temp_storage.save_credential( "GitHub", "dev", "password", "Visible note", note_is_hidden=False ) - + creds = temp_storage.list_credentials() assert creds[0]["note"] == "Visible note" assert creds[0]["note_is_hidden"] is False @@ -382,14 +382,14 @@ def test_save_credential_with_note_visible(temp_storage): def test_update_credential_note_and_hidden_flag(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + cred_id = temp_storage.save_credential("GitHub", "dev", "password") creds = temp_storage.list_credentials() assert creds[0]["note"] == "" assert creds[0]["note_is_hidden"] is False - + temp_storage.update_credential(cred_id, "GitHub", "dev", "password", "Updated note", True) - + creds = temp_storage.list_credentials() assert creds[0]["note"] == "Updated note" assert creds[0]["note_is_hidden"] is True @@ -397,11 +397,11 @@ def test_update_credential_note_and_hidden_flag(temp_storage): def test_update_credential_toggle_hidden_flag(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + cred_id = temp_storage.save_credential("GitHub", "dev", "password", "Note", True) - + temp_storage.update_credential(cred_id, "GitHub", "dev", "password", "Note", False) - + creds = temp_storage.list_credentials() assert creds[0]["note_is_hidden"] is False @@ -409,16 +409,16 @@ def test_update_credential_toggle_hidden_flag(temp_storage): def test_delete_credential_nonexistent_id_does_not_raise(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") temp_storage.save_credential("GitHub", "dev", "password") - + temp_storage.delete_credential(9999) def test_vault_unlock_invalid_password_raises(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") temp_storage.close() - + storage2 = StorageManager(db_path=temp_storage.db_path) - + with pytest.raises(InvalidPasswordError, match="Invalid master password"): storage2.unlock_vault("wrong_password") @@ -426,15 +426,15 @@ def test_vault_unlock_invalid_password_raises(temp_storage): def test_vault_unlock_after_close_reconnects(temp_storage): """Verify that unlock works after closing and reopening the vault (auto-lock scenario).""" temp_storage.initialize_vault("A-Very-Secret-Key1!") - + # Save a credential while unlocked temp_storage.save_credential("test_service", "test_user", "test_pass") creds = temp_storage.list_credentials() assert len(creds) == 1 - + # Close the vault (simulates auto-lock) temp_storage.close() - + # Unlock should reconnect and work properly temp_storage.unlock_vault("A-Very-Secret-Key1!") creds = temp_storage.list_credentials() @@ -445,7 +445,7 @@ def test_vault_unlock_after_close_reconnects(temp_storage): def test_list_credentials_empty_vault(temp_storage): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + creds = temp_storage.list_credentials() assert creds == [] @@ -453,10 +453,10 @@ def test_list_credentials_empty_vault(temp_storage): def test_export_csv_with_note_field(temp_storage, tmp_path): temp_storage.initialize_vault("A-Very-Secret-Key1!") temp_storage.save_credential("GitHub", "dev", "pass", "My note") - + csv_path = tmp_path / "export.csv" exported, skipped = temp_storage.export_to_csv(csv_path, export_format="generic") - + assert exported == 1 content = csv_path.read_text(encoding="utf-8") assert "My note" in content @@ -464,12 +464,12 @@ def test_export_csv_with_note_field(temp_storage, tmp_path): def test_import_csv_with_note_field(temp_storage, tmp_path): temp_storage.initialize_vault("A-Very-Secret-Key1!") - + csv_path = tmp_path / "import.csv" csv_path.write_text("name,username,password,note\nGitHub,dev,pass,Imported note\n", encoding="utf-8") - + imported, skipped, issues = temp_storage.import_from_csv(csv_path, import_format="generic") - + assert imported == 1 creds = temp_storage.list_credentials() assert creds[0]["note"] == "Imported note" diff --git a/tests/test_tui_helpers.py b/tests/test_tui_helpers.py index c3d924d..c519f9a 100644 --- a/tests/test_tui_helpers.py +++ b/tests/test_tui_helpers.py @@ -143,12 +143,12 @@ def find_credential_by_identity(self, service, username, exclude_id=None): self.list_credential_metadata(), service, username, exclude_id=exclude_id ) - def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> int: - self.saved.append((service, username, password, note, note_is_hidden)) + def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> int: + self.saved.append((service, username, password, note, note_is_hidden, url)) return 99 - def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> None: - self.updated.append((credential_id, service, username, password, note, note_is_hidden)) + def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> None: + self.updated.append((credential_id, service, username, password, note, note_is_hidden, url)) storage = FakeStorage() state = SimpleNamespace(storage=storage, vault_credentials=[]) @@ -167,7 +167,7 @@ def update_credential(self, credential_id: int, service: str, username: str, pas ) assert result == "saved" - assert storage.saved == [("GitLab", "dev", "new-pass", "", False)] + assert storage.saved == [("GitLab", "dev", "new-pass", "", False, "")] assert storage.updated == [] assert any(c["service"] == "GitLab" for c in state.vault_credentials) @@ -192,12 +192,12 @@ def find_credential_by_identity(self, service, username, exclude_id=None): self.list_credential_metadata(), service, username, exclude_id=exclude_id ) - def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> int: - self.saved.append((service, username, password, note, note_is_hidden)) + def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> int: + self.saved.append((service, username, password, note, note_is_hidden, url)) return 99 - def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> None: - self.updated.append((credential_id, service, username, password, note, note_is_hidden)) + def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> None: + self.updated.append((credential_id, service, username, password, note, note_is_hidden, url)) storage = FakeStorage() state = SimpleNamespace(storage=storage, vault_credentials=[]) @@ -216,7 +216,7 @@ def update_credential(self, credential_id: int, service: str, username: str, pas ) assert result == "saved" - assert storage.saved == [("GitHub", "work-account", "new-pass", "", False)] + assert storage.saved == [("GitHub", "work-account", "new-pass", "", False, "")] assert storage.updated == [] @@ -236,11 +236,11 @@ def find_credential_by_identity(self, service, username, exclude_id=None): self.list_credential_metadata(), service, username, exclude_id=exclude_id ) - def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> int: + def save_credential(self, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> int: raise AssertionError("save_credential should not be called for duplicates") - def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False) -> None: - self.updated.append((credential_id, service, username, password, note, note_is_hidden)) + def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", note_is_hidden: bool = False, url: str = "") -> None: + self.updated.append((credential_id, service, username, password, note, note_is_hidden, url)) storage = FakeStorage() state = SimpleNamespace(storage=storage, vault_credentials=[]) @@ -259,7 +259,7 @@ def update_credential(self, credential_id: int, service: str, username: str, pas ) assert result == "overwritten" - assert storage.updated == [(42, "github", "DEV", "new-pass", "", False)] + assert storage.updated == [(42, "github", "DEV", "new-pass", "", False, "")] def test_save_credential_duplicate_safe_cancels_without_overwrite(monkeypatch) -> None: @@ -279,11 +279,11 @@ def find_credential_by_identity(self, service, username, exclude_id=None): self.list_credential_metadata(), service, username, exclude_id=exclude_id ) - def save_credential(self, service: str, username: str, password: str, note: str = "") -> int: + def save_credential(self, service: str, username: str, password: str, note: str = "", *args, **kwargs) -> int: self.saved.append((service, username, password, note)) return 99 - def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "") -> None: + def update_credential(self, credential_id: int, service: str, username: str, password: str, note: str = "", *args, **kwargs) -> None: self.updated.append((credential_id, service, username, password, note)) storage = FakeStorage()