543 lines
20 KiB
Python
543 lines
20 KiB
Python
"""
|
|
©AngelaMos | 2026
|
|
main.py
|
|
|
|
CLI entry point — wires the user's keyboard to the vault
|
|
|
|
Everything below is glue: take the arguments the user typed, prompt
|
|
for the master password without echoing it, call the right method on
|
|
UnlockedVault, and print results in a friendly format. The actual
|
|
work happens in vault.py and crypto.py
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
Why Typer
|
|
────────────────────────────────────────────────────────────────────
|
|
Typer turns a regular Python function into a CLI command, just by
|
|
reading its type hints and docstrings. Compare these two ways of
|
|
saying "add a `--length` option that defaults to 24"
|
|
|
|
Manual argparse:
|
|
parser.add_argument("--length", type=int, default=24,
|
|
help="Password length")
|
|
|
|
Typer:
|
|
length: Annotated[int, typer.Option(help="Password length")] = 24
|
|
|
|
Typer also generates --help text, validates types automatically, and
|
|
plays well with rich for colorful output
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
Master password handling
|
|
────────────────────────────────────────────────────────────────────
|
|
We use getpass.getpass() so the password never appears on screen as
|
|
the user types. We never accept the master password as a CLI flag —
|
|
that would leak it into shell history (`history` command) and into
|
|
process listings (`ps`). Pass-through-stdin is fine for scripting
|
|
|
|
────────────────────────────────────────────────────────────────────
|
|
Commands exposed
|
|
────────────────────────────────────────────────────────────────────
|
|
init — create a new empty vault
|
|
add <name> — add an entry (prompts for fields)
|
|
get <name> — show an entry's details
|
|
list — list every entry name
|
|
delete <name> — remove an entry
|
|
change-password — rotate the master password (re-encrypts vault)
|
|
gen — generate a random password (no vault touched)
|
|
|
|
Connects to
|
|
vault.py — instantiates UnlockedVault for read/write operations
|
|
generator.py — used by `gen` and offered inside `add`
|
|
constants.py — pulls prompt strings and default vault path
|
|
"""
|
|
|
|
# Standard library: reads a password from the terminal WITHOUT
|
|
# echoing the characters as the user types — same trick `sudo` uses.
|
|
import getpass
|
|
# Standard library: object-oriented filesystem paths — safer and
|
|
# more readable than gluing strings with `os.path.join`.
|
|
from pathlib import Path
|
|
# Standard library: lets us attach extra metadata (Typer Option/Argument
|
|
# specs) to a parameter's type hint without changing its type.
|
|
from typing import Annotated
|
|
|
|
# Third-party (Typer): the CLI framework. Turns a regular function
|
|
# into a subcommand with parsed args, help text, and auto-completion.
|
|
import typer
|
|
# Third-party (rich): the printer that draws colored output to the
|
|
# terminal — every user-facing message goes through this.
|
|
from rich.console import Console
|
|
# Third-party (rich): draws a bordered box — used for the welcome
|
|
# banner and the "vault created" confirmation panel.
|
|
from rich.panel import Panel
|
|
# Third-party (rich): builds the colored ASCII table that lists
|
|
# vault entries in the `list` command.
|
|
from rich.table import Table
|
|
|
|
# Local: pull every prompt string, error message, and default value
|
|
# from constants — main.py never holds magic strings of its own.
|
|
from password_manager.constants import (
|
|
DEFAULT_GENERATED_PASSWORD_LENGTH,
|
|
DEFAULT_VAULT_PATH,
|
|
MINIMUM_MASTER_PASSWORD_LENGTH,
|
|
MSG_ENTRY_ADDED,
|
|
MSG_ENTRY_ALREADY_EXISTS,
|
|
MSG_ENTRY_DELETED,
|
|
MSG_ENTRY_NOT_FOUND,
|
|
MSG_MASTER_PASSWORD_CHANGED,
|
|
MSG_MASTER_PASSWORD_EMPTY,
|
|
MSG_MASTER_PASSWORD_TOO_SHORT,
|
|
MSG_PASSWORDS_DO_NOT_MATCH,
|
|
MSG_VAULT_ALREADY_EXISTS,
|
|
MSG_VAULT_CREATED,
|
|
MSG_VAULT_EMPTY,
|
|
MSG_VAULT_NOT_FOUND,
|
|
MSG_WRONG_MASTER_PASSWORD,
|
|
PROMPT_ENTRY_NOTES,
|
|
PROMPT_ENTRY_URL,
|
|
PROMPT_ENTRY_USERNAME,
|
|
PROMPT_MASTER_PASSWORD,
|
|
PROMPT_MASTER_PASSWORD_CONFIRM,
|
|
PROMPT_MASTER_PASSWORD_NEW,
|
|
)
|
|
# Local: the one crypto-layer error we want to translate into a
|
|
# friendly "wrong master password" message for the user.
|
|
from password_manager.crypto import WrongPasswordError
|
|
# Local: the password generator — its custom error type and the
|
|
# function that actually builds a random password.
|
|
from password_manager.generator import (
|
|
PasswordTooShortError,
|
|
generate_password,
|
|
)
|
|
# Local: every vault-layer name we need — the Entry record, the
|
|
# UnlockedVault class, and every domain-specific error we catch.
|
|
from password_manager.vault import (
|
|
Entry,
|
|
EntryAlreadyExistsError,
|
|
EntryNotFoundError,
|
|
UnlockedVault,
|
|
VaultAlreadyExistsError,
|
|
VaultError,
|
|
VaultFormatError,
|
|
VaultNotFoundError,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Typer app + consoles — module-level singletons
|
|
# =============================================================================
|
|
# Typer.app is the registry every @app.command() decorator attaches to.
|
|
# rich.Console handles colorful output. Both are created once and
|
|
# reused by every command
|
|
#
|
|
# We keep TWO consoles, one for each output stream. The convention
|
|
# is universal in CLI tools
|
|
#
|
|
# stdout — the "result" of the command. Pipe-safe. Capturable
|
|
# stderr — diagnostics, errors, progress. Always shown to the user
|
|
#
|
|
# Splitting them lets users redirect cleanly. `pv gen 32 | pbcopy`
|
|
# pipes ONLY the password into the clipboard, even if pv prints an
|
|
# error. `pv get foo 2>/dev/null` swallows error chatter without
|
|
# also swallowing the panel of credentials
|
|
|
|
app = typer.Typer(
|
|
name = "pv",
|
|
help = "Encrypted password manager (Argon2id + AES-256-GCM)",
|
|
no_args_is_help = True,
|
|
add_completion = False,
|
|
)
|
|
console = Console()
|
|
error_console = Console(stderr = True)
|
|
|
|
|
|
# =============================================================================
|
|
# Shared option type — every command takes --vault
|
|
# =============================================================================
|
|
# Annotated[T, typer.Option(...)] is how Typer reads option metadata
|
|
# without polluting the function signature. The Annotated wrapper is
|
|
# fully transparent at runtime — it only matters to Typer at startup
|
|
#
|
|
# We define the type alias once so every command takes the same flag
|
|
|
|
VaultPath = Annotated[
|
|
Path,
|
|
typer.Option(
|
|
"--vault",
|
|
"-v",
|
|
help = "Path to the vault file",
|
|
envvar = "PV_VAULT",
|
|
),
|
|
]
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers — keep command bodies focused on flow, not plumbing
|
|
# =============================================================================
|
|
|
|
|
|
def _prompt_master_password(prompt: str = PROMPT_MASTER_PASSWORD) -> str:
|
|
"""
|
|
Read a master password from the terminal without echoing it
|
|
|
|
Wraps getpass so we can swap implementations later (e.g. read
|
|
from stdin in non-interactive scripts) without touching every
|
|
command. getpass falls back to a noisy "echo enabled" warning
|
|
if the terminal does not support hidden input — that is the
|
|
library's behavior, not ours
|
|
"""
|
|
return getpass.getpass(prompt)
|
|
|
|
|
|
def _prompt_master_password_with_confirmation() -> str:
|
|
"""
|
|
Prompt for a new master password twice, validate it, and return it
|
|
|
|
Used by `init` and `change-password` to set or rotate the master
|
|
password. Three checks happen before the password is returned
|
|
|
|
1. Non-empty — an empty password "encrypts" the vault under no
|
|
real secret. Anyone who steals the file can re-derive the
|
|
same key from the public salt
|
|
2. At least MINIMUM_MASTER_PASSWORD_LENGTH characters — a hard
|
|
floor below which we refuse to proceed
|
|
3. Confirmation match — both prompts must produce the same
|
|
string, so a typo does not lock the user out of their vault
|
|
the first time they try to unlock it
|
|
|
|
Exits with code 1 on any of the above. The caller does not have
|
|
to handle these cases — by the time this returns, the password
|
|
is known good
|
|
"""
|
|
first = _prompt_master_password(PROMPT_MASTER_PASSWORD_NEW)
|
|
|
|
if not first:
|
|
error_console.print(f"[red]{MSG_MASTER_PASSWORD_EMPTY}[/red]")
|
|
raise typer.Exit(code = 1)
|
|
|
|
if len(first) < MINIMUM_MASTER_PASSWORD_LENGTH:
|
|
error_console.print(
|
|
f"[red]"
|
|
f"{MSG_MASTER_PASSWORD_TOO_SHORT.format(minimum=MINIMUM_MASTER_PASSWORD_LENGTH)}"
|
|
f"[/red]"
|
|
)
|
|
raise typer.Exit(code = 1)
|
|
|
|
second = _prompt_master_password(PROMPT_MASTER_PASSWORD_CONFIRM)
|
|
if first != second:
|
|
error_console.print(f"[red]{MSG_PASSWORDS_DO_NOT_MATCH}[/red]")
|
|
raise typer.Exit(code = 1)
|
|
|
|
return first
|
|
|
|
|
|
def _unlock_or_exit(path: Path, master_password: str) -> UnlockedVault:
|
|
"""
|
|
Open a vault, exiting cleanly on every kind of failure
|
|
|
|
Each error gets the right message and the right exit code.
|
|
`typer.Exit(code=N)` raises an exception that Typer turns into
|
|
`sys.exit(N)` cleanly — we never call sys.exit ourselves
|
|
|
|
Errors go through error_console (stderr); informational and
|
|
success messages go through console (stdout). That split is
|
|
what makes the CLI pipe-friendly
|
|
"""
|
|
try:
|
|
return UnlockedVault.unlock(path, master_password)
|
|
except VaultNotFoundError:
|
|
error_console.print(
|
|
f"[red]{MSG_VAULT_NOT_FOUND.format(path=path)}[/red]"
|
|
)
|
|
raise typer.Exit(code = 1) from None
|
|
except WrongPasswordError:
|
|
error_console.print(f"[red]{MSG_WRONG_MASTER_PASSWORD}[/red]")
|
|
raise typer.Exit(code = 1) from None
|
|
except VaultFormatError as exc:
|
|
error_console.print(f"[red]Vault file is invalid: {exc}[/red]")
|
|
raise typer.Exit(code = 1) from None
|
|
except VaultError as exc:
|
|
error_console.print(f"[red]Vault error: {exc}[/red]")
|
|
raise typer.Exit(code = 1) from None
|
|
|
|
|
|
def _render_entry(name: str, entry: Entry) -> Panel:
|
|
"""
|
|
Format an entry as a rich Panel for terminal display
|
|
|
|
A Panel is a bordered box. We list each field on its own line and
|
|
let the terminal handle long values. The password is shown
|
|
verbatim — this is a CLI tool, the user already trusts the screen
|
|
"""
|
|
body_lines = [
|
|
f"[bold]username[/bold] {entry.username}",
|
|
f"[bold]password[/bold] {entry.password}",
|
|
]
|
|
if entry.url:
|
|
body_lines.append(f"[bold]url[/bold] {entry.url}")
|
|
if entry.notes:
|
|
body_lines.append(f"[bold]notes[/bold] {entry.notes}")
|
|
body_lines.append(f"[dim]created {entry.created_at}[/dim]")
|
|
body_lines.append(f"[dim]updated {entry.updated_at}[/dim]")
|
|
return Panel(
|
|
"\n".join(body_lines),
|
|
title = name,
|
|
border_style = "cyan",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Commands
|
|
# =============================================================================
|
|
# Each @app.command decorates a function as a CLI command. The
|
|
# function name becomes the command name (init → `pv init`)
|
|
|
|
|
|
@app.command()
|
|
def init(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
|
|
"""
|
|
Create a new empty vault at --vault (or PV_VAULT or default path)
|
|
"""
|
|
# The pre-check is a UX nicety: it lets us refuse without
|
|
# prompting for a password we would only throw away. The check
|
|
# inside create() is the AUTHORITATIVE one — between this check
|
|
# and the prompts finishing, another process could have created
|
|
# the vault, and we still need to handle that race
|
|
if vault.exists():
|
|
error_console.print(
|
|
f"[red]{MSG_VAULT_ALREADY_EXISTS.format(path=vault)}[/red]"
|
|
)
|
|
raise typer.Exit(code = 1)
|
|
|
|
master = _prompt_master_password_with_confirmation()
|
|
try:
|
|
# The create() call writes the empty vault and returns an
|
|
# UnlockedVault. We have nothing else to do with it, so we
|
|
# use `with` purely to drop the AES key right away
|
|
with UnlockedVault.create(vault, master):
|
|
pass
|
|
except VaultAlreadyExistsError:
|
|
error_console.print(
|
|
f"[red]{MSG_VAULT_ALREADY_EXISTS.format(path=vault)}[/red]"
|
|
)
|
|
raise typer.Exit(code = 1) from None
|
|
|
|
console.print(f"[green]{MSG_VAULT_CREATED.format(path=vault)}[/green]")
|
|
|
|
|
|
@app.command(name = "list")
|
|
def list_entries(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
|
|
"""
|
|
Print every entry name in the vault, one per line
|
|
"""
|
|
master = _prompt_master_password()
|
|
# `with` ensures the AES key and plaintext entries are dropped
|
|
# as soon as the table has been printed. We render INSIDE the
|
|
# block because we still need to read the entries
|
|
with _unlock_or_exit(vault, master) as unlocked:
|
|
names = unlocked.names()
|
|
if not names:
|
|
console.print(f"[yellow]{MSG_VAULT_EMPTY}[/yellow]")
|
|
return
|
|
|
|
table = Table(title = f"Entries in {vault}", show_lines = False)
|
|
table.add_column("name", style = "cyan", no_wrap = True)
|
|
table.add_column("username", style = "white")
|
|
table.add_column("updated", style = "dim")
|
|
for name in names:
|
|
entry = unlocked.entries[name]
|
|
table.add_row(name, entry.username, entry.updated_at)
|
|
console.print(table)
|
|
|
|
|
|
@app.command()
|
|
def get(
|
|
name: Annotated[str,
|
|
typer.Argument(help = "Entry name to retrieve")],
|
|
vault: VaultPath = DEFAULT_VAULT_PATH,
|
|
) -> None:
|
|
"""
|
|
Show every field of one entry by name
|
|
"""
|
|
master = _prompt_master_password()
|
|
with _unlock_or_exit(vault, master) as unlocked:
|
|
try:
|
|
entry = unlocked.get_entry(name)
|
|
except EntryNotFoundError:
|
|
error_console.print(
|
|
f"[red]{MSG_ENTRY_NOT_FOUND.format(name=name)}[/red]"
|
|
)
|
|
raise typer.Exit(code = 1) from None
|
|
# entry is a frozen Entry instance — its fields remain
|
|
# readable after the vault closes, but we still render
|
|
# inside the block to keep the lifecycle obvious
|
|
console.print(_render_entry(name, entry))
|
|
|
|
|
|
@app.command()
|
|
def add(
|
|
name: Annotated[str,
|
|
typer.Argument(help = "Entry name (must be unique)")],
|
|
vault: VaultPath = DEFAULT_VAULT_PATH,
|
|
force: Annotated[
|
|
bool,
|
|
typer.Option("--force",
|
|
"-f",
|
|
help = "Overwrite if exists"),
|
|
] = False,
|
|
generate: Annotated[
|
|
bool,
|
|
typer.Option(
|
|
"--generate",
|
|
"-g",
|
|
help = "Generate a random password instead of prompting",
|
|
),
|
|
] = False,
|
|
length: Annotated[
|
|
int,
|
|
typer.Option(
|
|
"--length",
|
|
"-n",
|
|
help = "Length when --generate is used",
|
|
),
|
|
] = DEFAULT_GENERATED_PASSWORD_LENGTH,
|
|
) -> None:
|
|
"""
|
|
Add (or overwrite with --force) an entry in the vault
|
|
"""
|
|
master = _prompt_master_password()
|
|
with _unlock_or_exit(vault, master) as unlocked:
|
|
# Collect entry fields. We use plain input() for
|
|
# username/url/notes because they are not secret — getpass
|
|
# for the entry's password
|
|
username = input(PROMPT_ENTRY_USERNAME.format(entry = name))
|
|
|
|
if generate:
|
|
try:
|
|
password = generate_password(length)
|
|
except PasswordTooShortError as exc:
|
|
error_console.print(f"[red]{exc}[/red]")
|
|
raise typer.Exit(code = 1) from None
|
|
console.print(f"[green]Generated password:[/green] {password}")
|
|
else:
|
|
password = _prompt_master_password(
|
|
f"Password for {name} (hidden): "
|
|
)
|
|
|
|
url = input(PROMPT_ENTRY_URL).strip()
|
|
notes = input(PROMPT_ENTRY_NOTES).strip()
|
|
|
|
entry = Entry(
|
|
username = username,
|
|
password = password,
|
|
url = url,
|
|
notes = notes,
|
|
)
|
|
|
|
try:
|
|
unlocked.add_entry(name, entry, force = force)
|
|
except EntryAlreadyExistsError:
|
|
error_console.print(
|
|
f"[red]{MSG_ENTRY_ALREADY_EXISTS.format(name=name)}[/red]"
|
|
)
|
|
raise typer.Exit(code = 1) from None
|
|
except ValueError as exc:
|
|
# Empty / whitespace entry name caught by add_entry's
|
|
# validation. Surface it as a clean error
|
|
error_console.print(f"[red]{exc}[/red]")
|
|
raise typer.Exit(code = 1) from None
|
|
|
|
unlocked.save()
|
|
console.print(f"[green]{MSG_ENTRY_ADDED.format(name=name)}[/green]")
|
|
|
|
|
|
@app.command()
|
|
def delete(
|
|
name: Annotated[str,
|
|
typer.Argument(help = "Entry name to delete")],
|
|
vault: VaultPath = DEFAULT_VAULT_PATH,
|
|
) -> None:
|
|
"""
|
|
Remove an entry by name
|
|
"""
|
|
master = _prompt_master_password()
|
|
with _unlock_or_exit(vault, master) as unlocked:
|
|
try:
|
|
unlocked.delete_entry(name)
|
|
except EntryNotFoundError:
|
|
error_console.print(
|
|
f"[red]{MSG_ENTRY_NOT_FOUND.format(name=name)}[/red]"
|
|
)
|
|
raise typer.Exit(code = 1) from None
|
|
|
|
unlocked.save()
|
|
console.print(f"[green]{MSG_ENTRY_DELETED.format(name=name)}[/green]")
|
|
|
|
|
|
@app.command()
|
|
def gen(
|
|
length: Annotated[
|
|
int,
|
|
typer.Argument(help = "Password length"),
|
|
] = DEFAULT_GENERATED_PASSWORD_LENGTH,
|
|
no_symbols: Annotated[
|
|
bool,
|
|
typer.Option("--no-symbols",
|
|
help = "Letters and digits only"),
|
|
] = False,
|
|
no_digits: Annotated[
|
|
bool,
|
|
typer.Option("--no-digits",
|
|
help = "Letters and symbols only"),
|
|
] = False,
|
|
no_uppercase: Annotated[
|
|
bool,
|
|
typer.Option("--no-uppercase",
|
|
help = "No uppercase letters"),
|
|
] = False,
|
|
) -> None:
|
|
"""
|
|
Print a fresh random password and exit (no vault required)
|
|
"""
|
|
try:
|
|
password = generate_password(
|
|
length,
|
|
use_lowercase = True,
|
|
use_uppercase = not no_uppercase,
|
|
use_digits = not no_digits,
|
|
use_symbols = not no_symbols,
|
|
)
|
|
except (PasswordTooShortError, ValueError) as exc:
|
|
error_console.print(f"[red]{exc}[/red]")
|
|
raise typer.Exit(code = 1) from None
|
|
|
|
# Plain print() so the output is pipe-friendly:
|
|
# PASSWORD=$(pv gen 32)
|
|
print(password)
|
|
|
|
|
|
@app.command(name = "change-password")
|
|
def change_password(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
|
|
"""
|
|
Change the master password (re-encrypts the vault end-to-end)
|
|
|
|
The whole reason the on-disk format stores the salt and KDF
|
|
parameters next to the ciphertext is so this operation is
|
|
possible. We unlock with the OLD password, derive a fresh salt
|
|
+ key from the NEW password, and save — which re-encrypts every
|
|
entry under the new key. Old vault file content is replaced
|
|
atomically by the save() pattern, so a crash mid-rotation
|
|
leaves the user with either the old or the new vault, never
|
|
half of either
|
|
"""
|
|
current = _prompt_master_password("Current master password: ")
|
|
with _unlock_or_exit(vault, current) as unlocked:
|
|
new_password = _prompt_master_password_with_confirmation()
|
|
unlocked.change_master_password(new_password)
|
|
unlocked.save()
|
|
console.print(
|
|
f"[green]"
|
|
f"{MSG_MASTER_PASSWORD_CHANGED.format(path=vault)}"
|
|
f"[/green]"
|
|
)
|