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 ADDED
@@ -0,0 +1,7 @@
1
+ """passvault — a local, encrypted, offline password manager CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__"]
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ passvault = passvault.cli:app
@@ -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.