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 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
@@ -0,0 +1,3 @@
1
+ from vw_cli.cli import main
2
+
3
+ main()
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
@@ -0,0 +1,2 @@
1
+ class VwError(Exception):
2
+ pass
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,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
+ vw-cli = vw_cli.cli:main
@@ -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