vw-cli 0.2.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.
- vw_cli/__init__.py +12 -0
- vw_cli/__main__.py +3 -0
- vw_cli/auth.py +123 -0
- vw_cli/cli.py +123 -0
- vw_cli/crypto.py +112 -0
- vw_cli/error.py +2 -0
- vw_cli/vault.py +175 -0
- vw_cli-0.2.0.dist-info/METADATA +199 -0
- vw_cli-0.2.0.dist-info/RECORD +13 -0
- vw_cli-0.2.0.dist-info/WHEEL +5 -0
- vw_cli-0.2.0.dist-info/entry_points.txt +2 -0
- vw_cli-0.2.0.dist-info/licenses/LICENSE +9 -0
- vw_cli-0.2.0.dist-info/top_level.txt +1 -0
vw_cli/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .error import VwError
|
|
2
|
+
from .crypto import VwCryptoKey, derive_master_key, _safe_int, decode_encrypted, aes_cbc_decrypt, decrypt_bytes, decrypt, HAS_ARGON2
|
|
3
|
+
from .auth import VwAuth, REQUEST_TIMEOUT
|
|
4
|
+
from .vault import VwVault
|
|
5
|
+
from .cli import VERSION, USAGE, main
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"VwError", "VwCryptoKey", "VwAuth", "VwVault",
|
|
9
|
+
"derive_master_key", "_safe_int", "decode_encrypted", "aes_cbc_decrypt",
|
|
10
|
+
"decrypt_bytes", "decrypt", "HAS_ARGON2",
|
|
11
|
+
"REQUEST_TIMEOUT", "VERSION", "USAGE", "main",
|
|
12
|
+
]
|
vw_cli/__main__.py
ADDED
vw_cli/auth.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .error import VwError
|
|
9
|
+
|
|
10
|
+
REQUEST_TIMEOUT = 30
|
|
11
|
+
TOKEN_CACHE_DIR = os.path.expanduser("~/.config/vw-cli")
|
|
12
|
+
TOKEN_CACHE_PATH = os.path.join(TOKEN_CACHE_DIR, "token.json")
|
|
13
|
+
TOKEN_CACHE_TTL = 3600
|
|
14
|
+
TOKEN_CACHE_ENV = "VW_CLI_TOKEN_CACHE"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _token_cache_path() -> str:
|
|
18
|
+
return os.environ.get(TOKEN_CACHE_ENV, TOKEN_CACHE_PATH)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VwAuth:
|
|
22
|
+
def __init__(self, identity_url: str, client_id: str, client_secret: str):
|
|
23
|
+
self.identity_url = identity_url.rstrip("/")
|
|
24
|
+
self.client_id = client_id
|
|
25
|
+
self.client_secret = client_secret
|
|
26
|
+
self._session = requests.Session()
|
|
27
|
+
self._token: str | None = None
|
|
28
|
+
self._server_url: str | None = None
|
|
29
|
+
self._load_token()
|
|
30
|
+
|
|
31
|
+
def _http(self, method: str, url: str, **kw) -> dict:
|
|
32
|
+
kw.setdefault("timeout", REQUEST_TIMEOUT)
|
|
33
|
+
r = self._session.request(method, url, **kw)
|
|
34
|
+
if r.status_code != 200:
|
|
35
|
+
raise VwError(f"{method} {url} → {r.status_code}: {r.text[:300]}")
|
|
36
|
+
return r.json()
|
|
37
|
+
|
|
38
|
+
def _cache_key(self) -> str:
|
|
39
|
+
return f"{self.identity_url}|{self.client_id}"
|
|
40
|
+
|
|
41
|
+
def _load_token(self):
|
|
42
|
+
path = _token_cache_path()
|
|
43
|
+
try:
|
|
44
|
+
with open(path) as f:
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
if data.get("key") == self._cache_key():
|
|
47
|
+
age = time.time() - data.get("saved_at", 0)
|
|
48
|
+
if age < TOKEN_CACHE_TTL:
|
|
49
|
+
self._token = data.get("access_token")
|
|
50
|
+
self._server_url = data.get("server_url")
|
|
51
|
+
if self._token:
|
|
52
|
+
self._session.headers.update(
|
|
53
|
+
{"Authorization": f"Bearer {self._token}"}
|
|
54
|
+
)
|
|
55
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def _save_token(self):
|
|
59
|
+
path = _token_cache_path()
|
|
60
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
61
|
+
with open(path, "w") as f:
|
|
62
|
+
json.dump({
|
|
63
|
+
"key": self._cache_key(),
|
|
64
|
+
"access_token": self._token,
|
|
65
|
+
"server_url": self._server_url,
|
|
66
|
+
"saved_at": time.time(),
|
|
67
|
+
}, f)
|
|
68
|
+
|
|
69
|
+
def clear_cache(self):
|
|
70
|
+
try:
|
|
71
|
+
os.remove(_token_cache_path())
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def login(self):
|
|
76
|
+
if self._token:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if not self.client_id or not self.client_secret:
|
|
80
|
+
raise VwError("VW_CLIENTID and VW_CLIENTSECRET must be set")
|
|
81
|
+
|
|
82
|
+
for attempt in range(3):
|
|
83
|
+
try:
|
|
84
|
+
r = self._http(
|
|
85
|
+
"POST", f"{self.identity_url}/connect/token",
|
|
86
|
+
data={"grant_type": "client_credentials",
|
|
87
|
+
"client_id": self.client_id,
|
|
88
|
+
"client_secret": self.client_secret,
|
|
89
|
+
"scope": "api",
|
|
90
|
+
"device_identifier": str(uuid.uuid4()),
|
|
91
|
+
"device_type": "2",
|
|
92
|
+
"device_name": "vw-cli"},
|
|
93
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
94
|
+
except VwError as e:
|
|
95
|
+
if "429" in str(e) and attempt < 2:
|
|
96
|
+
wait = 10 * (attempt + 1)
|
|
97
|
+
print(f"rate limited – retrying in {wait}s ...",
|
|
98
|
+
file=__import__("sys").stderr)
|
|
99
|
+
time.sleep(wait)
|
|
100
|
+
continue
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
tok = r.get("access_token")
|
|
104
|
+
if not tok:
|
|
105
|
+
raise VwError("login failed – no access_token in response")
|
|
106
|
+
self._token = tok
|
|
107
|
+
self._session.headers.update(
|
|
108
|
+
{"Authorization": f"Bearer {self._token}"}
|
|
109
|
+
)
|
|
110
|
+
self._save_token()
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
def request(self, method: str, url: str, **kw) -> dict:
|
|
114
|
+
for attempt in range(2):
|
|
115
|
+
try:
|
|
116
|
+
return self._http(method, url, **kw)
|
|
117
|
+
except VwError as e:
|
|
118
|
+
if "401" in str(e) and attempt == 0:
|
|
119
|
+
self._token = None
|
|
120
|
+
self.clear_cache()
|
|
121
|
+
self.login()
|
|
122
|
+
continue
|
|
123
|
+
raise
|
vw_cli/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from .error import VwError
|
|
9
|
+
from .auth import VwAuth
|
|
10
|
+
from .vault import VwVault
|
|
11
|
+
|
|
12
|
+
VERSION = "0.2.0"
|
|
13
|
+
|
|
14
|
+
USAGE = """\
|
|
15
|
+
usage: vw-cli <command> [args]
|
|
16
|
+
|
|
17
|
+
commands:
|
|
18
|
+
login authenticate with API key
|
|
19
|
+
sync sync vault and print raw JSON
|
|
20
|
+
unlock unlock vault (derive encryption key)
|
|
21
|
+
list list all item names
|
|
22
|
+
get password <item> print password for <item>
|
|
23
|
+
get item <item> print full decrypted <item> as JSON
|
|
24
|
+
|
|
25
|
+
environment:
|
|
26
|
+
VW_SERVER vault server URL (default: https://vault.bitwarden.com)
|
|
27
|
+
VW_CLIENTID API client ID (user.xxxx)
|
|
28
|
+
VW_CLIENTSECRET API client secret
|
|
29
|
+
VW_PASSWORD master password
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _setup_urls(server_url: str) -> tuple[str, str]:
|
|
34
|
+
host = urlparse(server_url).hostname or ""
|
|
35
|
+
if "bitwarden" in host:
|
|
36
|
+
identity_url = "https://identity.bitwarden.com"
|
|
37
|
+
api_url = "https://api.bitwarden.com"
|
|
38
|
+
else:
|
|
39
|
+
base = server_url.rstrip("/")
|
|
40
|
+
identity_url = f"{base}/identity"
|
|
41
|
+
api_url = base
|
|
42
|
+
return identity_url, api_url
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
|
47
|
+
print(USAGE, end="")
|
|
48
|
+
sys.exit(0)
|
|
49
|
+
if sys.argv[1] in ("--version",):
|
|
50
|
+
print(f"vw-cli {VERSION}")
|
|
51
|
+
sys.exit(0)
|
|
52
|
+
|
|
53
|
+
cmd = sys.argv[1]
|
|
54
|
+
|
|
55
|
+
server_url = os.environ.get("VW_SERVER", "https://vault.bitwarden.com")
|
|
56
|
+
identity_url, api_url = _setup_urls(server_url)
|
|
57
|
+
client_id = os.environ.get("VW_CLIENTID", "")
|
|
58
|
+
client_secret = os.environ.get("VW_CLIENTSECRET", "")
|
|
59
|
+
password = os.environ.get("VW_PASSWORD", "")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
if cmd == "login":
|
|
63
|
+
auth = VwAuth(identity_url, client_id, client_secret)
|
|
64
|
+
auth.login()
|
|
65
|
+
print("ok – logged in")
|
|
66
|
+
|
|
67
|
+
elif cmd == "sync":
|
|
68
|
+
auth = VwAuth(identity_url, client_id, client_secret)
|
|
69
|
+
auth.login()
|
|
70
|
+
vault = VwVault(api_url, password, auth.request)
|
|
71
|
+
data = vault.sync()
|
|
72
|
+
print(json.dumps(data, indent=2))
|
|
73
|
+
|
|
74
|
+
elif cmd == "unlock":
|
|
75
|
+
auth = VwAuth(identity_url, client_id, client_secret)
|
|
76
|
+
auth.login()
|
|
77
|
+
vault = VwVault(api_url, password, auth.request)
|
|
78
|
+
vault.sync()
|
|
79
|
+
vault.unlock()
|
|
80
|
+
print("ok – vault unlocked")
|
|
81
|
+
|
|
82
|
+
elif cmd == "list":
|
|
83
|
+
auth = VwAuth(identity_url, client_id, client_secret)
|
|
84
|
+
auth.login()
|
|
85
|
+
vault = VwVault(api_url, password, auth.request)
|
|
86
|
+
vault.sync()
|
|
87
|
+
vault.unlock()
|
|
88
|
+
for name in vault.list_names():
|
|
89
|
+
print(name)
|
|
90
|
+
|
|
91
|
+
elif cmd == "get":
|
|
92
|
+
if len(sys.argv) < 4:
|
|
93
|
+
print("usage: vw-cli get password|item <name>\n", file=sys.stderr)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
sub = sys.argv[2]
|
|
96
|
+
name = sys.argv[3]
|
|
97
|
+
auth = VwAuth(identity_url, client_id, client_secret)
|
|
98
|
+
auth.login()
|
|
99
|
+
vault = VwVault(api_url, password, auth.request)
|
|
100
|
+
vault.sync()
|
|
101
|
+
vault.unlock()
|
|
102
|
+
if sub == "password":
|
|
103
|
+
print(vault.get_password(name))
|
|
104
|
+
elif sub == "item":
|
|
105
|
+
print(json.dumps(vault.get_item(name), indent=2))
|
|
106
|
+
else:
|
|
107
|
+
print(f"unknown sub-command: get {sub}", file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
else:
|
|
111
|
+
print(f"unknown command: {cmd}\n{USAGE}", file=sys.stderr)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
except VwError as e:
|
|
115
|
+
print(f"error: {e}", file=sys.stderr)
|
|
116
|
+
sys.exit(1)
|
|
117
|
+
except requests.RequestException as e:
|
|
118
|
+
print(f"network error: {e}", file=sys.stderr)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
vw_cli/crypto.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from argon2.low_level import hash_secret_raw, Type
|
|
11
|
+
HAS_ARGON2 = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
HAS_ARGON2 = False
|
|
14
|
+
|
|
15
|
+
from .error import VwError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class VwCryptoKey:
|
|
20
|
+
enc: bytes
|
|
21
|
+
mac: bytes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def derive_master_key(
|
|
25
|
+
password: str, email: str, kdf: int, iterations: int,
|
|
26
|
+
memory: int = 64, parallelism: int = 4,
|
|
27
|
+
) -> tuple[bytes, bytes]:
|
|
28
|
+
salt = email.lower().encode("utf-8")
|
|
29
|
+
pw = password.encode("utf-8")
|
|
30
|
+
|
|
31
|
+
if kdf == 0:
|
|
32
|
+
mk = hashlib.pbkdf2_hmac("sha256", pw, salt, iterations, 32)
|
|
33
|
+
elif kdf == 1:
|
|
34
|
+
if not HAS_ARGON2:
|
|
35
|
+
raise VwError("Argon2id KDF requires argon2-cffi. "
|
|
36
|
+
"Install: pip install argon2-cffi")
|
|
37
|
+
mk = hash_secret_raw(secret=pw, salt=salt,
|
|
38
|
+
time_cost=iterations,
|
|
39
|
+
memory_cost=memory * 1024,
|
|
40
|
+
parallelism=parallelism, hash_len=32,
|
|
41
|
+
type=Type.ID, version=19)
|
|
42
|
+
else:
|
|
43
|
+
raise VwError(f"unsupported KDF type {kdf}")
|
|
44
|
+
|
|
45
|
+
hsh = hashlib.pbkdf2_hmac("sha256", mk, pw, 1, 32)
|
|
46
|
+
return mk, hsh
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _safe_int(v, default: int) -> int:
|
|
50
|
+
if isinstance(v, int):
|
|
51
|
+
return v
|
|
52
|
+
if isinstance(v, str):
|
|
53
|
+
try:
|
|
54
|
+
return int(v)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return default
|
|
57
|
+
return default
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_encrypted(enc_str: str) -> tuple[Optional[bytes], Optional[bytes], Optional[bytes]]:
|
|
61
|
+
if not enc_str:
|
|
62
|
+
return None, None, None
|
|
63
|
+
|
|
64
|
+
parts = enc_str.split(".")
|
|
65
|
+
|
|
66
|
+
# Format: type.iv|ct|mac (e.g. "2.iv|ct|mac")
|
|
67
|
+
if len(parts) == 2:
|
|
68
|
+
rest = parts[1]
|
|
69
|
+
sub = rest.split("|")
|
|
70
|
+
if len(sub) == 3:
|
|
71
|
+
return (base64.b64decode(sub[0]), # iv
|
|
72
|
+
base64.b64decode(sub[2]), # mac
|
|
73
|
+
base64.b64decode(sub[1])) # ct
|
|
74
|
+
|
|
75
|
+
# Format: iv.mac.ct
|
|
76
|
+
if len(parts) == 3:
|
|
77
|
+
return (base64.b64decode(parts[0]),
|
|
78
|
+
base64.b64decode(parts[1]),
|
|
79
|
+
base64.b64decode(parts[2]))
|
|
80
|
+
|
|
81
|
+
# Combined format: single base64 blob (iv(16) + mac(32) + ct)
|
|
82
|
+
raw = base64.b64decode(parts[0])
|
|
83
|
+
if len(raw) < 48:
|
|
84
|
+
return None, None, None
|
|
85
|
+
return raw[:16], raw[16:48], raw[48:]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def stretch_key(mk: bytes, info: str) -> bytes:
|
|
89
|
+
return hmac.new(mk, info.encode() + b"\x01", hashlib.sha256).digest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def aes_cbc_decrypt(ct: bytes, key: bytes, iv: bytes) -> bytes:
|
|
93
|
+
c = Cipher(algorithms.AES(key), modes.CBC(iv))
|
|
94
|
+
d = c.decryptor()
|
|
95
|
+
padded = d.update(ct) + d.finalize()
|
|
96
|
+
pad = padded[-1]
|
|
97
|
+
return padded[:-pad]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decrypt_bytes(enc_str: str, k: VwCryptoKey) -> bytes:
|
|
101
|
+
iv, mac, ct = decode_encrypted(enc_str)
|
|
102
|
+
if iv is None:
|
|
103
|
+
return b""
|
|
104
|
+
if mac:
|
|
105
|
+
want = hmac.new(k.mac, iv + ct, hashlib.sha256).digest()
|
|
106
|
+
if not hmac.compare_digest(want, mac):
|
|
107
|
+
raise VwError("HMAC mismatch – wrong password or corrupted data")
|
|
108
|
+
return aes_cbc_decrypt(ct, k.enc, iv)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def decrypt(enc_str: str, k: VwCryptoKey) -> str:
|
|
112
|
+
return decrypt_bytes(enc_str, k).decode("utf-8")
|
vw_cli/error.py
ADDED
vw_cli/vault.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from typing import Callable, Optional
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
|
|
7
|
+
|
|
8
|
+
from .error import VwError
|
|
9
|
+
from .crypto import VwCryptoKey, decrypt, decrypt_bytes, derive_master_key, stretch_key, _safe_int
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VwVault:
|
|
13
|
+
def __init__(self, api_url: str, password: str, http_func: Callable):
|
|
14
|
+
self.api_url = api_url.rstrip("/")
|
|
15
|
+
self.password = password
|
|
16
|
+
self._http = http_func
|
|
17
|
+
self._profile: Optional[dict] = None
|
|
18
|
+
self._ciphers: list[dict] = []
|
|
19
|
+
self._crypto_key: Optional[VwCryptoKey] = None
|
|
20
|
+
self._org_keys: dict[str, VwCryptoKey] = {}
|
|
21
|
+
self._name_cache: Optional[dict[str, dict]] = None
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------------
|
|
24
|
+
# sync
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
def sync(self) -> dict:
|
|
27
|
+
self._profile = {}
|
|
28
|
+
self._ciphers = []
|
|
29
|
+
self._name_cache = None
|
|
30
|
+
data = self._http("GET", f"{self.api_url}/api/sync")
|
|
31
|
+
self._profile = data.get("profile", {})
|
|
32
|
+
self._ciphers = data.get("ciphers", [])
|
|
33
|
+
return data
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# unlock
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
def unlock(self):
|
|
39
|
+
if not self.password:
|
|
40
|
+
raise VwError("VW_PASSWORD must be set")
|
|
41
|
+
if not self._profile:
|
|
42
|
+
self.sync()
|
|
43
|
+
|
|
44
|
+
p = self._profile
|
|
45
|
+
email = p.get("email", "")
|
|
46
|
+
if not email:
|
|
47
|
+
raise VwError("email missing from profile – cannot derive key")
|
|
48
|
+
kdf = _safe_int(p.get("kdf"), 0)
|
|
49
|
+
iterations = _safe_int(p.get("kdfIterations"), 600000)
|
|
50
|
+
memory = _safe_int(p.get("kdfMemory"), 64)
|
|
51
|
+
parallelism = _safe_int(p.get("kdfParallelism"), 4)
|
|
52
|
+
|
|
53
|
+
enc_key = p.get("key") or p.get("encryptedKey")
|
|
54
|
+
if not enc_key:
|
|
55
|
+
raise VwError("no encrypted key in profile – "
|
|
56
|
+
"make sure you are using a user API key (user.xxxx)")
|
|
57
|
+
|
|
58
|
+
mk, _h = derive_master_key(self.password, email, kdf,
|
|
59
|
+
iterations, memory, parallelism)
|
|
60
|
+
uk = decrypt_bytes(enc_key,
|
|
61
|
+
VwCryptoKey(enc=stretch_key(mk, "enc"),
|
|
62
|
+
mac=stretch_key(mk, "mac")))
|
|
63
|
+
if len(uk) != 64:
|
|
64
|
+
raise VwError(f"expected 64-byte symmetric key, got {len(uk)}")
|
|
65
|
+
self._crypto_key = VwCryptoKey(enc=uk[:32], mac=uk[32:])
|
|
66
|
+
|
|
67
|
+
# Decrypt RSA private key
|
|
68
|
+
priv_enc = p.get("privateKey")
|
|
69
|
+
if priv_enc:
|
|
70
|
+
priv_raw = decrypt_bytes(priv_enc, self._crypto_key)
|
|
71
|
+
priv_key = serialization.load_der_private_key(priv_raw, password=None)
|
|
72
|
+
|
|
73
|
+
# Decrypt organization keys
|
|
74
|
+
for org in p.get("organizations", []):
|
|
75
|
+
org_id = org.get("id")
|
|
76
|
+
org_key_enc = org.get("key")
|
|
77
|
+
if not org_id or not org_key_enc:
|
|
78
|
+
continue
|
|
79
|
+
enc_type = org_key_enc.split(".")[0]
|
|
80
|
+
if enc_type == "4":
|
|
81
|
+
org_cipher = base64.b64decode(org_key_enc.split(".", 1)[1])
|
|
82
|
+
org_dec = priv_key.decrypt(
|
|
83
|
+
org_cipher,
|
|
84
|
+
asym_padding.OAEP(
|
|
85
|
+
mgf=asym_padding.MGF1(algorithm=hashes.SHA1()),
|
|
86
|
+
algorithm=hashes.SHA1(),
|
|
87
|
+
label=None,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
org_key = VwCryptoKey(enc=org_dec[:32], mac=org_dec[32:])
|
|
91
|
+
self._org_keys[org_id] = org_key
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
# internal
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
def _key_for(self, cipher: dict) -> VwCryptoKey:
|
|
99
|
+
oid = cipher.get("organizationId")
|
|
100
|
+
if oid and oid in self._org_keys:
|
|
101
|
+
return self._org_keys[oid]
|
|
102
|
+
return self._crypto_key
|
|
103
|
+
|
|
104
|
+
def _ensure_unlocked(self):
|
|
105
|
+
if not self._crypto_key:
|
|
106
|
+
self.unlock()
|
|
107
|
+
|
|
108
|
+
def _rebuild_name_cache(self):
|
|
109
|
+
self._ensure_unlocked()
|
|
110
|
+
if self._name_cache is not None:
|
|
111
|
+
return
|
|
112
|
+
d: dict[str, dict] = {}
|
|
113
|
+
for c in self._ciphers:
|
|
114
|
+
cn = decrypt(c.get("name", ""), self._key_for(c))
|
|
115
|
+
d[cn.lower()] = c
|
|
116
|
+
self._name_cache = d
|
|
117
|
+
|
|
118
|
+
def _find_cipher(self, name: str) -> dict:
|
|
119
|
+
self._rebuild_name_cache()
|
|
120
|
+
assert self._name_cache is not None
|
|
121
|
+
target = name.lower()
|
|
122
|
+
if target in self._name_cache:
|
|
123
|
+
return self._name_cache[target]
|
|
124
|
+
for key, c in self._name_cache.items():
|
|
125
|
+
if target in key:
|
|
126
|
+
return c
|
|
127
|
+
raise VwError(f"item '{name}' not found")
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# get password
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
def get_password(self, name: str) -> str:
|
|
133
|
+
c = self._find_cipher(name)
|
|
134
|
+
enc_pw = (c.get("login") or {}).get("password", "")
|
|
135
|
+
if not enc_pw:
|
|
136
|
+
return ""
|
|
137
|
+
return decrypt(enc_pw, self._key_for(c))
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# list
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
def list_names(self) -> list[str]:
|
|
143
|
+
self._rebuild_name_cache()
|
|
144
|
+
assert self._name_cache is not None
|
|
145
|
+
return sorted(self._name_cache.keys())
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# get item
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
def get_item(self, name: str) -> dict:
|
|
151
|
+
c = self._find_cipher(name)
|
|
152
|
+
k = self._key_for(c)
|
|
153
|
+
|
|
154
|
+
def d(s: str) -> str:
|
|
155
|
+
return decrypt(s, k) if s else ""
|
|
156
|
+
|
|
157
|
+
out: dict = {"id": c.get("id")}
|
|
158
|
+
for f in ("name", "notes"):
|
|
159
|
+
if c.get(f):
|
|
160
|
+
out[f] = d(c[f])
|
|
161
|
+
if "login" in c:
|
|
162
|
+
li = c["login"]
|
|
163
|
+
lo: dict = {}
|
|
164
|
+
for f in ("username", "password", "totp"):
|
|
165
|
+
if li.get(f):
|
|
166
|
+
lo[f] = d(li[f])
|
|
167
|
+
out["login"] = lo
|
|
168
|
+
if "fields" in c:
|
|
169
|
+
out["fields"] = [
|
|
170
|
+
{"name": d(f.get("name", "")),
|
|
171
|
+
"value": d(f.get("value", "")),
|
|
172
|
+
"type": f.get("type")}
|
|
173
|
+
for f in c["fields"]
|
|
174
|
+
]
|
|
175
|
+
return out
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vw-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Lightweight Vaultwarden (Bitwarden-compatible) CLI in Python
|
|
5
|
+
Author-email: Roberta Brandao <roberta@betabrandao.com.br>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/betabrandao/vaultvarden-cli
|
|
8
|
+
Project-URL: Repository, https://gitlab.com/betabrandao/vaultvarden-cli
|
|
9
|
+
Keywords: bitwarden,vaultwarden,cli,password-manager,alpine
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Security :: Cryptography
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: requests>=2.31
|
|
25
|
+
Requires-Dist: cryptography>=41
|
|
26
|
+
Requires-Dist: argon2-cffi>=23
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# vw-cli
|
|
30
|
+
|
|
31
|
+
Lightweight Vaultwarden (Bitwarden-compatible) CLI written in Python.
|
|
32
|
+
Authenticates via API key, syncs the vault, derives the encryption key
|
|
33
|
+
locally, and prints decrypted passwords or items — all in a single command.
|
|
34
|
+
|
|
35
|
+
Designed for use in Alpine‑based Docker containers where the official
|
|
36
|
+
`bw` Node.js CLI is impractical.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
pip install vw-cli
|
|
42
|
+
|
|
43
|
+
export VW_SERVER=https://vault.example.com
|
|
44
|
+
export VW_CLIENTID=user.aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
|
|
45
|
+
export VW_CLIENTSECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
46
|
+
export VW_PASSWORD="your master password"
|
|
47
|
+
|
|
48
|
+
vw-cli get password "My Website"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
usage: vw-cli <command> [args]
|
|
55
|
+
|
|
56
|
+
commands:
|
|
57
|
+
login authenticate with API key
|
|
58
|
+
sync sync vault and print raw JSON
|
|
59
|
+
unlock unlock vault (derive encryption key)
|
|
60
|
+
list list all item names
|
|
61
|
+
get password <item> print password for <item>
|
|
62
|
+
get item <item> print full decrypted <item> as JSON
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Commands
|
|
66
|
+
|
|
67
|
+
| command | description |
|
|
68
|
+
|---------|-------------|
|
|
69
|
+
| `login` | Authenticate with the API key. Verifies credentials work. |
|
|
70
|
+
| `sync` | Pull the full vault (profile + all ciphers) and print raw JSON. |
|
|
71
|
+
| `unlock` | Derive the master key locally and decrypt the stored symmetric key. |
|
|
72
|
+
| `list` | Print every item name (lowercased), one per line. |
|
|
73
|
+
| `get password <item>` | Print the password for the named item (empty string if none). |
|
|
74
|
+
| `get item <item>` | Print the full decrypted item as JSON. |
|
|
75
|
+
|
|
76
|
+
Item lookup is case‑insensitive and supports substring matching.
|
|
77
|
+
|
|
78
|
+
## Environment variables
|
|
79
|
+
|
|
80
|
+
| variable | required | default | description |
|
|
81
|
+
|----------|----------|---------|-------------|
|
|
82
|
+
| `VW_SERVER` | no | `https://vault.bitwarden.com` | Vaultwarden or Bitwarden server URL |
|
|
83
|
+
| `VW_CLIENTID` | yes | — | API client ID (`user.xxx` or `org.xxx`) |
|
|
84
|
+
| `VW_CLIENTSECRET` | yes | — | API client secret |
|
|
85
|
+
| `VW_PASSWORD` | yes | — | Master password |
|
|
86
|
+
|
|
87
|
+
When `VW_SERVER` contains `bitwarden` the official Bitwarden identity
|
|
88
|
+
and API endpoints are used automatically; otherwise the same URL is used
|
|
89
|
+
for both identity and API paths.
|
|
90
|
+
|
|
91
|
+
## Docker example
|
|
92
|
+
|
|
93
|
+
```Dockerfile
|
|
94
|
+
FROM alpine:3.19
|
|
95
|
+
RUN apk add --no-cache python3 py3-pip
|
|
96
|
+
RUN pip install vw-cli
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
docker run --rm \
|
|
101
|
+
-e VW_SERVER=https://vault.example.com \
|
|
102
|
+
-e VW_CLIENTID=user.xxx \
|
|
103
|
+
-e VW_CLIENTSECRET=xxx \
|
|
104
|
+
-e VW_PASSWORD="..." \
|
|
105
|
+
my-image vw-cli get password "Database"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
1. **Login** — sends a `client_credentials` grant with the API key to
|
|
111
|
+
`{identity_url}/connect/token` and receives a bearer token.
|
|
112
|
+
2. **Sync** — fetches `GET /api/sync` which returns the user profile
|
|
113
|
+
(email, KDF parameters, encrypted symmetric key) and all ciphers.
|
|
114
|
+
3. **Unlock** — derives the 32‑byte master key using the password and
|
|
115
|
+
email (PBKDF2‑SHA256 or Argon2id, depending on the profile KDF), then
|
|
116
|
+
decrypts the 64‑byte symmetric key stored in the profile.
|
|
117
|
+
4. **Decrypt** — splits the symmetric key into an AES‑256‑CBC encryption
|
|
118
|
+
key (first 32 bytes) and an HMAC‑SHA256 MAC key (last 32 bytes), then
|
|
119
|
+
decrypts individual cipher fields.
|
|
120
|
+
|
|
121
|
+
All key derivation happens **locally** — no unlock endpoint is called.
|
|
122
|
+
|
|
123
|
+
## Architecture
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
vw_cli/
|
|
127
|
+
├── __init__.py # package entry, re‑exports
|
|
128
|
+
├── __main__.py # python -m vw_cli support
|
|
129
|
+
├── error.py # VwError exception class
|
|
130
|
+
├── crypto.py # VwCryptoKey, key derivation, AES-CBC, HMAC
|
|
131
|
+
├── auth.py # VwAuth – API key login, session management
|
|
132
|
+
├── vault.py # VwVault – sync, unlock, cipher operations
|
|
133
|
+
└── cli.py # CLI argument parsing, orchestration
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Separation of concerns:
|
|
137
|
+
|
|
138
|
+
| module | responsibility |
|
|
139
|
+
|--------|---------------|
|
|
140
|
+
| `crypto.py` | Pure functions: key derivation, encryption/decryption, data types. No I/O. |
|
|
141
|
+
| `auth.py` | `VwAuth` class: HTTP session, token acquisition, request helper. |
|
|
142
|
+
| `vault.py` | `VwVault` class: sync, unlock, name cache, find/get/list operations. |
|
|
143
|
+
| `cli.py` | Environment variable reading, argument parsing, command dispatch. |
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
### Virtualenv (recommended)
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
python -m venv venv
|
|
151
|
+
source venv/bin/activate
|
|
152
|
+
pip install -e .
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The package is installed in **editable mode** (`-e`), so source changes take
|
|
156
|
+
effect immediately — no need to reinstall.
|
|
157
|
+
|
|
158
|
+
### Run without pip
|
|
159
|
+
|
|
160
|
+
You don't need to `pip install` at all. The `__main__.py` entry point lets you
|
|
161
|
+
run the CLI directly from the checkout:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
python -m vw_cli get password "My Website"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Or set up a shell alias for convenience:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
alias vw-cli='python -m vw_cli'
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Test
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
python -m pytest tests/ -v
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Testing
|
|
180
|
+
|
|
181
|
+
Three test files covering all layers:
|
|
182
|
+
|
|
183
|
+
| test file | coverage |
|
|
184
|
+
|-----------|----------|
|
|
185
|
+
| `tests/test_crypto.py` | `VwCryptoKey`, `_safe_int`, key derivation, AES-CBC, HMAC, `decrypt`, `decrypt_bytes`, `decode_encrypted` |
|
|
186
|
+
| `tests/test_client.py` | `VwAuth` (login, errors), `VwVault` (sync, unlock, find, get, list), `_setup_urls`, network errors, timeout |
|
|
187
|
+
| `tests/test_cli.py` | CLI argument parsing, help/version output, error handling, exit codes |
|
|
188
|
+
|
|
189
|
+
Tests use mocks and never touch the network.
|
|
190
|
+
|
|
191
|
+
### Dependencies
|
|
192
|
+
|
|
193
|
+
- `requests` — HTTP client
|
|
194
|
+
- `cryptography` — AES‑256‑CBC via OpenSSL bindings
|
|
195
|
+
- `argon2-cffi` — Argon2id KDF (optional; falls back gracefully)
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
vw_cli/__init__.py,sha256=8jVdzr6IvESIzQb5V4s--1Nht_Xc008xjYbSnnVeahw,507
|
|
2
|
+
vw_cli/__main__.py,sha256=YV4Zw3I2UpQdtA26kynsxazz3sUwx4bRcPLrcUp4pSQ,36
|
|
3
|
+
vw_cli/auth.py,sha256=-7YAwLBlZFLG_GFBvqfyjzADKCYDmSkrkOh1_nh3ois,4272
|
|
4
|
+
vw_cli/cli.py,sha256=brtBvOu2DmAr6eGl-ux9-jnHKYR6Vyzj8NXl_u785Kc,3810
|
|
5
|
+
vw_cli/crypto.py,sha256=HsvIoMsEUHC_kdby-WDJnFYYfM2M0EyVW4Sexi9NM6k,3246
|
|
6
|
+
vw_cli/error.py,sha256=USoXlZfT5LdRsOueNPGIiFLcG-d6l8BPWBlYKGiZxUY,35
|
|
7
|
+
vw_cli/vault.py,sha256=DS6zZQqVJYi96yE8wSkY_zMIYrprK5ZCh0UJlVRkXfk,6623
|
|
8
|
+
vw_cli-0.2.0.dist-info/licenses/LICENSE,sha256=17chzbl98Pk6UP0EWBHCEeMzhXZ4kNtJJefNhbX5X_s,1107
|
|
9
|
+
vw_cli-0.2.0.dist-info/METADATA,sha256=QdzSY0_CwtgasC8_acMFeqK3mm-6-ulo-soIOXK_3q4,6532
|
|
10
|
+
vw_cli-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
vw_cli-0.2.0.dist-info/entry_points.txt,sha256=bCBrJdd92ZiEzUA-uxWpTWvFvEome9McrZ5NXAYgAvI,43
|
|
12
|
+
vw_cli-0.2.0.dist-info/top_level.txt,sha256=nID4WsHGaaMskZbzPF7W2WxW0fsvvjg2kg3oqcZZZxc,7
|
|
13
|
+
vw_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 robertanrbrandao[at]gmail[dot]com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vw_cli
|