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.
- lockedpass_cli-0.1.0.dist-info/METADATA +107 -0
- lockedpass_cli-0.1.0.dist-info/RECORD +12 -0
- lockedpass_cli-0.1.0.dist-info/WHEEL +5 -0
- lockedpass_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lockedpass_cli-0.1.0.dist-info/licenses/LICENSE +8 -0
- lockedpass_cli-0.1.0.dist-info/top_level.txt +1 -0
- lp/__init__.py +2 -0
- lp/__main__.py +4 -0
- lp/api.py +140 -0
- lp/cli.py +727 -0
- lp/crypto.py +143 -0
- lp/session.py +105 -0
|
@@ -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,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
lp/__main__.py
ADDED
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"])
|