passvault-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- passvault/__init__.py +7 -0
- passvault/cli.py +258 -0
- passvault/crypto.py +96 -0
- passvault/generator.py +52 -0
- passvault/storage.py +130 -0
- passvault_cli-0.1.0.dist-info/METADATA +211 -0
- passvault_cli-0.1.0.dist-info/RECORD +10 -0
- passvault_cli-0.1.0.dist-info/WHEEL +4 -0
- passvault_cli-0.1.0.dist-info/entry_points.txt +2 -0
- passvault_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
passvault/__init__.py
ADDED
passvault/cli.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""passvault command line interface.
|
|
2
|
+
|
|
3
|
+
Every command that touches the vault prompts for the master password (there is
|
|
4
|
+
deliberately no session caching and no OS keychain). Decrypted material lives
|
|
5
|
+
only in local variables for the duration of a command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import NoReturn, Optional
|
|
11
|
+
|
|
12
|
+
import pyperclip
|
|
13
|
+
import typer
|
|
14
|
+
from InquirerPy import inquirer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.markup import escape
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from . import __version__, crypto, generator, storage
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
add_completion=False,
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
help="A local, encrypted, offline password manager.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
error_console = Console(stderr=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fail(message: str, code: int = 1) -> NoReturn:
|
|
32
|
+
"""Print an error to stderr and exit non-zero."""
|
|
33
|
+
error_console.print(f"[bold red]error:[/bold red] {message}")
|
|
34
|
+
raise typer.Exit(code=code)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- interactive prompt helpers -------------------------------------------------
|
|
38
|
+
# These are thin wrappers so they can be monkeypatched in tests (InquirerPy needs
|
|
39
|
+
# a real TTY, which Typer's CliRunner does not provide).
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prompt_secret(message: str) -> str:
|
|
43
|
+
return inquirer.secret(message=f"{message}:", qmark="").execute()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prompt_text(message: str) -> str:
|
|
47
|
+
return inquirer.text(message=f"{message}:", qmark="").execute()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _prompt_confirm(message: str, default: bool = False) -> bool:
|
|
51
|
+
return inquirer.confirm(message=message, default=default, qmark="").execute()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- shared vault unlock --------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _unlock_vault() -> tuple[dict, str]:
|
|
58
|
+
"""Prompt for the master password and return ``(entries, password)``.
|
|
59
|
+
|
|
60
|
+
The password is returned so a command can hand it straight to
|
|
61
|
+
:func:`storage.save_vault`. It is never stored globally.
|
|
62
|
+
"""
|
|
63
|
+
if not storage.vault_exists():
|
|
64
|
+
_fail("No vault found. Run 'passvault init' first.")
|
|
65
|
+
|
|
66
|
+
password = _prompt_secret("Master password")
|
|
67
|
+
if not password:
|
|
68
|
+
_fail("Master password cannot be empty.")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
entries = storage.load_vault(password)
|
|
72
|
+
except crypto.InvalidVaultPassword as exc:
|
|
73
|
+
_fail(str(exc))
|
|
74
|
+
except FileNotFoundError as exc:
|
|
75
|
+
_fail(str(exc))
|
|
76
|
+
return entries, password
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _output_secret(value: str, clipboard: bool, label: str = "Generated password") -> None:
|
|
80
|
+
"""Emit a secret to stdout or the clipboard, never silently."""
|
|
81
|
+
if clipboard:
|
|
82
|
+
try:
|
|
83
|
+
pyperclip.copy(value)
|
|
84
|
+
except pyperclip.PyperclipException as exc: # pragma: no cover - env dependent
|
|
85
|
+
_fail(f"Could not copy to clipboard: {exc}")
|
|
86
|
+
console.print(f"[green]{label} copied to clipboard[/green] (not shown).")
|
|
87
|
+
else:
|
|
88
|
+
# Only reached on an explicit request to display the secret. Markup is
|
|
89
|
+
# disabled so characters like '[' in a password are printed verbatim.
|
|
90
|
+
console.print(value, markup=False, highlight=False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- commands -------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@app.callback(invoke_without_command=True)
|
|
97
|
+
def _main(
|
|
98
|
+
version: bool = typer.Option(
|
|
99
|
+
False, "--version", help="Show the version and exit.", is_eager=True
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
if version:
|
|
103
|
+
console.print(f"passvault {__version__}")
|
|
104
|
+
raise typer.Exit()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command()
|
|
108
|
+
def init() -> None:
|
|
109
|
+
"""Create a new, empty vault protected by a master password."""
|
|
110
|
+
if storage.vault_exists():
|
|
111
|
+
_fail(f"A vault already exists at {storage.vault_path()}.")
|
|
112
|
+
|
|
113
|
+
password = _prompt_secret("Choose a master password")
|
|
114
|
+
if not password:
|
|
115
|
+
_fail("Master password cannot be empty.")
|
|
116
|
+
|
|
117
|
+
confirmation = _prompt_secret("Confirm master password")
|
|
118
|
+
if password != confirmation:
|
|
119
|
+
_fail("Passwords do not match.")
|
|
120
|
+
|
|
121
|
+
storage.create_empty_vault(password)
|
|
122
|
+
console.print(f"[green]Vault created[/green] at {storage.vault_path()}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@app.command()
|
|
126
|
+
def add(
|
|
127
|
+
name: str = typer.Argument(..., help="Name of the entry to add."),
|
|
128
|
+
generate: bool = typer.Option(
|
|
129
|
+
False, "--generate", "-g", help="Generate a password instead of prompting."
|
|
130
|
+
),
|
|
131
|
+
length: int = typer.Option(
|
|
132
|
+
16, "--length", "-l", help="Length used with --generate."
|
|
133
|
+
),
|
|
134
|
+
username: Optional[str] = typer.Option(
|
|
135
|
+
None, "--username", "-u", help="Username to store (skips the prompt)."
|
|
136
|
+
),
|
|
137
|
+
notes: Optional[str] = typer.Option(
|
|
138
|
+
None, "--notes", help="Notes to store (skips the prompt)."
|
|
139
|
+
),
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Add or overwrite an entry."""
|
|
142
|
+
entries, password = _unlock_vault()
|
|
143
|
+
|
|
144
|
+
if name in entries:
|
|
145
|
+
if not _prompt_confirm(f"'{name}' already exists. Overwrite?", default=False):
|
|
146
|
+
_fail("Aborted; the existing entry was left unchanged.")
|
|
147
|
+
|
|
148
|
+
if generate:
|
|
149
|
+
try:
|
|
150
|
+
secret = generator.generate_password(length=length)
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
_fail(str(exc))
|
|
153
|
+
else:
|
|
154
|
+
secret = _prompt_secret(f"Password for '{name}'")
|
|
155
|
+
if not secret:
|
|
156
|
+
_fail("Password cannot be empty.")
|
|
157
|
+
|
|
158
|
+
if username is None:
|
|
159
|
+
username = _prompt_text("Username (optional)") or None
|
|
160
|
+
if notes is None:
|
|
161
|
+
notes = _prompt_text("Notes (optional)") or None
|
|
162
|
+
|
|
163
|
+
entries[name] = {"password": secret, "username": username, "notes": notes}
|
|
164
|
+
storage.save_vault(entries, password)
|
|
165
|
+
console.print(f"[green]Saved[/green] '{escape(name)}'.")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command("list")
|
|
169
|
+
def list_entries() -> None:
|
|
170
|
+
"""List entry names. Stored passwords are never shown."""
|
|
171
|
+
entries, _ = _unlock_vault()
|
|
172
|
+
|
|
173
|
+
if not entries:
|
|
174
|
+
console.print("Vault is empty.")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
table = Table(title="passvault entries", show_lines=False)
|
|
178
|
+
table.add_column("Name", style="bold")
|
|
179
|
+
table.add_column("Username")
|
|
180
|
+
table.add_column("Notes")
|
|
181
|
+
|
|
182
|
+
for entry_name in sorted(entries):
|
|
183
|
+
record = entries[entry_name]
|
|
184
|
+
has_notes = bool(record.get("notes"))
|
|
185
|
+
table.add_row(
|
|
186
|
+
escape(entry_name),
|
|
187
|
+
escape(record.get("username") or "-"),
|
|
188
|
+
"(set)" if has_notes else "-",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
console.print(table)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
def get(
|
|
196
|
+
name: str = typer.Argument(..., help="Entry to retrieve."),
|
|
197
|
+
clipboard: bool = typer.Option(
|
|
198
|
+
False, "--clipboard", "-c", help="Copy to the clipboard instead of printing."
|
|
199
|
+
),
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Retrieve a stored password."""
|
|
202
|
+
entries, _ = _unlock_vault()
|
|
203
|
+
|
|
204
|
+
record = entries.get(name)
|
|
205
|
+
if record is None:
|
|
206
|
+
_fail(f"No entry named '{escape(name)}'.")
|
|
207
|
+
|
|
208
|
+
secret = record.get("password", "")
|
|
209
|
+
if clipboard:
|
|
210
|
+
_output_secret(secret, clipboard=True, label=f"Password for '{escape(name)}'")
|
|
211
|
+
else:
|
|
212
|
+
console.print(secret, markup=False, highlight=False)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def delete(
|
|
217
|
+
name: str = typer.Argument(..., help="Entry to delete."),
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Delete an entry after confirmation."""
|
|
220
|
+
entries, password = _unlock_vault()
|
|
221
|
+
|
|
222
|
+
if name not in entries:
|
|
223
|
+
_fail(f"No entry named '{escape(name)}'.")
|
|
224
|
+
|
|
225
|
+
if not _prompt_confirm(f"Delete '{name}'?", default=False):
|
|
226
|
+
_fail("Aborted; nothing was deleted.")
|
|
227
|
+
|
|
228
|
+
del entries[name]
|
|
229
|
+
storage.save_vault(entries, password)
|
|
230
|
+
console.print(f"[green]Deleted[/green] '{escape(name)}'.")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@app.command()
|
|
234
|
+
def generate(
|
|
235
|
+
length: int = typer.Option(16, "--length", "-l", help="Password length."),
|
|
236
|
+
no_symbols: bool = typer.Option(
|
|
237
|
+
False, "--no-symbols", help="Exclude symbols."
|
|
238
|
+
),
|
|
239
|
+
no_digits: bool = typer.Option(False, "--no-digits", help="Exclude digits."),
|
|
240
|
+
clipboard: bool = typer.Option(
|
|
241
|
+
False, "--clipboard", "-c", help="Copy to the clipboard instead of printing."
|
|
242
|
+
),
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Generate a password without touching the vault."""
|
|
245
|
+
try:
|
|
246
|
+
secret = generator.generate_password(
|
|
247
|
+
length=length,
|
|
248
|
+
use_symbols=not no_symbols,
|
|
249
|
+
use_digits=not no_digits,
|
|
250
|
+
)
|
|
251
|
+
except ValueError as exc:
|
|
252
|
+
_fail(str(exc))
|
|
253
|
+
|
|
254
|
+
_output_secret(secret, clipboard=clipboard)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__": # pragma: no cover
|
|
258
|
+
app()
|
passvault/crypto.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Cryptographic primitives for passvault.
|
|
2
|
+
|
|
3
|
+
Key derivation uses Argon2id (via ``argon2-cffi``) and encryption uses
|
|
4
|
+
``cryptography``'s Fernet (AES-128-CBC + HMAC-SHA256, authenticated).
|
|
5
|
+
|
|
6
|
+
Design notes
|
|
7
|
+
------------
|
|
8
|
+
* The master password is never stored. Only a salt is persisted alongside the
|
|
9
|
+
encrypted blob; the key is re-derived on every command.
|
|
10
|
+
* ``derive_key`` returns a URL-safe base64 encoded 32-byte key, which is the
|
|
11
|
+
exact format Fernet expects.
|
|
12
|
+
* ``decrypt`` never returns partial or garbage plaintext: any authentication
|
|
13
|
+
failure is surfaced as :class:`InvalidVaultPassword`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from argon2.low_level import Type, hash_secret_raw
|
|
22
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"InvalidVaultPassword",
|
|
26
|
+
"SALT_SIZE",
|
|
27
|
+
"generate_salt",
|
|
28
|
+
"derive_key",
|
|
29
|
+
"encrypt",
|
|
30
|
+
"decrypt",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
#: Length of the random salt, in bytes.
|
|
34
|
+
SALT_SIZE = 16
|
|
35
|
+
|
|
36
|
+
#: Length of the raw derived key, in bytes (before base64 encoding for Fernet).
|
|
37
|
+
KEY_SIZE = 32
|
|
38
|
+
|
|
39
|
+
# Argon2id parameters. Chosen to be conservative but usable for an interactive
|
|
40
|
+
# CLI on a laptop; raise these if you have cycles to spare.
|
|
41
|
+
_TIME_COST = 3
|
|
42
|
+
_MEMORY_COST_KIB = 64 * 1024 # 64 MiB
|
|
43
|
+
_PARALLELISM = 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidVaultPassword(Exception):
|
|
47
|
+
"""Raised when decryption fails, i.e. the master password is wrong.
|
|
48
|
+
|
|
49
|
+
Also raised when the vault file is corrupted or was encrypted with a
|
|
50
|
+
different key, because Fernet cannot distinguish those cases.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def generate_salt() -> bytes:
|
|
55
|
+
"""Return ``SALT_SIZE`` cryptographically secure random bytes."""
|
|
56
|
+
return os.urandom(SALT_SIZE)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def derive_key(password: str, salt: bytes) -> bytes:
|
|
60
|
+
"""Derive a Fernet-compatible key from ``password`` and ``salt``.
|
|
61
|
+
|
|
62
|
+
The derivation is deterministic: the same password and salt always produce
|
|
63
|
+
the same key. The result is a URL-safe base64 encoded 32-byte string, ready
|
|
64
|
+
to hand to :class:`cryptography.fernet.Fernet`.
|
|
65
|
+
"""
|
|
66
|
+
if not isinstance(salt, (bytes, bytearray)) or len(salt) == 0:
|
|
67
|
+
raise ValueError("salt must be a non-empty bytes value")
|
|
68
|
+
raw = hash_secret_raw(
|
|
69
|
+
secret=password.encode("utf-8"),
|
|
70
|
+
salt=bytes(salt),
|
|
71
|
+
time_cost=_TIME_COST,
|
|
72
|
+
memory_cost=_MEMORY_COST_KIB,
|
|
73
|
+
parallelism=_PARALLELISM,
|
|
74
|
+
hash_len=KEY_SIZE,
|
|
75
|
+
type=Type.ID,
|
|
76
|
+
)
|
|
77
|
+
return base64.urlsafe_b64encode(raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def encrypt(data: bytes, key: bytes) -> bytes:
|
|
81
|
+
"""Encrypt ``data`` with ``key``, returning a Fernet token."""
|
|
82
|
+
return Fernet(key).encrypt(data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decrypt(token: bytes, key: bytes) -> bytes:
|
|
86
|
+
"""Decrypt a Fernet ``token`` with ``key``.
|
|
87
|
+
|
|
88
|
+
Raises :class:`InvalidVaultPassword` if the key is wrong or the token was
|
|
89
|
+
tampered with. This never silently returns garbage.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
return Fernet(key).decrypt(token)
|
|
93
|
+
except InvalidToken as exc:
|
|
94
|
+
raise InvalidVaultPassword(
|
|
95
|
+
"Incorrect master password, or the vault is corrupted."
|
|
96
|
+
) from exc
|
passvault/generator.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Password generation using the :mod:`secrets` module.
|
|
2
|
+
|
|
3
|
+
Everything here is cryptographically random. ``random`` is never used.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
|
|
11
|
+
__all__ = ["generate_password", "SYMBOLS"]
|
|
12
|
+
|
|
13
|
+
#: Symbols offered when ``use_symbols`` is enabled.
|
|
14
|
+
SYMBOLS = "!@#$%^&*()-_=+[]{}<>?,.:;/"
|
|
15
|
+
|
|
16
|
+
_LOWERCASE = string.ascii_lowercase
|
|
17
|
+
_UPPERCASE = string.ascii_uppercase
|
|
18
|
+
_DIGITS = string.digits
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_password(
|
|
22
|
+
length: int = 16,
|
|
23
|
+
use_symbols: bool = True,
|
|
24
|
+
use_digits: bool = True,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""Return a random password of ``length`` characters.
|
|
27
|
+
|
|
28
|
+
At least one character from every enabled class is guaranteed to appear
|
|
29
|
+
(lowercase and uppercase are always enabled).
|
|
30
|
+
|
|
31
|
+
Raises :class:`ValueError` if ``length`` is too short to include one
|
|
32
|
+
character from each requested class, or if it is not positive.
|
|
33
|
+
"""
|
|
34
|
+
pools = [_LOWERCASE, _UPPERCASE]
|
|
35
|
+
if use_digits:
|
|
36
|
+
pools.append(_DIGITS)
|
|
37
|
+
if use_symbols:
|
|
38
|
+
pools.append(SYMBOLS)
|
|
39
|
+
|
|
40
|
+
if length < len(pools):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"length must be at least {len(pools)} to include one character "
|
|
43
|
+
f"from each requested class (got {length})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
alphabet = "".join(pools)
|
|
47
|
+
|
|
48
|
+
# Rejection-sample until every requested class is represented.
|
|
49
|
+
while True:
|
|
50
|
+
password = "".join(secrets.choice(alphabet) for _ in range(length))
|
|
51
|
+
if all(any(ch in pool for ch in password) for pool in pools):
|
|
52
|
+
return password
|
passvault/storage.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Vault persistence.
|
|
2
|
+
|
|
3
|
+
The vault is a single file containing::
|
|
4
|
+
|
|
5
|
+
salt (16 raw bytes) || Fernet token (encrypted UTF-8 JSON object)
|
|
6
|
+
|
|
7
|
+
The entries themselves are a JSON object mapping entry name to a small record::
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"github": {"password": "...", "username": "me", "notes": "..."},
|
|
11
|
+
...
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
The salt is generated once at vault creation and never rotated, so the derived
|
|
15
|
+
key stays stable across saves.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from platformdirs import user_data_dir
|
|
25
|
+
|
|
26
|
+
from . import crypto
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"VAULT_FILENAME",
|
|
30
|
+
"vault_path",
|
|
31
|
+
"vault_exists",
|
|
32
|
+
"create_empty_vault",
|
|
33
|
+
"load_vault",
|
|
34
|
+
"save_vault",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
VAULT_FILENAME = "vault.dat"
|
|
38
|
+
|
|
39
|
+
_APP_NAME = "passvault"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def vault_path() -> Path:
|
|
43
|
+
"""Return the location of the vault file.
|
|
44
|
+
|
|
45
|
+
Overridden in tests via monkeypatch; kept as a function so no path is
|
|
46
|
+
captured at import time.
|
|
47
|
+
"""
|
|
48
|
+
return Path(user_data_dir(_APP_NAME)) / VAULT_FILENAME
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def vault_exists() -> bool:
|
|
52
|
+
"""Return ``True`` if a vault file is present."""
|
|
53
|
+
return vault_path().is_file()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_raw(path: Path) -> tuple[bytes, bytes]:
|
|
57
|
+
"""Read the vault file and split it into ``(salt, encrypted_blob)``."""
|
|
58
|
+
try:
|
|
59
|
+
raw = path.read_bytes()
|
|
60
|
+
except FileNotFoundError as exc:
|
|
61
|
+
raise FileNotFoundError(
|
|
62
|
+
"No vault found. Run 'passvault init' first."
|
|
63
|
+
) from exc
|
|
64
|
+
|
|
65
|
+
if len(raw) <= crypto.SALT_SIZE:
|
|
66
|
+
raise crypto.InvalidVaultPassword(
|
|
67
|
+
"Vault file is truncated or corrupted."
|
|
68
|
+
)
|
|
69
|
+
return raw[: crypto.SALT_SIZE], raw[crypto.SALT_SIZE :]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_raw(path: Path, salt: bytes, blob: bytes) -> None:
|
|
73
|
+
"""Write ``salt || blob`` to ``path`` atomically."""
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
76
|
+
tmp.write_bytes(salt + blob)
|
|
77
|
+
os.replace(tmp, path)
|
|
78
|
+
# Best-effort tightening of permissions on POSIX; a no-op on Windows.
|
|
79
|
+
try:
|
|
80
|
+
os.chmod(path, 0o600)
|
|
81
|
+
except (OSError, NotImplementedError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def create_empty_vault(password: str) -> None:
|
|
86
|
+
"""Create a new vault protected by ``password`` with no entries."""
|
|
87
|
+
path = vault_path()
|
|
88
|
+
salt = crypto.generate_salt()
|
|
89
|
+
key = crypto.derive_key(password, salt)
|
|
90
|
+
blob = crypto.encrypt(json.dumps({}).encode("utf-8"), key)
|
|
91
|
+
_write_raw(path, salt, blob)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_vault(password: str) -> dict:
|
|
95
|
+
"""Decrypt and return the entries dict.
|
|
96
|
+
|
|
97
|
+
Raises :class:`crypto.InvalidVaultPassword` on a wrong password or a
|
|
98
|
+
corrupted vault.
|
|
99
|
+
"""
|
|
100
|
+
salt, blob = _read_raw(vault_path())
|
|
101
|
+
key = crypto.derive_key(password, salt)
|
|
102
|
+
plaintext = crypto.decrypt(blob, key)
|
|
103
|
+
try:
|
|
104
|
+
entries = json.loads(plaintext.decode("utf-8"))
|
|
105
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
106
|
+
raise crypto.InvalidVaultPassword(
|
|
107
|
+
"Vault contents are unreadable."
|
|
108
|
+
) from exc
|
|
109
|
+
if not isinstance(entries, dict):
|
|
110
|
+
raise crypto.InvalidVaultPassword("Vault contents are malformed.")
|
|
111
|
+
return entries
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def save_vault(entries: dict, password: str) -> None:
|
|
115
|
+
"""Persist ``entries``, reusing the existing salt.
|
|
116
|
+
|
|
117
|
+
Deriving the key requires the salt, so the vault must already exist; the
|
|
118
|
+
password must be correct or decryption/encryption would be inconsistent.
|
|
119
|
+
"""
|
|
120
|
+
path = vault_path()
|
|
121
|
+
salt, blob = _read_raw(path)
|
|
122
|
+
key = crypto.derive_key(password, salt)
|
|
123
|
+
# Verify the password against the current contents before overwriting.
|
|
124
|
+
# Without this, a wrong password would re-encrypt with a bogus key and
|
|
125
|
+
# silently destroy the vault.
|
|
126
|
+
crypto.decrypt(blob, key)
|
|
127
|
+
new_blob = crypto.encrypt(
|
|
128
|
+
json.dumps(entries, sort_keys=True).encode("utf-8"), key
|
|
129
|
+
)
|
|
130
|
+
_write_raw(path, salt, new_blob)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passvault-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local, encrypted, offline password manager CLI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Ajeet2005/passvault
|
|
6
|
+
Project-URL: Repository, https://github.com/Ajeet2005/passvault
|
|
7
|
+
Project-URL: Issues, https://github.com/Ajeet2005/passvault/issues
|
|
8
|
+
Author: passvault contributors
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 passvault contributors
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: cli,encryption,offline,password-manager,security,vault
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Security :: Cryptography
|
|
42
|
+
Classifier: Topic :: Utilities
|
|
43
|
+
Requires-Python: >=3.10
|
|
44
|
+
Requires-Dist: argon2-cffi>=23.1
|
|
45
|
+
Requires-Dist: cryptography>=42.0
|
|
46
|
+
Requires-Dist: inquirerpy>=0.3.4
|
|
47
|
+
Requires-Dist: platformdirs>=4.0
|
|
48
|
+
Requires-Dist: pyperclip>=1.8
|
|
49
|
+
Requires-Dist: rich>=13.0
|
|
50
|
+
Requires-Dist: typer>=0.12
|
|
51
|
+
Provides-Extra: dev
|
|
52
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
53
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
54
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
55
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
56
|
+
Description-Content-Type: text/markdown
|
|
57
|
+
|
|
58
|
+
# passvault
|
|
59
|
+
|
|
60
|
+
A local, encrypted, offline password manager CLI.
|
|
61
|
+
|
|
62
|
+
> **PyPI note:** the distribution is published as
|
|
63
|
+
> [`passvault-cli`](https://pypi.org/project/passvault-cli/) because the name
|
|
64
|
+
> `passvault` was already taken. The installed command and import name are
|
|
65
|
+
> still `passvault`.
|
|
66
|
+
|
|
67
|
+
- No network calls, no accounts, no telemetry.
|
|
68
|
+
- Everything lives in a single encrypted file on your machine.
|
|
69
|
+
- Your master password is required on **every** command. There is no session
|
|
70
|
+
caching and no OS keychain integration — by design.
|
|
71
|
+
|
|
72
|
+
## How it works
|
|
73
|
+
|
|
74
|
+
Entries are stored as a JSON object and encrypted with
|
|
75
|
+
[Fernet](https://cryptography.io/en/latest/fernet/) (AES-128-CBC + HMAC-SHA256,
|
|
76
|
+
authenticated encryption).
|
|
77
|
+
|
|
78
|
+
The encryption key is derived from your master password with **Argon2id**
|
|
79
|
+
(memory-hard, 64 MiB, 3 passes) using a random 16-byte salt.
|
|
80
|
+
|
|
81
|
+
The vault file is laid out as:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
salt (16 bytes) || Fernet token
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The salt is generated once at vault creation and never rotated. Deriving the
|
|
88
|
+
key requires the master password, which is never stored, logged, or written
|
|
89
|
+
anywhere. A wrong master password fails loudly — authenticated decryption means
|
|
90
|
+
we never return garbage plaintext.
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
From PyPI:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install passvault-cli
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Or from source:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/Ajeet2005/passvault.git
|
|
104
|
+
cd passvault
|
|
105
|
+
python -m venv .venv
|
|
106
|
+
# Windows: .venv\Scripts\activate
|
|
107
|
+
source .venv/bin/activate
|
|
108
|
+
pip install -e .
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Requires Python 3.10+.
|
|
112
|
+
|
|
113
|
+
## Usage
|
|
114
|
+
|
|
115
|
+
### Create a vault
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
passvault init
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Refuses to run if a vault already exists. You'll be asked for your master
|
|
122
|
+
password twice.
|
|
123
|
+
|
|
124
|
+
### Add an entry
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
passvault add github --generate --length 24 --username me@example.com
|
|
128
|
+
passvault add email # prompt for the password (hidden input)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Existing entries are only overwritten after a confirmation prompt. Passwords
|
|
132
|
+
are never echoed back to the terminal.
|
|
133
|
+
|
|
134
|
+
### List entries
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
passvault list
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Shows names, usernames, and whether notes are set. **Never** prints stored
|
|
141
|
+
passwords.
|
|
142
|
+
|
|
143
|
+
### Retrieve a password
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
passvault get github # print the password to stdout
|
|
147
|
+
passvault get github --clipboard # copy to the clipboard, don't print it
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The behavior is always explicit: you choose whether the secret is printed or
|
|
151
|
+
copied. There is no silent default.
|
|
152
|
+
|
|
153
|
+
### Delete an entry
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
passvault delete github
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Asks for confirmation first.
|
|
160
|
+
|
|
161
|
+
### Generate a password without a vault
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
passvault generate --length 32
|
|
165
|
+
passvault generate --no-symbols
|
|
166
|
+
passvault generate --no-digits --clipboard
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Vault location
|
|
170
|
+
|
|
171
|
+
The vault lives in your OS's user data directory:
|
|
172
|
+
|
|
173
|
+
| OS | Path |
|
|
174
|
+
| ------- | ------------------------------------------------- |
|
|
175
|
+
| Windows | `%LOCALAPPDATA%\passvault\vault.dat` |
|
|
176
|
+
| macOS | `~/Library/Application Support/passvault/vault.dat` |
|
|
177
|
+
| Linux | `~/.local/share/passvault/vault.dat` |
|
|
178
|
+
|
|
179
|
+
## Development
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pip install -e ".[dev]"
|
|
183
|
+
pytest --cov=passvault
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Test layout:
|
|
187
|
+
|
|
188
|
+
- `tests/test_crypto.py` — key derivation determinism, round trips, wrong-password
|
|
189
|
+
failure.
|
|
190
|
+
- `tests/test_storage.py` — create/load/save round trips, salt persistence,
|
|
191
|
+
refusing writes on a wrong password.
|
|
192
|
+
- `tests/test_generator.py` — length and character-class guarantees.
|
|
193
|
+
- `tests/test_cli.py` — end-to-end command tests via Typer's `CliRunner`.
|
|
194
|
+
|
|
195
|
+
## Security notes
|
|
196
|
+
|
|
197
|
+
- The master password is never stored, in any form.
|
|
198
|
+
- Decrypted passwords are only printed when you explicitly run `get` without
|
|
199
|
+
`--clipboard`.
|
|
200
|
+
- All randomness uses Python's `secrets` module, never `random`.
|
|
201
|
+
- A wrong master password always fails loudly.
|
|
202
|
+
- No session caching and no OS keychain support in this version.
|
|
203
|
+
|
|
204
|
+
## Disclaimer
|
|
205
|
+
|
|
206
|
+
This is a learning project. It has **not** been audited. For real secrets, use a
|
|
207
|
+
battle-tested password manager.
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
passvault/__init__.py,sha256=dlE_EqIzDbo5x5P6vlP3shdFEOqlPWfVGDkKMSIxZIg,156
|
|
2
|
+
passvault/cli.py,sha256=mQb8-r_f5q7dpIn_R3xlXvppL7YwohiDZ46yOIafQQw,8080
|
|
3
|
+
passvault/crypto.py,sha256=J8mvmlOsct6QzgphgjU50egJbi_69Mam1SYGAV79iFo,3015
|
|
4
|
+
passvault/generator.py,sha256=QLg0VpbHte4zQcCryw3h4XCYvowKB6hNBcjfSDkrjKM,1505
|
|
5
|
+
passvault/storage.py,sha256=zwpVXqfx7_-dOVpPHOlkR2J713ClVhetkMuACHROLJE,3821
|
|
6
|
+
passvault_cli-0.1.0.dist-info/METADATA,sha256=Nw9gVvZlyFnKS6Wt9ZVJhgGi5KLsebZ1JYlUvtns0YY,6612
|
|
7
|
+
passvault_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
passvault_cli-0.1.0.dist-info/entry_points.txt,sha256=qXq0s2RfhPeOqYvJTARFWb_ArI0xVWrv1DUFogC73No,48
|
|
9
|
+
passvault_cli-0.1.0.dist-info/licenses/LICENSE,sha256=l9n3TmFv8BEN1nx5I2aseQ3497tFG5s_pkl5oaN1n-8,1079
|
|
10
|
+
passvault_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 passvault contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|