lockedpass-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.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockedpass-cli
3
+ Version: 0.1.0
4
+ Summary: Official CLI for LockedPass — zero-knowledge password manager
5
+ Author-email: Nextcoders <hello@lockedpass.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://lockedpass.com
8
+ Project-URL: Documentation, https://account.lockedpass.com/api-docs
9
+ Project-URL: Repository, https://github.com/nextcoders/lockedpass-cli
10
+ Keywords: password,manager,cli,zero-knowledge,vault,encryption
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: httpx>=0.28
25
+ Requires-Dist: pynacl>=1.5
26
+ Requires-Dist: argon2-cffi>=23.0
27
+ Requires-Dist: cryptography>=41.0
28
+ Requires-Dist: pyperclip>=1.8
29
+ Dynamic: license-file
30
+
31
+ # LockedPass CLI
32
+
33
+ Official command-line interface for [LockedPass](https://lockedpass.com) — a zero-knowledge password manager for teams and AI agents.
34
+
35
+ All encryption and decryption happens **locally on your machine**. The server never sees your master password, private key, or any plaintext credential data.
36
+
37
+ ## Requirements
38
+
39
+ - Python 3.11+
40
+ - A LockedPass account (Pro plan or higher)
41
+ - An active CLI token generated from **Settings → API & CLI**
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install lockedpass-cli
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```bash
52
+ lp login you@example.com
53
+ # Master password: •••••••• (prompted once, then cached)
54
+
55
+ lp vault list
56
+ lp ls
57
+ lp get "GitHub"
58
+ lp get "GitHub" --field password
59
+ ```
60
+
61
+ ## Commands
62
+
63
+ | Command | Description |
64
+ |---|---|
65
+ | `lp login <email>` | Authenticate — master password required once |
66
+ | `lp lock` | Wipe cached keys (re-prompt on next use) |
67
+ | `lp unlock` | Re-cache keys from master password after lock |
68
+ | `lp vault list` | List all vaults |
69
+ | `lp ls` | List credentials in default vault |
70
+ | `lp get <name>` | Show a credential |
71
+ | `lp get <name> --field password` | Print a single field (scriptable) |
72
+ | `lp add` | Create a credential interactively |
73
+ | `lp edit <name>` | Edit a credential |
74
+ | `lp rm <name>` | Delete a credential |
75
+ | `lp otp <name>` | Generate a live TOTP code |
76
+ | `lp generate` | Generate a random password |
77
+ | `lp copy <name>` | Copy password to clipboard (clears in 30s) |
78
+ | `lp status` | Show session status |
79
+ | `lp logout` | Clear saved session |
80
+
81
+ ## Non-interactive / AI agent use
82
+
83
+ The master password can be passed as an argument or environment variable — useful for automation and AI agents:
84
+
85
+ ```bash
86
+ # Argument
87
+ lp login lp.aivault@example.com "$LP_AI_PASSWORD"
88
+
89
+ # Environment variable
90
+ LP_MASTER_PASSWORD="$LP_AI_PASSWORD" lp login lp.aivault@example.com
91
+ ```
92
+
93
+ After login the session is cached — subsequent commands run without any password.
94
+
95
+ See [AGENT.md](AGENT.md) for the full AI agent setup guide.
96
+
97
+ ## Security
98
+
99
+ - **Argon2id** key derivation — only a one-way hash (`auth_verifier`) is sent to the server
100
+ - **X25519 + XSalsa20-Poly1305** (NaCl) for vault key encryption
101
+ - **XSalsa20-Poly1305** for credential data encryption
102
+ - Private key and vault keys never leave your machine in plaintext
103
+ - Session cached with a random local key — wiped on `lp lock`
104
+
105
+ ## License
106
+
107
+ Copyright (c) 2026 Nextcoders LLC. All Rights Reserved. — see [LICENSE](LICENSE)
@@ -0,0 +1,12 @@
1
+ lockedpass_cli-0.1.0.dist-info/licenses/LICENSE,sha256=jPSIk6Su8JaFEOsDNYNtj_0Jo6qlk2uqeYy7XRA9nG0,353
2
+ lp/__init__.py,sha256=ch3u3UbWnVneQN9uEXXjuZH4bmfBtJ0Snv9ls1bSmR0,80
3
+ lp/__main__.py,sha256=BtkIJkOCJAlxbh8CmmsCPLDCJykPrJvMU8UWSKd8as4,61
4
+ lp/api.py,sha256=6AVB6YxO88DWZ8nYBqOivnckOfkT0ZQ24gqkwBj-z0E,5971
5
+ lp/cli.py,sha256=r1msSjaJGMUlDM-Tn1LeFBW7L35NHmd8PvVVFxOSaCs,25925
6
+ lp/crypto.py,sha256=8ZwvMgafLoSTwc5pfyYO-eoFHiu7A1Az41f6lMbgzKw,5550
7
+ lp/session.py,sha256=TvPhQ0AYfLb_5dgg3HdnTwSzRwlTXBbgdS5Ojje8pOU,3141
8
+ lockedpass_cli-0.1.0.dist-info/METADATA,sha256=44UJL6_2Gn5er2lS9hybdLKjTpqVaAhFd3JN0uFG9yI,3587
9
+ lockedpass_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ lockedpass_cli-0.1.0.dist-info/entry_points.txt,sha256=6w5RUEJP33qP-rrRrhJBqNmZEMAvKC464Daci7bsc84,34
11
+ lockedpass_cli-0.1.0.dist-info/top_level.txt,sha256=itu-yMNux89mPKAp65tBDua0uq70OwEe-nihpFEo3KU,3
12
+ lockedpass_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lp = lp.cli:cli
@@ -0,0 +1,8 @@
1
+ Copyright (c) 2026 Nextcoders LLC. All Rights Reserved.
2
+
3
+ This software and its source code are proprietary and confidential.
4
+ Unauthorized copying, distribution, modification, or use of this software,
5
+ via any medium, is strictly prohibited without the express written permission
6
+ of Nextcoders LLC.
7
+
8
+ For licensing inquiries, contact: hello@lockedpass.com
@@ -0,0 +1 @@
1
+ lp
lp/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """LockedPass CLI — zero-knowledge password manager."""
2
+ __version__ = "0.1.0"
lp/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from lp.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
lp/api.py ADDED
@@ -0,0 +1,140 @@
1
+ """HTTP client for the LockedPass API."""
2
+
3
+ import httpx
4
+
5
+ _DEFAULT_TIMEOUT = 15
6
+
7
+
8
+ class APIError(Exception):
9
+ def __init__(self, status: int, detail: str):
10
+ super().__init__(detail)
11
+ self.status = status
12
+
13
+ @classmethod
14
+ def from_response(cls, r: httpx.Response) -> "APIError":
15
+ try:
16
+ d = r.json()
17
+ detail = d.get("detail", f"HTTP {r.status_code}")
18
+ if isinstance(detail, list):
19
+ detail = "; ".join(e.get("msg", str(e)) for e in detail)
20
+ except Exception:
21
+ detail = f"HTTP {r.status_code}"
22
+ return cls(r.status_code, str(detail))
23
+
24
+
25
+ class Client:
26
+ def __init__(self, base_url: str, token: str | None = None):
27
+ self.base_url = base_url.rstrip("/")
28
+ self.token = token
29
+
30
+ def _headers(self) -> dict:
31
+ h = {"Content-Type": "application/json"}
32
+ if self.token:
33
+ h["Authorization"] = f"Bearer {self.token}"
34
+ return h
35
+
36
+ def _check(self, r: httpx.Response) -> httpx.Response:
37
+ if not r.is_success:
38
+ raise APIError.from_response(r)
39
+ return r
40
+
41
+ def get(self, path: str) -> dict:
42
+ r = httpx.get(f"{self.base_url}{path}", headers=self._headers(), timeout=_DEFAULT_TIMEOUT)
43
+ return self._check(r).json()
44
+
45
+ def post(self, path: str, body: dict) -> dict | None:
46
+ r = httpx.post(f"{self.base_url}{path}", json=body, headers=self._headers(), timeout=_DEFAULT_TIMEOUT)
47
+ self._check(r)
48
+ return r.json() if r.status_code != 204 else None
49
+
50
+ def patch(self, path: str, body: dict) -> dict:
51
+ r = httpx.patch(f"{self.base_url}{path}", json=body, headers=self._headers(), timeout=_DEFAULT_TIMEOUT)
52
+ return self._check(r).json()
53
+
54
+ def delete(self, path: str) -> None:
55
+ r = httpx.delete(f"{self.base_url}{path}", headers=self._headers(), timeout=_DEFAULT_TIMEOUT)
56
+ self._check(r)
57
+
58
+ # ── Auth ─────────────────────────────────────────────────────────────────
59
+
60
+ def login_init(self, email: str) -> dict:
61
+ return self.post("/api/v1/auth/login/init", {"email": email})
62
+
63
+ def cli_login(self, email: str, auth_verifier: str) -> dict:
64
+ """Authenticate and get a 30-day CLI token."""
65
+ return self.post("/api/v1/auth/cli/token", {"email": email, "auth_verifier": auth_verifier})
66
+
67
+ def me(self) -> dict:
68
+ return self.get("/api/v1/cli/me")
69
+
70
+ # ── Vaults ───────────────────────────────────────────────────────────────
71
+
72
+ def list_vaults(self) -> list:
73
+ """Returns vaults with embedded members (no extra request needed)."""
74
+ return self.get("/api/v1/cli/vaults")
75
+
76
+ def create_vault(self, name_encrypted: str, vault_type: str, encrypted_vault_key: str) -> dict:
77
+ return self.post("/api/v1/vaults", {
78
+ "name_encrypted": name_encrypted,
79
+ "type": vault_type,
80
+ "encrypted_vault_key": encrypted_vault_key,
81
+ })
82
+
83
+ # ── Credentials ──────────────────────────────────────────────────────────
84
+
85
+ def list_credentials(self, vault_id: str, limit: int = 2000) -> list:
86
+ return self.get(f"/api/v1/cli/vaults/{vault_id}/credentials?limit={limit}")
87
+
88
+ def list_all_credentials(self, vault_id: str) -> list:
89
+ PAGE, skip, all_creds = 2000, 0, []
90
+ while True:
91
+ page = self.get(f"/api/v1/cli/vaults/{vault_id}/credentials?skip={skip}&limit={PAGE}")
92
+ all_creds.extend(page)
93
+ if len(page) < PAGE:
94
+ break
95
+ skip += PAGE
96
+ return all_creds
97
+
98
+ def get_credential_with_key(self, cred_id: str) -> dict:
99
+ """Single request — returns credential + vault_key_encrypted."""
100
+ return self.get(f"/api/v1/cli/credentials/{cred_id}")
101
+
102
+ def create_credential(self, vault_id: str, cred_type: str, name_enc: str, data_enc: str) -> dict:
103
+ return self.post("/api/v1/cli/credentials", {
104
+ "vault_id": vault_id,
105
+ "type": cred_type,
106
+ "name_encrypted": name_enc,
107
+ "encrypted_data": data_enc,
108
+ })
109
+
110
+ def update_credential(self, vault_id: str, cred_id: str, name_enc: str, data_enc: str) -> dict:
111
+ return self.patch(f"/api/v1/vaults/{vault_id}/credentials/{cred_id}", {
112
+ "name_encrypted": name_enc,
113
+ "encrypted_data": data_enc,
114
+ })
115
+
116
+ def delete_credential(self, vault_id: str, cred_id: str) -> None:
117
+ self.delete(f"/api/v1/vaults/{vault_id}/credentials/{cred_id}")
118
+
119
+ def get_otp_secret(self, cred_id: str) -> dict:
120
+ return self.get(f"/api/v1/cli/otps/{cred_id}/secret")
121
+
122
+ def generate_password(self, length: int = 20, upper: bool = True, lower: bool = True,
123
+ numbers: bool = True, symbols: bool = True) -> str:
124
+ r = self.get(
125
+ f"/api/v1/cli/generate-password?length={length}"
126
+ f"&upper={str(upper).lower()}&lower={str(lower).lower()}"
127
+ f"&numbers={str(numbers).lower()}&symbols={str(symbols).lower()}"
128
+ )
129
+ return r["password"]
130
+
131
+ def log_action(self, vault_id: str, cred_id: str, action: str) -> None:
132
+ try:
133
+ self.post(f"/api/v1/vaults/{vault_id}/credentials/{cred_id}/log", {"action": action})
134
+ except Exception:
135
+ pass # non-critical
136
+
137
+ # ── Users ────────────────────────────────────────────────────────────────
138
+
139
+ def lookup_user(self, email: str) -> dict:
140
+ return self.get(f"/api/v1/users/lookup?email={email}")
lp/cli.py ADDED
@@ -0,0 +1,727 @@
1
+ """LockedPass CLI — lp command."""
2
+
3
+ import json
4
+ import sys
5
+ import threading
6
+ import time
7
+ from typing import Optional
8
+
9
+ import click
10
+
11
+ from lp import __version__
12
+ from lp.api import APIError, Client
13
+ from lp.crypto import (
14
+ b64_dec, b64_enc,
15
+ decrypt_json, decrypt_text, decrypt_vault_key,
16
+ encrypt_json, encrypt_text, encrypt_vault_key,
17
+ generate_password,
18
+ )
19
+ import nacl.utils
20
+ from lp.session import (
21
+ cache_private_key, clear_session, get_master_password, load_config, load_session,
22
+ make_client, require_session, save_config, save_session, unlock,
23
+ )
24
+
25
+ # ── Helpers ────────────────────────────────────────────────────────────────────
26
+
27
+ def ok(msg): click.echo(click.style(f" ✓ {msg}", fg="green"))
28
+ def warn(msg): click.echo(click.style(f" ⚠ {msg}", fg="yellow"))
29
+ def err(msg): click.echo(click.style(f" ✗ {msg}", fg="red"), err=True)
30
+ def info(msg): click.echo(f" {msg}")
31
+
32
+ CRED_TYPE_EMOJI = {"password": "🔑", "otp": "🔐", "note": "📝", "custom": "⚙️"}
33
+
34
+
35
+ def _load_vault_key(client: Client, vault: dict, priv_key: bytes, pub_key: bytes) -> bytes:
36
+ """Decrypt the vault key for the current user.
37
+ Vault dict from /api/v1/cli/vaults already embeds members — no extra request needed.
38
+ """
39
+ my_id = _current_user_id()
40
+ members = vault.get("members") or []
41
+ me = next((m for m in members if m["user_id"] == my_id), None)
42
+ if me is None:
43
+ raise click.ClickException("You are not a member of this vault")
44
+ adder = next((m for m in members if m["user_id"] == me["added_by"]), me)
45
+ sender_pub = b64_dec(adder["public_key"])
46
+ return decrypt_vault_key(vault["encrypted_vault_key"], sender_pub, priv_key)
47
+
48
+
49
+ def _current_user_id() -> str:
50
+ s = load_session()
51
+ return s["user_id"] if s else ""
52
+
53
+
54
+ def _resolve_vault(client: Client, name_or_id: Optional[str],
55
+ priv_key: bytes, pub_key: bytes) -> tuple[dict, bytes]:
56
+ """Return (vault_dict, vault_key). Uses default if name_or_id is None."""
57
+ vaults = client.list_vaults()
58
+ if not vaults:
59
+ raise click.ClickException("No vaults found. Create one: lp vault new")
60
+
61
+ cfg = load_config()
62
+
63
+ if name_or_id:
64
+ match = next(
65
+ (v for v in vaults if v["id"] == name_or_id or _vault_display_name(v, priv_key, pub_key, client) == name_or_id),
66
+ None,
67
+ )
68
+ else:
69
+ default_id = cfg.get("default_vault")
70
+ match = next((v for v in vaults if v["id"] == default_id), vaults[0])
71
+
72
+ if not match:
73
+ raise click.ClickException(f"Vault not found: {name_or_id}")
74
+
75
+ vault_key = _load_vault_key(client, match, priv_key, pub_key)
76
+ return match, vault_key
77
+
78
+
79
+ def _vault_display_name(vault: dict, priv_key: bytes, pub_key: bytes, client: Client) -> str:
80
+ try:
81
+ vk = _load_vault_key(client, vault, priv_key, pub_key)
82
+ return decrypt_text(vault["name_encrypted"], vk)
83
+ except Exception:
84
+ return "(encrypted)"
85
+
86
+
87
+ def _resolve_cred(creds: list[dict], name_or_num: str, vault_key: bytes) -> tuple[dict, dict]:
88
+ """Return (cred_raw, decrypted_data) matching a name or #N."""
89
+ if name_or_num.isdigit():
90
+ idx = int(name_or_num) - 1
91
+ if 0 <= idx < len(creds):
92
+ c = creds[idx]
93
+ return c, decrypt_json(c["encrypted_data"], vault_key)
94
+ raise click.ClickException(f"No credential #{name_or_num}")
95
+
96
+ q = name_or_num.lower()
97
+ for c in creds:
98
+ try:
99
+ data = decrypt_json(c["encrypted_data"], vault_key)
100
+ if q in (data.get("name") or "").lower():
101
+ return c, data
102
+ except Exception:
103
+ pass
104
+ raise click.ClickException(f"Credential not found: {name_or_num}")
105
+
106
+
107
+ def _clipboard_clear(text: str, seconds: int = 30) -> None:
108
+ """Copy text to clipboard and schedule a clear after `seconds`."""
109
+ import pyperclip
110
+ pyperclip.copy(text)
111
+ ok(f"Copied to clipboard — clears in {seconds}s")
112
+
113
+ def _clear():
114
+ time.sleep(seconds)
115
+ try:
116
+ if pyperclip.paste() == text:
117
+ pyperclip.copy("")
118
+ except Exception:
119
+ pass
120
+
121
+ t = threading.Thread(target=_clear, daemon=True)
122
+ t.start()
123
+ # Block briefly so the thread is started before the process exits
124
+ time.sleep(0.2)
125
+
126
+
127
+ # ── Root command ───────────────────────────────────────────────────────────────
128
+
129
+ @click.group()
130
+ @click.version_option(__version__, prog_name="lp")
131
+ def cli():
132
+ """🔐 LockedPass — zero-knowledge password manager"""
133
+
134
+
135
+ # ── Auth ───────────────────────────────────────────────────────────────────────
136
+
137
+ @cli.command()
138
+ @click.option("--server", default="https://account.lockedpass.com", show_default=True,
139
+ envvar="LP_SERVER", help="API server URL")
140
+ @click.option("--staging", is_flag=True, help="Use staging server (dev.lockedpass.com)")
141
+ @click.argument("email")
142
+ @click.argument("password", required=False, default=None)
143
+ def login(email, password, server, staging):
144
+ """Authenticate and save session.
145
+
146
+ PASSWORD is optional — if omitted, reads LP_MASTER_PASSWORD env var or prompts.
147
+ Useful for non-interactive / AI agent use: lp login ai@example.com "$LP_AI_PASSWORD"
148
+ """
149
+ if staging:
150
+ server = "https://dev.lockedpass.com"
151
+
152
+ c = Client(server)
153
+ try:
154
+ params = c.login_init(email)
155
+ except APIError as e:
156
+ raise click.ClickException(str(e))
157
+
158
+ pw = password or get_master_password()
159
+
160
+ from lp.crypto import derive_keys, decrypt_private_key
161
+ keys = derive_keys(pw, params["argon2_params"])
162
+
163
+ try:
164
+ result = c.cli_login(email, keys["auth_verifier"])
165
+ except APIError as e:
166
+ if e.status == 403:
167
+ raise click.ClickException(str(e))
168
+ raise click.ClickException("Invalid email or master password")
169
+
170
+ priv_key = decrypt_private_key(result["encrypted_private_key"], keys["enc_key"])
171
+ sess = cache_private_key({
172
+ "server": server,
173
+ "token": result["access_token"],
174
+ "user_id": result["user_id"],
175
+ "email": email,
176
+ "plan": result["plan"],
177
+ "encrypted_private_key": result["encrypted_private_key"],
178
+ "argon2_params": params["argon2_params"],
179
+ "public_key": params["public_key"],
180
+ }, priv_key)
181
+ save_session(sess)
182
+ ok(f"Logged in as {email} (plan: {result['plan']})")
183
+
184
+
185
+ @cli.command()
186
+ def logout():
187
+ """Clear saved session."""
188
+ clear_session()
189
+ ok("Session cleared")
190
+
191
+
192
+ @cli.command()
193
+ def lock():
194
+ """Remove cached keys — master password required on next use."""
195
+ sess = load_session()
196
+ if sess:
197
+ sess.pop("_local_key", None)
198
+ sess.pop("_local_priv", None)
199
+ save_session(sess)
200
+ ok("Locked 🔒 (run 'lp unlock' to re-cache keys without re-logging in)")
201
+
202
+
203
+ @cli.command(name="unlock")
204
+ def unlock_cmd():
205
+ """Re-cache keys from master password after a lock."""
206
+ sess = require_session()
207
+ from lp.crypto import derive_keys, decrypt_private_key
208
+ pw = get_master_password()
209
+ keys = derive_keys(pw, sess["argon2_params"])
210
+ try:
211
+ priv_key = decrypt_private_key(sess["encrypted_private_key"], keys["enc_key"])
212
+ except Exception:
213
+ raise click.ClickException("Wrong master password")
214
+ save_session(cache_private_key(sess, priv_key))
215
+ ok("Unlocked 🔓")
216
+
217
+
218
+ @cli.command()
219
+ def status():
220
+ """Show current auth status."""
221
+ sess = load_session()
222
+ if not sess:
223
+ click.echo("Not logged in.")
224
+ return
225
+ ok(f"Logged in as {sess['email']}")
226
+ info(f"Server: {sess['server']}")
227
+ info(f"Plan: {sess.get('plan', 'unknown')}")
228
+ cfg = load_config()
229
+ if cfg.get("default_vault"):
230
+ info(f"Vault: {cfg['default_vault']} (default)")
231
+ # Verify token is still valid against server
232
+ try:
233
+ me = make_client(sess).me()
234
+ info(f"Token: valid (plan: {me.get('plan_name', me.get('plan'))})")
235
+ except APIError as e:
236
+ warn(f"Token may be expired or invalid ({e}). Run: lp login")
237
+
238
+
239
+ # ── Vault management ───────────────────────────────────────────────────────────
240
+
241
+ @cli.group()
242
+ def vault():
243
+ """Manage vaults."""
244
+
245
+
246
+ @vault.command("list")
247
+ def vault_list():
248
+ """List all vaults with decrypted names."""
249
+ sess = require_session()
250
+ client = make_client(sess)
251
+ _, priv, pub = unlock(sess)
252
+
253
+ try:
254
+ vaults = client.list_vaults()
255
+ except APIError as e:
256
+ raise click.ClickException(str(e))
257
+
258
+ if not vaults:
259
+ warn("No vaults. Create one: lp vault new <name>")
260
+ return
261
+
262
+ cfg = load_config()
263
+ default_id = cfg.get("default_vault")
264
+
265
+ click.echo()
266
+ for v in vaults:
267
+ name = _vault_display_name(v, priv, pub, client)
268
+ marker = click.style(" ◀ default", fg="cyan") if v["id"] == default_id else ""
269
+ role = click.style(v["user_role"], fg="yellow")
270
+ click.echo(f" {CRED_TYPE_EMOJI.get(v['type'], '🔒')} {name} [{role}] {v['id'][:8]}…{marker}")
271
+ click.echo()
272
+
273
+
274
+ @vault.command("new")
275
+ @click.argument("name")
276
+ @click.option("--type", "vault_type", default="personal",
277
+ type=click.Choice(["personal", "space"]), show_default=True)
278
+ def vault_new(name, vault_type):
279
+ """Create a new vault."""
280
+ sess = require_session()
281
+ client = make_client(sess)
282
+ _, priv, pub = unlock(sess)
283
+
284
+ import nacl.utils
285
+ vault_key = nacl.utils.random(32)
286
+ enc_vault_key = encrypt_vault_key(vault_key, pub, priv)
287
+ name_enc = encrypt_text(name, vault_key)
288
+
289
+ try:
290
+ v = client.create_vault(name_enc, vault_type, enc_vault_key)
291
+ except APIError as e:
292
+ raise click.ClickException(str(e))
293
+
294
+ # Auto-select as default if first vault
295
+ cfg = load_config()
296
+ if not cfg.get("default_vault"):
297
+ cfg["default_vault"] = v["id"]
298
+ save_config(cfg)
299
+
300
+ ok(f"Created vault \"{name}\" ({v['id'][:8]}…)")
301
+
302
+
303
+ @vault.command("use")
304
+ @click.argument("name_or_id")
305
+ def vault_use(name_or_id):
306
+ """Set default vault by name or ID prefix."""
307
+ sess = require_session()
308
+ client = make_client(sess)
309
+ _, priv, pub = unlock(sess)
310
+
311
+ vaults = client.list_vaults()
312
+ match = None
313
+ for v in vaults:
314
+ dec_name = _vault_display_name(v, priv, pub, client)
315
+ if v["id"].startswith(name_or_id) or dec_name.lower() == name_or_id.lower():
316
+ match = v
317
+ break
318
+
319
+ if not match:
320
+ raise click.ClickException(f"Vault not found: {name_or_id}")
321
+
322
+ cfg = load_config()
323
+ cfg["default_vault"] = match["id"]
324
+ save_config(cfg)
325
+ ok(f"Default vault set to: {_vault_display_name(match, priv, pub, client)}")
326
+
327
+
328
+ # ── Credential commands ────────────────────────────────────────────────────────
329
+
330
+ @cli.command("ls")
331
+ @click.option("--vault", "vault_name", default=None, help="Vault name or ID")
332
+ @click.option("--type", "cred_type", default=None,
333
+ type=click.Choice(["password", "otp", "note", "custom"]))
334
+ def ls(vault_name, cred_type):
335
+ """List credentials in the default (or specified) vault."""
336
+ sess = require_session()
337
+ client = make_client(sess)
338
+ _, priv, pub = unlock(sess)
339
+
340
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
341
+
342
+ try:
343
+ creds = client.list_all_credentials(vault_obj["id"])
344
+ except APIError as e:
345
+ raise click.ClickException(str(e))
346
+
347
+ if not creds:
348
+ warn("No credentials. Add one: lp add")
349
+ return
350
+
351
+ if cred_type:
352
+ creds = [c for c in creds if c["type"] == cred_type]
353
+
354
+ click.echo()
355
+ w = len(str(len(creds)))
356
+ for i, c in enumerate(creds, 1):
357
+ try:
358
+ data = decrypt_json(c["encrypted_data"], vault_key)
359
+ name = data.get("name") or "(no name)"
360
+ user = data.get("username") or data.get("issuer") or ""
361
+ sub = f" {click.style(user, fg='bright_black')}" if user else ""
362
+ except Exception:
363
+ name = "(encrypted)"
364
+ sub = ""
365
+ icon = CRED_TYPE_EMOJI.get(c["type"], "❓")
366
+ num = click.style(f"{i:{w}d}", fg="bright_black")
367
+ ty = click.style(c["type"], fg="cyan")
368
+ click.echo(f" {num} {icon} {click.style(name, bold=True)}{sub} [{ty}]")
369
+ click.echo()
370
+
371
+
372
+ @cli.command("get")
373
+ @click.argument("name_or_num")
374
+ @click.option("--vault", "vault_name", default=None)
375
+ @click.option("--field", default=None,
376
+ help="Show only this field (password, username, url, notes, secret)")
377
+ def get_cred(name_or_num, vault_name, field):
378
+ """Show a credential (decrypted)."""
379
+ sess = require_session()
380
+ client = make_client(sess)
381
+ _, priv, pub = unlock(sess)
382
+
383
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
384
+
385
+ try:
386
+ creds = client.list_all_credentials(vault_obj["id"])
387
+ except APIError as e:
388
+ raise click.ClickException(str(e))
389
+
390
+ cred_raw, data = _resolve_cred(creds, name_or_num, vault_key)
391
+ client.log_action(vault_obj["id"], cred_raw["id"], "viewed")
392
+
393
+ if field:
394
+ val = data.get(field)
395
+ if val is None:
396
+ raise click.ClickException(f"Field '{field}' not found")
397
+ click.echo(val)
398
+ return
399
+
400
+ click.echo()
401
+ _print_credential(data, cred_raw["type"])
402
+ click.echo()
403
+
404
+
405
+ def _print_credential(data: dict, cred_type: str) -> None:
406
+ def row(label, value, secret=False):
407
+ if not value:
408
+ return
409
+ if secret:
410
+ import sys
411
+ is_tty = sys.stdin.isatty() and sys.stdout.isatty()
412
+ if is_tty:
413
+ click.echo(f" {click.style(label+':', fg='bright_black', bold=True):<18} "
414
+ f"{click.style('••••••••', fg='bright_black')} "
415
+ f"[Enter=reveal c=copy]", nl=False)
416
+ try:
417
+ ch = click.getchar(echo=False)
418
+ click.echo()
419
+ if ch in ("\r", "\n", ""):
420
+ click.echo(f" {click.style(label+':', fg='bright_black', bold=True):<18} {value}")
421
+ elif ch.lower() == "c":
422
+ _clipboard_clear(value)
423
+ return
424
+ except OSError:
425
+ click.echo()
426
+ click.echo(f" {click.style(label+':', fg='bright_black', bold=True):<18} {click.style('••••••••', fg='bright_black')}")
427
+ else:
428
+ click.echo(f" {click.style(label+':', fg='bright_black', bold=True):<18} {value}")
429
+
430
+ row("Name", data.get("name"))
431
+ row("Username", data.get("username"))
432
+ row("Password", data.get("password"), secret=True)
433
+ row("URL", data.get("url"))
434
+ row("Secret", data.get("secret"), secret=True)
435
+ row("Issuer", data.get("issuer"))
436
+ row("Notes", data.get("notes"))
437
+ for f in data.get("custom_fields") or []:
438
+ row(f.get("label", "Field"), f.get("value"))
439
+
440
+
441
+ @cli.command("copy")
442
+ @click.argument("name_or_num")
443
+ @click.option("--vault", "vault_name", default=None)
444
+ @click.option("--field", default="password",
445
+ help="Field to copy (default: password)")
446
+ def copy_cred(name_or_num, vault_name, field):
447
+ """Copy a credential field to clipboard (clears in 30s)."""
448
+ sess = require_session()
449
+ client = make_client(sess)
450
+ _, priv, pub = unlock(sess)
451
+
452
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
453
+
454
+ try:
455
+ creds = client.list_all_credentials(vault_obj["id"])
456
+ except APIError as e:
457
+ raise click.ClickException(str(e))
458
+
459
+ cred_raw, data = _resolve_cred(creds, name_or_num, vault_key)
460
+
461
+ val = data.get(field)
462
+ if not val:
463
+ raise click.ClickException(f"Field '{field}' is empty or not set")
464
+
465
+ _clipboard_clear(val)
466
+ client.log_action(vault_obj["id"], cred_raw["id"], "copied")
467
+
468
+
469
+ @cli.command("add")
470
+ @click.option("--type", "cred_type", default="password",
471
+ type=click.Choice(["password", "otp", "note", "custom"]), show_default=True)
472
+ @click.option("--vault", "vault_name", default=None)
473
+ def add_cred(cred_type, vault_name):
474
+ """Add a new credential (interactive)."""
475
+ sess = require_session()
476
+ client = make_client(sess)
477
+ _, priv, pub = unlock(sess)
478
+
479
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
480
+
481
+ click.echo(f"\n Adding {cred_type} credential to vault...")
482
+ data: dict = {"name": None}
483
+
484
+ data["name"] = click.prompt(" Name / label")
485
+
486
+ if cred_type == "password":
487
+ data["username"] = click.prompt(" Username", default="")
488
+ pw_choice = click.prompt(
489
+ " Password [Enter to generate]", default="", hide_input=False
490
+ )
491
+ if not pw_choice:
492
+ data["password"] = generate_password()
493
+ ok(f"Generated: {data['password']}")
494
+ else:
495
+ data["password"] = pw_choice
496
+ data["url"] = click.prompt(" URL", default="")
497
+ data["notes"] = click.prompt(" Notes", default="")
498
+
499
+ elif cred_type == "otp":
500
+ data["secret"] = click.prompt(" TOTP secret")
501
+ data["issuer"] = click.prompt(" Issuer", default="")
502
+
503
+ elif cred_type == "note":
504
+ data["notes"] = click.prompt(" Note content")
505
+
506
+ elif cred_type == "custom":
507
+ data["username"] = click.prompt(" Username", default="")
508
+ data["password"] = click.prompt(" Password", default="", hide_input=True)
509
+ data["url"] = click.prompt(" URL", default="")
510
+ fields = []
511
+ while click.confirm(" Add custom field?", default=False):
512
+ lbl = click.prompt(" Label")
513
+ val = click.prompt(" Value")
514
+ fields.append({"label": lbl, "value": val})
515
+ data["custom_fields"] = fields
516
+ data["notes"] = click.prompt(" Notes", default="")
517
+
518
+ name_enc = encrypt_text(data["name"], vault_key)
519
+ data_enc = encrypt_json(data, vault_key)
520
+
521
+ try:
522
+ client.create_credential(vault_obj["id"], cred_type, name_enc, data_enc)
523
+ except APIError as e:
524
+ raise click.ClickException(str(e))
525
+
526
+ ok(f"Created \"{data['name']}\"")
527
+
528
+
529
+ @cli.command("edit")
530
+ @click.argument("name_or_num")
531
+ @click.option("--vault", "vault_name", default=None)
532
+ def edit_cred(name_or_num, vault_name):
533
+ """Edit a credential (shows current values, Enter to keep)."""
534
+ sess = require_session()
535
+ client = make_client(sess)
536
+ _, priv, pub = unlock(sess)
537
+
538
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
539
+
540
+ try:
541
+ creds = client.list_all_credentials(vault_obj["id"])
542
+ except APIError as e:
543
+ raise click.ClickException(str(e))
544
+
545
+ cred_raw, data = _resolve_cred(creds, name_or_num, vault_key)
546
+ cred_type = cred_raw["type"]
547
+
548
+ click.echo(f"\n Editing \"{data.get('name')}\" (Enter to keep current value)\n")
549
+
550
+ data["name"] = click.prompt(" Name", default=data.get("name", ""))
551
+
552
+ if cred_type == "password":
553
+ data["username"] = click.prompt(" Username", default=data.get("username", ""))
554
+ new_pw = click.prompt(" Password [Enter to keep]", default="", hide_input=True)
555
+ if new_pw:
556
+ data["password"] = new_pw
557
+ data["url"] = click.prompt(" URL", default=data.get("url", ""))
558
+ data["notes"] = click.prompt(" Notes", default=data.get("notes", ""))
559
+
560
+ elif cred_type == "otp":
561
+ data["secret"] = click.prompt(" TOTP secret", default=data.get("secret", ""))
562
+ data["issuer"] = click.prompt(" Issuer", default=data.get("issuer", ""))
563
+
564
+ elif cred_type == "note":
565
+ data["notes"] = click.prompt(" Note content", default=data.get("notes", ""))
566
+
567
+ name_enc = encrypt_text(data["name"], vault_key)
568
+ data_enc = encrypt_json(data, vault_key)
569
+
570
+ try:
571
+ client.update_credential(vault_obj["id"], cred_raw["id"], name_enc, data_enc)
572
+ except APIError as e:
573
+ raise click.ClickException(str(e))
574
+
575
+ ok(f"Updated \"{data['name']}\"")
576
+
577
+
578
+ @cli.command("rm")
579
+ @click.argument("name_or_num")
580
+ @click.option("--vault", "vault_name", default=None)
581
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
582
+ def rm_cred(name_or_num, vault_name, yes):
583
+ """Delete a credential."""
584
+ sess = require_session()
585
+ client = make_client(sess)
586
+ _, priv, pub = unlock(sess)
587
+
588
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
589
+
590
+ try:
591
+ creds = client.list_all_credentials(vault_obj["id"])
592
+ except APIError as e:
593
+ raise click.ClickException(str(e))
594
+
595
+ cred_raw, data = _resolve_cred(creds, name_or_num, vault_key)
596
+ name = data.get("name") or name_or_num
597
+
598
+ if not yes and not click.confirm(f" Delete \"{name}\"?"):
599
+ info("Cancelled")
600
+ return
601
+
602
+ try:
603
+ client.delete_credential(vault_obj["id"], cred_raw["id"])
604
+ except APIError as e:
605
+ raise click.ClickException(str(e))
606
+
607
+ ok(f"Deleted \"{name}\"")
608
+
609
+
610
+ # ── Utilities ─────────────────────────────────────────────────────────────────
611
+
612
+ @cli.command("otp")
613
+ @click.argument("name_or_num")
614
+ @click.option("--vault", "vault_name", default=None)
615
+ @click.option("--copy", "-c", "do_copy", is_flag=True, help="Copy code to clipboard")
616
+ def otp_cmd(name_or_num, vault_name, do_copy):
617
+ """Generate a TOTP code for a stored OTP credential."""
618
+ import time as _time
619
+ try:
620
+ import pyotp
621
+ except ImportError:
622
+ raise click.ClickException("pyotp is required: pip install pyotp")
623
+
624
+ sess = require_session()
625
+ client = make_client(sess)
626
+ _, priv, pub = unlock(sess)
627
+
628
+ vault_obj, vault_key = _resolve_vault(client, vault_name, priv, pub)
629
+
630
+ try:
631
+ creds = client.list_all_credentials(vault_obj["id"])
632
+ except APIError as e:
633
+ raise click.ClickException(str(e))
634
+
635
+ otp_creds = [c for c in creds if c["type"] == "otp"]
636
+ if not otp_creds:
637
+ raise click.ClickException("No OTP credentials in this vault")
638
+
639
+ cred_raw, data = _resolve_cred(otp_creds, name_or_num, vault_key)
640
+
641
+ secret = data.get("secret", "").replace(" ", "")
642
+ if not secret:
643
+ raise click.ClickException("OTP secret is empty")
644
+
645
+ try:
646
+ totp = pyotp.TOTP(secret)
647
+ code = totp.now()
648
+ remaining = 30 - (int(_time.time()) % 30)
649
+ except Exception as e:
650
+ raise click.ClickException(f"Failed to generate OTP: {e}")
651
+
652
+ name = data.get("name") or data.get("issuer") or "OTP"
653
+ click.echo()
654
+ click.echo(f" {click.style(name, bold=True)}")
655
+ click.echo(f" {click.style(code, fg='green', bold=True)} "
656
+ f"{click.style(f'(valid {remaining}s)', fg='bright_black')}")
657
+ click.echo()
658
+
659
+ if do_copy:
660
+ _clipboard_clear(code, seconds=remaining)
661
+ client.log_action(vault_obj["id"], cred_raw["id"], "copied")
662
+
663
+
664
+ @cli.command("generate")
665
+ @click.option("--length", "-l", default=20, show_default=True, help="Password length")
666
+ @click.option("--no-symbols", is_flag=True, help="Only alphanumeric")
667
+ @click.option("--copy", "-c", "do_copy", is_flag=True, help="Copy to clipboard")
668
+ @click.option("--count", "-n", default=1, show_default=True, help="How many to generate")
669
+ def generate(length, no_symbols, do_copy, count):
670
+ """Generate cryptographically random password(s)."""
671
+ passwords = [generate_password(length, symbols=not no_symbols) for _ in range(count)]
672
+ for pw in passwords:
673
+ click.echo(f" {pw}")
674
+ if do_copy and count == 1:
675
+ _clipboard_clear(passwords[0])
676
+
677
+
678
+ @cli.command("export")
679
+ @click.option("--vault", "vault_name", default=None, help="Vault name or ID (default: all vaults)")
680
+ @click.option("--out", "-o", default=None, help="Output file (default: stdout)")
681
+ @click.option("--format", "fmt", default="json", type=click.Choice(["json", "csv"]), show_default=True)
682
+ def export_cmd(vault_name, out, fmt):
683
+ """Export credentials as decrypted JSON or CSV."""
684
+ sess = require_session()
685
+ client = make_client(sess)
686
+ _, priv, pub = unlock(sess)
687
+
688
+ if vault_name:
689
+ target_vaults = [_resolve_vault(client, vault_name, priv, pub)[0]]
690
+ else:
691
+ target_vaults = client.list_vaults()
692
+
693
+ rows = []
694
+ for v in target_vaults:
695
+ try:
696
+ vault_key = _load_vault_key(client, v, priv, pub)
697
+ vault_dec_name = decrypt_text(v["name_encrypted"], vault_key)
698
+ except Exception:
699
+ continue
700
+ creds = client.list_all_credentials(v["id"])
701
+ for c in creds:
702
+ try:
703
+ data = decrypt_json(c["encrypted_data"], vault_key)
704
+ data["_vault"] = vault_dec_name
705
+ data["_type"] = c["type"]
706
+ data["_id"] = c["id"]
707
+ rows.append(data)
708
+ except Exception:
709
+ pass
710
+
711
+ if fmt == "json":
712
+ output = json.dumps(rows, indent=2, ensure_ascii=False)
713
+ else:
714
+ import csv, io
715
+ fields = ["_vault", "_type", "name", "username", "password", "url", "notes", "secret", "issuer", "_id"]
716
+ buf = io.StringIO()
717
+ w = csv.DictWriter(buf, fieldnames=fields, extrasaction="ignore")
718
+ w.writeheader()
719
+ w.writerows(rows)
720
+ output = buf.getvalue()
721
+
722
+ if out:
723
+ with open(out, "w", encoding="utf-8") as f:
724
+ f.write(output)
725
+ ok(f"Exported {len(rows)} credentials to {out}")
726
+ else:
727
+ click.echo(output)
lp/crypto.py ADDED
@@ -0,0 +1,143 @@
1
+ """
2
+ Zero-knowledge crypto — byte-compatible with the JavaScript frontend.
3
+
4
+ JS uses TweetNaCl + @noble/hashes. Python uses PyNaCl + argon2-cffi + cryptography.
5
+ Both implement the same NaCl spec, so ciphertext produced by one decrypts in the other.
6
+
7
+ Storage format for all encrypted blobs:
8
+ base64( nonce(24 bytes) + ciphertext(MAC_16 + plaintext) )
9
+ """
10
+
11
+ import base64
12
+ import os
13
+
14
+ import nacl.secret
15
+ import nacl.public
16
+ import nacl.utils
17
+ from argon2.low_level import hash_secret_raw, Type
18
+ from cryptography.hazmat.primitives.hashes import SHA256
19
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF as _HKDF
20
+
21
+
22
+ # ── Encoding ──────────────────────────────────────────────────────────────────
23
+
24
+ def b64_enc(data: bytes) -> str:
25
+ return base64.b64encode(data).decode()
26
+
27
+ def b64_dec(s: str) -> bytes:
28
+ return base64.b64decode(s)
29
+
30
+
31
+ # ── HKDF (SHA-256) — matches @noble/hashes hkdf(sha256, ...) ─────────────────
32
+
33
+ def hkdf(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes:
34
+ h = _HKDF(algorithm=SHA256(), length=length, salt=salt, info=info)
35
+ return h.derive(ikm)
36
+
37
+
38
+ # ── Key derivation — matches src/crypto/index.js deriveKeys() ─────────────────
39
+
40
+ def derive_keys(password: str, argon2_params: dict) -> dict:
41
+ """
42
+ Derive encKey + authVerifier from master password + stored argon2 params.
43
+ Must produce identical output to the JavaScript deriveKeys() function.
44
+ """
45
+ salt = b64_dec(argon2_params["salt"])
46
+
47
+ master = hash_secret_raw(
48
+ secret=password.encode(),
49
+ salt=salt,
50
+ time_cost=argon2_params["iterations"],
51
+ memory_cost=argon2_params["memory"],
52
+ parallelism=argon2_params["parallelism"],
53
+ hash_len=64,
54
+ type=Type.ID,
55
+ )
56
+
57
+ enc_key = hkdf(master, salt, b"lockedpass-enc-v1", 32)
58
+ auth_bytes = hkdf(master, salt, b"lockedpass-auth-v1", 32)
59
+
60
+ return {
61
+ "master_key": master,
62
+ "enc_key": enc_key,
63
+ "auth_verifier": b64_enc(auth_bytes),
64
+ }
65
+
66
+
67
+ # ── Private key (nacl.secretbox) ──────────────────────────────────────────────
68
+
69
+ def decrypt_private_key(encrypted_b64: str, enc_key: bytes) -> bytes:
70
+ data = b64_dec(encrypted_b64)
71
+ nonce = data[:nacl.secret.SecretBox.NONCE_SIZE]
72
+ ct = data[nacl.secret.SecretBox.NONCE_SIZE:]
73
+ return nacl.secret.SecretBox(enc_key).decrypt(ct, nonce)
74
+
75
+
76
+ # ── Vault key (nacl.box — X25519 DH) ─────────────────────────────────────────
77
+
78
+ def decrypt_vault_key(encrypted_b64: str, sender_pub_key: bytes, recipient_sec_key: bytes) -> bytes:
79
+ data = b64_dec(encrypted_b64)
80
+ nonce = data[:nacl.public.Box.NONCE_SIZE]
81
+ ct = data[nacl.public.Box.NONCE_SIZE:]
82
+ box = nacl.public.Box(nacl.public.PrivateKey(recipient_sec_key),
83
+ nacl.public.PublicKey(sender_pub_key))
84
+ return box.decrypt(ct, nonce)
85
+
86
+
87
+ def encrypt_vault_key(vault_key: bytes, recipient_pub_key: bytes, sender_sec_key: bytes) -> str:
88
+ nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE)
89
+ box = nacl.public.Box(nacl.public.PrivateKey(sender_sec_key),
90
+ nacl.public.PublicKey(recipient_pub_key))
91
+ ct = box.encrypt(vault_key, nonce).ciphertext
92
+ return b64_enc(nonce + ct)
93
+
94
+
95
+ # ── Vault contents (nacl.secretbox — XSalsa20-Poly1305) ──────────────────────
96
+
97
+ def decrypt_text(encrypted_b64: str, vault_key: bytes) -> str:
98
+ data = b64_dec(encrypted_b64)
99
+ nonce = data[:nacl.secret.SecretBox.NONCE_SIZE]
100
+ ct = data[nacl.secret.SecretBox.NONCE_SIZE:]
101
+ return nacl.secret.SecretBox(vault_key).decrypt(ct, nonce).decode()
102
+
103
+
104
+ def encrypt_text(text: str, vault_key: bytes) -> str:
105
+ nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
106
+ ct = nacl.secret.SecretBox(vault_key).encrypt(text.encode(), nonce).ciphertext
107
+ return b64_enc(nonce + ct)
108
+
109
+
110
+ def decrypt_json(encrypted_b64: str, vault_key: bytes) -> dict:
111
+ import json
112
+ return json.loads(decrypt_text(encrypted_b64, vault_key))
113
+
114
+
115
+ def encrypt_json(obj: dict, vault_key: bytes) -> str:
116
+ import json
117
+ return encrypt_text(json.dumps(obj), vault_key)
118
+
119
+
120
+ # ── Password generator ────────────────────────────────────────────────────────
121
+
122
+ _LOWER = "abcdefghijklmnopqrstuvwxyz"
123
+ _UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
124
+ _DIGITS = "0123456789"
125
+ _SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?"
126
+
127
+ def generate_password(length: int = 20, symbols: bool = True) -> str:
128
+ alphabet = _LOWER + _UPPER + _DIGITS
129
+ if symbols:
130
+ alphabet += _SYMBOLS
131
+ # Use os.urandom for cryptographic randomness
132
+ raw = os.urandom(length * 4)
133
+ result = [alphabet[b % len(alphabet)] for b in raw][:length]
134
+ # Guarantee at least one of each character class
135
+ import random as _r
136
+ rng = _r.SystemRandom()
137
+ result[rng.randrange(length)] = rng.choice(_LOWER)
138
+ result[rng.randrange(length)] = rng.choice(_UPPER)
139
+ result[rng.randrange(length)] = rng.choice(_DIGITS)
140
+ if symbols:
141
+ result[rng.randrange(length)] = rng.choice(_SYMBOLS)
142
+ rng.shuffle(result)
143
+ return "".join(result)
lp/session.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ Session management — persistent auth token + key material.
3
+
4
+ ~/.lockedpass/session.json stores the JWT and encrypted key material.
5
+ Keys (encKey, privateKey) are derived on demand — never stored on disk.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import click
13
+
14
+ from lp.crypto import derive_keys, decrypt_private_key, b64_dec, b64_enc
15
+ from lp.api import Client
16
+
17
+ SESSION_DIR = Path.home() / ".lockedpass"
18
+ SESSION_FILE = SESSION_DIR / "session.json"
19
+ CONFIG_FILE = SESSION_DIR / "config.json"
20
+
21
+
22
+ def save_session(data: dict) -> None:
23
+ SESSION_DIR.mkdir(mode=0o700, exist_ok=True)
24
+ SESSION_FILE.write_text(json.dumps(data, indent=2))
25
+ SESSION_FILE.chmod(0o600)
26
+
27
+
28
+ def load_session() -> dict | None:
29
+ if not SESSION_FILE.exists():
30
+ return None
31
+ try:
32
+ return json.loads(SESSION_FILE.read_text())
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ def clear_session() -> None:
38
+ SESSION_FILE.unlink(missing_ok=True)
39
+
40
+
41
+ def save_config(data: dict) -> None:
42
+ SESSION_DIR.mkdir(mode=0o700, exist_ok=True)
43
+ CONFIG_FILE.write_text(json.dumps(data, indent=2))
44
+
45
+
46
+ def load_config() -> dict:
47
+ if not CONFIG_FILE.exists():
48
+ return {}
49
+ try:
50
+ return json.loads(CONFIG_FILE.read_text())
51
+ except Exception:
52
+ return {}
53
+
54
+
55
+ def require_session() -> dict:
56
+ """Return session or abort with a helpful message."""
57
+ sess = load_session()
58
+ if not sess:
59
+ raise click.ClickException("Not logged in. Run: lp login")
60
+ return sess
61
+
62
+
63
+ def get_master_password() -> str:
64
+ """Get master password from env var or prompt."""
65
+ pw = os.environ.get("LP_MASTER_PASSWORD")
66
+ if pw:
67
+ return pw
68
+ return click.prompt("Master password", hide_input=True)
69
+
70
+
71
+ def cache_private_key(session: dict, priv_key: bytes) -> dict:
72
+ """Re-encrypt private key with a random local key and store in session."""
73
+ import nacl.secret
74
+ import nacl.utils
75
+ local_key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
76
+ box = nacl.secret.SecretBox(local_key)
77
+ session = dict(session)
78
+ session["_local_key"] = b64_enc(local_key)
79
+ session["_local_priv"] = b64_enc(bytes(box.encrypt(priv_key)))
80
+ return session
81
+
82
+
83
+ def unlock(session: dict) -> tuple[bytes, bytes, bytes]:
84
+ """
85
+ Returns (enc_key, private_key, public_key_bytes).
86
+ Uses locally-cached private key if available — no master password needed.
87
+ Falls back to prompting for master password.
88
+ """
89
+ if session.get("_local_key") and session.get("_local_priv"):
90
+ import nacl.secret
91
+ local_key = b64_dec(session["_local_key"])
92
+ box = nacl.secret.SecretBox(local_key)
93
+ priv_key = bytes(box.decrypt(b64_dec(session["_local_priv"])))
94
+ pub_key = b64_dec(session["public_key"])
95
+ return b"", priv_key, pub_key
96
+
97
+ pw = get_master_password()
98
+ keys = derive_keys(pw, session["argon2_params"])
99
+ priv_key = decrypt_private_key(session["encrypted_private_key"], keys["enc_key"])
100
+ pub_key = b64_dec(session["public_key"])
101
+ return keys["enc_key"], priv_key, pub_key
102
+
103
+
104
+ def make_client(session: dict) -> Client:
105
+ return Client(session["server"], session["token"])