Skip to content
This repository was archived by the owner on Aug 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -194,33 +194,45 @@ 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:

1) Set `GENERATE_IT_WORDLIST` to a file path

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
Expand Down
244 changes: 243 additions & 1 deletion generate_it/__main__.py
Original file line number Diff line number Diff line change
@@ -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()


Expand Down
7 changes: 5 additions & 2 deletions generate_it/_crypto_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading