Cybersecurity-Projects/PROJECTS/foundations/password-manager/src/password_manager/main.py

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]"
)