keepassxc-browser-api 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.
- keepassxc_browser_api/__init__.py +23 -0
- keepassxc_browser_api/client.py +735 -0
- keepassxc_browser_api/config.py +101 -0
- keepassxc_browser_api/exceptions.py +27 -0
- keepassxc_browser_api/models.py +77 -0
- keepassxc_browser_api-0.1.0.dist-info/METADATA +117 -0
- keepassxc_browser_api-0.1.0.dist-info/RECORD +10 -0
- keepassxc_browser_api-0.1.0.dist-info/WHEEL +5 -0
- keepassxc_browser_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- keepassxc_browser_api-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""KeePassXC Browser API library.
|
|
2
|
+
|
|
3
|
+
A Python library for communicating with KeePassXC via the browser extension
|
|
4
|
+
protocol (NaCl-encrypted JSON over a Unix socket).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .client import BrowserClient
|
|
10
|
+
from .config import Association, BrowserConfig
|
|
11
|
+
from .exceptions import AssociationError, KeePassXCError, NotAssociatedError
|
|
12
|
+
from .models import Entry, Group
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BrowserClient",
|
|
16
|
+
"BrowserConfig",
|
|
17
|
+
"Association",
|
|
18
|
+
"Entry",
|
|
19
|
+
"Group",
|
|
20
|
+
"KeePassXCError",
|
|
21
|
+
"AssociationError",
|
|
22
|
+
"NotAssociatedError",
|
|
23
|
+
]
|
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""KeePassXC browser extension protocol client.
|
|
2
|
+
|
|
3
|
+
Implements the NaCl-encrypted protocol used by the KeePassXC browser extension
|
|
4
|
+
to communicate with the KeePassXC application.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import nacl.exceptions
|
|
18
|
+
import nacl.public
|
|
19
|
+
import nacl.utils
|
|
20
|
+
|
|
21
|
+
from .config import Association, BrowserConfig
|
|
22
|
+
from .exceptions import AssociationError, NotAssociatedError, ProtocolError
|
|
23
|
+
from .models import Entry, Group
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
CLIENT_ID = "keepassxc-browser-api"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_keepassxc_socket_path() -> str:
|
|
31
|
+
"""Get the KeePassXC browser extension socket path (platform-aware)."""
|
|
32
|
+
# Linux: XDG_RUNTIME_DIR based path used by Flatpak / native installs
|
|
33
|
+
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", "")
|
|
34
|
+
if xdg_runtime:
|
|
35
|
+
flatpak_path = os.path.join(
|
|
36
|
+
xdg_runtime, "app", "org.keepassxc.KeePassXC",
|
|
37
|
+
"org.keepassxc.KeePassXC.BrowserServer",
|
|
38
|
+
)
|
|
39
|
+
if os.path.exists(flatpak_path):
|
|
40
|
+
return flatpak_path
|
|
41
|
+
native_path = os.path.join(xdg_runtime, "org.keepassxc.KeePassXC.BrowserServer")
|
|
42
|
+
if os.path.exists(native_path):
|
|
43
|
+
return native_path
|
|
44
|
+
# macOS / fallback
|
|
45
|
+
return os.path.join(tempfile.gettempdir(), "org.keepassxc.KeePassXC.BrowserServer")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _b64encode(data: bytes) -> str:
|
|
49
|
+
return base64.b64encode(data).decode("ascii")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _b64decode(data: str) -> bytes:
|
|
53
|
+
return base64.b64decode(data)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _increment_nonce(nonce: bytes) -> bytes:
|
|
57
|
+
"""Increment a nonce by 1 (little-endian), matching sodium_increment().
|
|
58
|
+
|
|
59
|
+
Processes all bytes regardless of carry to avoid timing side-channels.
|
|
60
|
+
"""
|
|
61
|
+
n = bytearray(nonce)
|
|
62
|
+
carry = 1
|
|
63
|
+
for i in range(len(n)):
|
|
64
|
+
val = n[i] + carry
|
|
65
|
+
n[i] = val & 0xFF
|
|
66
|
+
carry = val >> 8
|
|
67
|
+
return bytes(n)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BrowserClient:
|
|
71
|
+
"""Client for the KeePassXC browser extension protocol.
|
|
72
|
+
|
|
73
|
+
Typical usage::
|
|
74
|
+
|
|
75
|
+
config = BrowserConfig.load()
|
|
76
|
+
client = BrowserClient(config)
|
|
77
|
+
|
|
78
|
+
# First-time setup (requires user approval in KeePassXC)
|
|
79
|
+
client.setup()
|
|
80
|
+
config.save()
|
|
81
|
+
|
|
82
|
+
# Ensure DB is unlocked (triggers TouchID/biometrics if locked)
|
|
83
|
+
client.ensure_unlocked()
|
|
84
|
+
|
|
85
|
+
# Use the API (auto-connects if needed)
|
|
86
|
+
entries = client.get_logins("https://example.com")
|
|
87
|
+
|
|
88
|
+
# Or use as a context manager
|
|
89
|
+
with BrowserClient(config) as client:
|
|
90
|
+
entries = client.get_logins("https://example.com")
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, config: BrowserConfig):
|
|
94
|
+
self.config = config
|
|
95
|
+
self._socket: socket.socket | None = None
|
|
96
|
+
self._server_public_key: nacl.public.PublicKey | None = None
|
|
97
|
+
|
|
98
|
+
if config.client_public_key and config.client_secret_key:
|
|
99
|
+
sk_bytes = _b64decode(config.client_secret_key)
|
|
100
|
+
self._secret_key = nacl.public.PrivateKey(sk_bytes)
|
|
101
|
+
self._public_key = self._secret_key.public_key
|
|
102
|
+
else:
|
|
103
|
+
self._secret_key = nacl.public.PrivateKey.generate()
|
|
104
|
+
self._public_key = self._secret_key.public_key
|
|
105
|
+
config.client_public_key = _b64encode(bytes(self._public_key))
|
|
106
|
+
config.client_secret_key = _b64encode(bytes(self._secret_key))
|
|
107
|
+
|
|
108
|
+
def __enter__(self) -> BrowserClient:
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def __exit__(self, *exc: object) -> None:
|
|
112
|
+
self.disconnect()
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
# Connection management
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def connect(self) -> bool:
|
|
119
|
+
"""Connect to KeePassXC browser extension socket.
|
|
120
|
+
|
|
121
|
+
Returns True on success, False if KeePassXC is not running.
|
|
122
|
+
"""
|
|
123
|
+
path = _get_keepassxc_socket_path()
|
|
124
|
+
try:
|
|
125
|
+
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
126
|
+
self._socket.settimeout(5.0)
|
|
127
|
+
self._socket.connect(path)
|
|
128
|
+
logger.debug("Connected to KeePassXC at %s", path)
|
|
129
|
+
return True
|
|
130
|
+
except OSError as e:
|
|
131
|
+
logger.error("Cannot connect to KeePassXC browser socket at %s: %s", path, e)
|
|
132
|
+
self._socket = None
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def disconnect(self) -> None:
|
|
136
|
+
"""Close the connection and clear session state."""
|
|
137
|
+
if self._socket:
|
|
138
|
+
try:
|
|
139
|
+
self._socket.close()
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
self._socket = None
|
|
143
|
+
self._server_public_key = None
|
|
144
|
+
|
|
145
|
+
def _ensure_session(self) -> bool:
|
|
146
|
+
"""Ensure there is an active connection with completed key exchange.
|
|
147
|
+
|
|
148
|
+
Automatically connects and exchanges public keys if needed.
|
|
149
|
+
Returns True if the session is ready, False on failure.
|
|
150
|
+
"""
|
|
151
|
+
if self._socket and self._server_public_key:
|
|
152
|
+
return True
|
|
153
|
+
if not self._socket:
|
|
154
|
+
if not self.connect():
|
|
155
|
+
return False
|
|
156
|
+
if not self._server_public_key:
|
|
157
|
+
if not self.change_public_keys():
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Low-level messaging
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def _send_json(self, msg: dict) -> dict | None:
|
|
166
|
+
"""Send a JSON message and read the JSON response."""
|
|
167
|
+
if not self._socket:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
msg["clientID"] = CLIENT_ID
|
|
171
|
+
|
|
172
|
+
data = json.dumps(msg).encode("utf-8")
|
|
173
|
+
try:
|
|
174
|
+
self._socket.sendall(data)
|
|
175
|
+
except OSError as e:
|
|
176
|
+
logger.error("Failed to send message: %s", e)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
self._socket.settimeout(self.config.unlock_timeout)
|
|
181
|
+
response_data = self._socket.recv(1024 * 1024)
|
|
182
|
+
if not response_data:
|
|
183
|
+
return None
|
|
184
|
+
return json.loads(response_data)
|
|
185
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
186
|
+
logger.error("Failed to read response: %s", e)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def _encrypt(self, message: dict, nonce: bytes) -> str:
|
|
190
|
+
"""Encrypt a JSON message using NaCl crypto_box."""
|
|
191
|
+
if not self._server_public_key:
|
|
192
|
+
raise ProtocolError("No server public key (call change_public_keys first)")
|
|
193
|
+
|
|
194
|
+
box = nacl.public.Box(self._secret_key, self._server_public_key)
|
|
195
|
+
plaintext = json.dumps(message).encode("utf-8")
|
|
196
|
+
encrypted = box.encrypt(plaintext, nonce)
|
|
197
|
+
return _b64encode(encrypted.ciphertext)
|
|
198
|
+
|
|
199
|
+
def _decrypt(self, encrypted_b64: str, nonce: bytes) -> dict | None:
|
|
200
|
+
"""Decrypt a NaCl-encrypted response."""
|
|
201
|
+
if not self._server_public_key:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
box = nacl.public.Box(self._secret_key, self._server_public_key)
|
|
205
|
+
try:
|
|
206
|
+
ciphertext = _b64decode(encrypted_b64)
|
|
207
|
+
plaintext = box.decrypt(ciphertext, nonce)
|
|
208
|
+
return json.loads(plaintext)
|
|
209
|
+
except (nacl.exceptions.CryptoError, json.JSONDecodeError, ValueError) as e:
|
|
210
|
+
logger.error("Failed to decrypt message: %s", e)
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
def _send_encrypted(self, action: str, inner: dict, *, timeout: float | None = None) -> dict | None:
|
|
214
|
+
"""Send an encrypted action message and return the decrypted response.
|
|
215
|
+
|
|
216
|
+
Automatically connects and performs key exchange if needed.
|
|
217
|
+
Returns the decrypted inner dict, or None on failure.
|
|
218
|
+
"""
|
|
219
|
+
if not self._ensure_session():
|
|
220
|
+
return None
|
|
221
|
+
nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE)
|
|
222
|
+
encrypted = self._encrypt(inner, nonce)
|
|
223
|
+
msg = {
|
|
224
|
+
"action": action,
|
|
225
|
+
"message": encrypted,
|
|
226
|
+
"nonce": _b64encode(nonce),
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if timeout is not None and self._socket:
|
|
230
|
+
old_timeout = self._socket.gettimeout()
|
|
231
|
+
self._socket.settimeout(timeout)
|
|
232
|
+
|
|
233
|
+
response = self._send_json(msg)
|
|
234
|
+
|
|
235
|
+
if timeout is not None and self._socket:
|
|
236
|
+
self._socket.settimeout(old_timeout)
|
|
237
|
+
|
|
238
|
+
if not response:
|
|
239
|
+
return None
|
|
240
|
+
if "errorCode" in response:
|
|
241
|
+
logger.debug("Error response for %s: %s (code %s)", action, response.get("error"), response.get("errorCode"))
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
resp_nonce_b64 = response.get("nonce", "")
|
|
245
|
+
resp_message = response.get("message", "")
|
|
246
|
+
if not resp_nonce_b64 or not resp_message:
|
|
247
|
+
logger.error("Missing nonce or message in response for %s", action)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
resp_nonce = _b64decode(resp_nonce_b64)
|
|
251
|
+
return self._decrypt(resp_message, resp_nonce)
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
# Protocol: key exchange & association
|
|
255
|
+
# ------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def change_public_keys(self) -> bool:
|
|
258
|
+
"""Perform NaCl key exchange with KeePassXC."""
|
|
259
|
+
nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE)
|
|
260
|
+
msg = {
|
|
261
|
+
"action": "change-public-keys",
|
|
262
|
+
"publicKey": _b64encode(bytes(self._public_key)),
|
|
263
|
+
"nonce": _b64encode(nonce),
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
response = self._send_json(msg)
|
|
267
|
+
if not response:
|
|
268
|
+
logger.error("No response to change-public-keys")
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
if "errorCode" in response:
|
|
272
|
+
logger.error("Key exchange failed: %s", response.get("error"))
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
server_pk_b64 = response.get("publicKey")
|
|
276
|
+
if not server_pk_b64:
|
|
277
|
+
logger.error("No server public key in response")
|
|
278
|
+
return False
|
|
279
|
+
|
|
280
|
+
self._server_public_key = nacl.public.PublicKey(_b64decode(server_pk_b64))
|
|
281
|
+
logger.debug("Key exchange successful")
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
def associate(self) -> Association | None:
|
|
285
|
+
"""Associate with KeePassXC (one-time, requires user approval in KeePassXC).
|
|
286
|
+
|
|
287
|
+
Returns the Association on success, or None on failure.
|
|
288
|
+
"""
|
|
289
|
+
id_key = nacl.public.PrivateKey.generate()
|
|
290
|
+
id_public_key = id_key.public_key
|
|
291
|
+
|
|
292
|
+
nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE)
|
|
293
|
+
inner = {
|
|
294
|
+
"action": "associate",
|
|
295
|
+
"key": _b64encode(bytes(self._public_key)),
|
|
296
|
+
"idKey": _b64encode(bytes(id_public_key)),
|
|
297
|
+
}
|
|
298
|
+
encrypted = self._encrypt(inner, nonce)
|
|
299
|
+
msg = {
|
|
300
|
+
"action": "associate",
|
|
301
|
+
"message": encrypted,
|
|
302
|
+
"nonce": _b64encode(nonce),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
old_timeout = self._socket.gettimeout() if self._socket else 30
|
|
306
|
+
if self._socket:
|
|
307
|
+
self._socket.settimeout(120)
|
|
308
|
+
|
|
309
|
+
response = self._send_json(msg)
|
|
310
|
+
|
|
311
|
+
if self._socket:
|
|
312
|
+
self._socket.settimeout(old_timeout)
|
|
313
|
+
|
|
314
|
+
if not response or "errorCode" in response:
|
|
315
|
+
err = response.get("error") if response else "no response"
|
|
316
|
+
raise AssociationError(f"Association failed: {err}")
|
|
317
|
+
|
|
318
|
+
resp_nonce = _b64decode(response.get("nonce", ""))
|
|
319
|
+
resp_message = response.get("message", "")
|
|
320
|
+
if not resp_message:
|
|
321
|
+
raise AssociationError("No encrypted message in associate response")
|
|
322
|
+
|
|
323
|
+
decrypted = self._decrypt(resp_message, resp_nonce)
|
|
324
|
+
if not decrypted:
|
|
325
|
+
raise AssociationError("Failed to decrypt associate response")
|
|
326
|
+
|
|
327
|
+
assoc_id = decrypted.get("id")
|
|
328
|
+
db_hash = decrypted.get("hash")
|
|
329
|
+
if not assoc_id:
|
|
330
|
+
raise AssociationError("No association ID in response")
|
|
331
|
+
|
|
332
|
+
association = Association(
|
|
333
|
+
id=assoc_id,
|
|
334
|
+
id_key=_b64encode(bytes(id_public_key)),
|
|
335
|
+
key=_b64encode(bytes(id_key)),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if db_hash:
|
|
339
|
+
self.config.associations[db_hash] = association
|
|
340
|
+
|
|
341
|
+
logger.info("Associated with KeePassXC (id=%s)", assoc_id)
|
|
342
|
+
return association
|
|
343
|
+
|
|
344
|
+
def test_associate(self, association: Association) -> bool:
|
|
345
|
+
"""Test if an existing association is still valid."""
|
|
346
|
+
inner = {
|
|
347
|
+
"action": "test-associate",
|
|
348
|
+
"id": association.id,
|
|
349
|
+
"key": association.id_key,
|
|
350
|
+
}
|
|
351
|
+
result = self._send_encrypted("test-associate", inner)
|
|
352
|
+
return result is not None
|
|
353
|
+
|
|
354
|
+
def _get_connection_keys(self) -> list[dict]:
|
|
355
|
+
"""Build the keys array from stored associations for authenticated requests."""
|
|
356
|
+
return [
|
|
357
|
+
{"id": assoc.id, "key": assoc.id_key}
|
|
358
|
+
for assoc in self.config.associations.values()
|
|
359
|
+
]
|
|
360
|
+
|
|
361
|
+
# ------------------------------------------------------------------
|
|
362
|
+
# Protocol: database hash / unlock
|
|
363
|
+
# ------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def _send_get_databasehash(self, trigger_unlock: bool = False) -> dict | None:
|
|
366
|
+
"""Send a get-databasehash request. Returns raw response dict."""
|
|
367
|
+
nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE)
|
|
368
|
+
inner = {"action": "get-databasehash"}
|
|
369
|
+
encrypted = self._encrypt(inner, nonce)
|
|
370
|
+
msg = {
|
|
371
|
+
"action": "get-databasehash",
|
|
372
|
+
"message": encrypted,
|
|
373
|
+
"nonce": _b64encode(nonce),
|
|
374
|
+
}
|
|
375
|
+
if trigger_unlock:
|
|
376
|
+
msg["triggerUnlock"] = "true"
|
|
377
|
+
return self._send_json(msg)
|
|
378
|
+
|
|
379
|
+
def trigger_unlock(self) -> bool:
|
|
380
|
+
"""Trigger KeePassXC database unlock (biometrics/TouchID).
|
|
381
|
+
|
|
382
|
+
Sends get-databasehash with triggerUnlock=true (non-blocking), then
|
|
383
|
+
polls until the DB is unlocked or the timeout expires.
|
|
384
|
+
|
|
385
|
+
Returns True if the database is now unlocked.
|
|
386
|
+
"""
|
|
387
|
+
logger.debug("Sending get-databasehash with triggerUnlock=true")
|
|
388
|
+
response = self._send_get_databasehash(trigger_unlock=True)
|
|
389
|
+
|
|
390
|
+
if not response:
|
|
391
|
+
logger.error("No response to unlock trigger")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
if "errorCode" not in response:
|
|
395
|
+
logger.info("Database was already unlocked")
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
error_code = response.get("errorCode")
|
|
399
|
+
if error_code != "1":
|
|
400
|
+
logger.error("Unlock failed: %s (code %s)", response.get("error"), error_code)
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
logger.info("Unlock dialog triggered, waiting for user to authenticate...")
|
|
404
|
+
deadline = time.monotonic() + self.config.unlock_timeout
|
|
405
|
+
poll_interval = 1.0
|
|
406
|
+
|
|
407
|
+
while time.monotonic() < deadline:
|
|
408
|
+
time.sleep(poll_interval)
|
|
409
|
+
|
|
410
|
+
self.disconnect()
|
|
411
|
+
if not self.connect():
|
|
412
|
+
continue
|
|
413
|
+
if not self.change_public_keys():
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
response = self._send_get_databasehash(trigger_unlock=False)
|
|
417
|
+
if not response:
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
if "errorCode" not in response:
|
|
421
|
+
logger.info("Database unlocked successfully")
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
logger.debug("Still locked, polling...")
|
|
425
|
+
|
|
426
|
+
logger.warning("Timeout waiting for database unlock")
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
def ensure_unlocked(self) -> bool:
|
|
430
|
+
"""Connect and ensure the database is unlocked.
|
|
431
|
+
|
|
432
|
+
Handles the full flow: connect → key exchange → trigger unlock.
|
|
433
|
+
Returns True if the database is now unlocked.
|
|
434
|
+
|
|
435
|
+
Raises NotAssociatedError if no associations are configured.
|
|
436
|
+
"""
|
|
437
|
+
if not self.config.associations:
|
|
438
|
+
raise NotAssociatedError("No associations configured. Run setup() first.")
|
|
439
|
+
|
|
440
|
+
if not self.connect():
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
if not self.change_public_keys():
|
|
445
|
+
return False
|
|
446
|
+
return self.trigger_unlock()
|
|
447
|
+
finally:
|
|
448
|
+
self.disconnect()
|
|
449
|
+
|
|
450
|
+
def setup(self) -> bool:
|
|
451
|
+
"""Perform initial setup: connect, key exchange, and associate.
|
|
452
|
+
|
|
453
|
+
The user must approve the association in the KeePassXC window.
|
|
454
|
+
Returns True on success.
|
|
455
|
+
|
|
456
|
+
Raises AssociationError if the user denies or an error occurs.
|
|
457
|
+
"""
|
|
458
|
+
if not self.connect():
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
if not self.change_public_keys():
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
print("Requesting association with KeePassXC...")
|
|
466
|
+
print("Please approve the association in the KeePassXC window.")
|
|
467
|
+
|
|
468
|
+
association = self.associate()
|
|
469
|
+
if not association:
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
print(f"Association successful! ID: {association.id}")
|
|
473
|
+
return True
|
|
474
|
+
finally:
|
|
475
|
+
self.disconnect()
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------------
|
|
478
|
+
# API: read operations
|
|
479
|
+
# ------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
def get_logins(
|
|
482
|
+
self,
|
|
483
|
+
url: str,
|
|
484
|
+
submit_url: str = "",
|
|
485
|
+
http_auth: bool = False,
|
|
486
|
+
) -> list[Entry]:
|
|
487
|
+
"""Return entries matching the given URL.
|
|
488
|
+
|
|
489
|
+
KeePassXC matches entries whose URL field matches ``url``.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
url: The site URL to look up (e.g. "https://example.com").
|
|
493
|
+
submit_url: Optional form submission URL for more precise matching.
|
|
494
|
+
http_auth: Set True to search HTTP Basic Auth entries.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
List of matching entries.
|
|
498
|
+
"""
|
|
499
|
+
inner: dict = {
|
|
500
|
+
"action": "get-logins",
|
|
501
|
+
"url": url,
|
|
502
|
+
"keys": self._get_connection_keys(),
|
|
503
|
+
}
|
|
504
|
+
if submit_url:
|
|
505
|
+
inner["submitUrl"] = submit_url
|
|
506
|
+
if http_auth:
|
|
507
|
+
inner["httpAuth"] = "true"
|
|
508
|
+
|
|
509
|
+
decrypted = self._send_encrypted("get-logins", inner)
|
|
510
|
+
if not decrypted:
|
|
511
|
+
return []
|
|
512
|
+
|
|
513
|
+
return [Entry.from_dict(e) for e in decrypted.get("entries", [])]
|
|
514
|
+
|
|
515
|
+
def get_database_entries(self) -> list[Entry]:
|
|
516
|
+
"""Return all entries in the database.
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
List of all entries.
|
|
520
|
+
"""
|
|
521
|
+
inner = {
|
|
522
|
+
"action": "get-database-entries",
|
|
523
|
+
"keys": self._get_connection_keys(),
|
|
524
|
+
}
|
|
525
|
+
decrypted = self._send_encrypted("get-database-entries", inner)
|
|
526
|
+
if not decrypted:
|
|
527
|
+
return []
|
|
528
|
+
|
|
529
|
+
return [Entry.from_dict(e) for e in decrypted.get("entries", [])]
|
|
530
|
+
|
|
531
|
+
def get_database_groups(self) -> list[Group]:
|
|
532
|
+
"""Return all groups in the database as a tree.
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
List of root groups; each group has a ``children`` attribute.
|
|
536
|
+
"""
|
|
537
|
+
inner = {"action": "get-database-groups"}
|
|
538
|
+
decrypted = self._send_encrypted("get-database-groups", inner)
|
|
539
|
+
if not decrypted:
|
|
540
|
+
return []
|
|
541
|
+
|
|
542
|
+
groups_data = decrypted.get("groups", {})
|
|
543
|
+
# KeePassXC returns {"groups": {"groups": [...]}}
|
|
544
|
+
if isinstance(groups_data, dict):
|
|
545
|
+
groups_data = groups_data.get("groups", [])
|
|
546
|
+
|
|
547
|
+
return [Group.from_dict(g) for g in groups_data]
|
|
548
|
+
|
|
549
|
+
def get_totp(self, uuid: str) -> str | None:
|
|
550
|
+
"""Return the current TOTP code for an entry.
|
|
551
|
+
|
|
552
|
+
Args:
|
|
553
|
+
uuid: The entry UUID.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
TOTP code string, or None if the entry has no TOTP configured.
|
|
557
|
+
"""
|
|
558
|
+
inner = {
|
|
559
|
+
"action": "get-totp",
|
|
560
|
+
"uuid": uuid,
|
|
561
|
+
}
|
|
562
|
+
decrypted = self._send_encrypted("get-totp", inner)
|
|
563
|
+
if not decrypted:
|
|
564
|
+
return None
|
|
565
|
+
return decrypted.get("totp") or None
|
|
566
|
+
|
|
567
|
+
# ------------------------------------------------------------------
|
|
568
|
+
# API: write operations
|
|
569
|
+
# ------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
def set_login(
|
|
572
|
+
self,
|
|
573
|
+
url: str,
|
|
574
|
+
username: str,
|
|
575
|
+
password: str,
|
|
576
|
+
*,
|
|
577
|
+
title: str = "",
|
|
578
|
+
submit_url: str = "",
|
|
579
|
+
uuid: str = "",
|
|
580
|
+
group: str = "",
|
|
581
|
+
group_uuid: str = "",
|
|
582
|
+
download_favicon: bool = False,
|
|
583
|
+
) -> bool:
|
|
584
|
+
"""Create or update a login entry.
|
|
585
|
+
|
|
586
|
+
Pass ``uuid`` to update an existing entry; omit to create a new one.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
url: The URL to associate with this entry.
|
|
590
|
+
username: The username/login field.
|
|
591
|
+
password: The password field.
|
|
592
|
+
title: Optional entry title (defaults to the URL hostname in KeePassXC).
|
|
593
|
+
submit_url: Optional form submit URL.
|
|
594
|
+
uuid: Existing entry UUID for updates.
|
|
595
|
+
group: Target group name for new entries.
|
|
596
|
+
group_uuid: Target group UUID for new entries.
|
|
597
|
+
download_favicon: Ask KeePassXC to download the site's favicon.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
True on success.
|
|
601
|
+
"""
|
|
602
|
+
inner: dict = {
|
|
603
|
+
"action": "set-login",
|
|
604
|
+
"url": url,
|
|
605
|
+
"login": username,
|
|
606
|
+
"password": password,
|
|
607
|
+
"keys": self._get_connection_keys(),
|
|
608
|
+
}
|
|
609
|
+
if title:
|
|
610
|
+
inner["id"] = title
|
|
611
|
+
if submit_url:
|
|
612
|
+
inner["submitUrl"] = submit_url
|
|
613
|
+
if uuid:
|
|
614
|
+
inner["uuid"] = uuid
|
|
615
|
+
if group:
|
|
616
|
+
inner["group"] = group
|
|
617
|
+
if group_uuid:
|
|
618
|
+
inner["groupUuid"] = group_uuid
|
|
619
|
+
if download_favicon:
|
|
620
|
+
inner["downloadFavicon"] = "true"
|
|
621
|
+
|
|
622
|
+
decrypted = self._send_encrypted("set-login", inner)
|
|
623
|
+
return decrypted is not None
|
|
624
|
+
|
|
625
|
+
def create_group(self, name: str, parent_group_uuid: str = "") -> Group | None:
|
|
626
|
+
"""Create a new group in the database.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
name: Group name.
|
|
630
|
+
parent_group_uuid: UUID of the parent group. If empty, creates at root.
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
The newly created Group, or None on failure.
|
|
634
|
+
"""
|
|
635
|
+
inner: dict = {
|
|
636
|
+
"action": "create-new-group",
|
|
637
|
+
"groupName": name,
|
|
638
|
+
}
|
|
639
|
+
if parent_group_uuid:
|
|
640
|
+
inner["groupUuid"] = parent_group_uuid
|
|
641
|
+
|
|
642
|
+
decrypted = self._send_encrypted("create-new-group", inner)
|
|
643
|
+
if not decrypted:
|
|
644
|
+
return None
|
|
645
|
+
|
|
646
|
+
return Group(
|
|
647
|
+
uuid=decrypted.get("uuid", ""),
|
|
648
|
+
name=decrypted.get("name", name),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
def delete_entry(self, uuid: str) -> bool:
|
|
652
|
+
"""Delete an entry by UUID.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
uuid: The entry UUID to delete.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
True on success.
|
|
659
|
+
"""
|
|
660
|
+
inner = {
|
|
661
|
+
"action": "delete-entry",
|
|
662
|
+
"uuid": uuid,
|
|
663
|
+
}
|
|
664
|
+
decrypted = self._send_encrypted("delete-entry", inner)
|
|
665
|
+
return decrypted is not None
|
|
666
|
+
|
|
667
|
+
def lock_database(self) -> bool:
|
|
668
|
+
"""Lock the KeePassXC database.
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
True if the lock command was accepted.
|
|
672
|
+
"""
|
|
673
|
+
inner = {"action": "lock-database"}
|
|
674
|
+
decrypted = self._send_encrypted("lock-database", inner)
|
|
675
|
+
return decrypted is not None
|
|
676
|
+
|
|
677
|
+
def request_autotype(self, search: str = "") -> bool:
|
|
678
|
+
"""Trigger KeePassXC's global auto-type for the active window.
|
|
679
|
+
|
|
680
|
+
KeePassXC will show an entry picker if multiple matches are found,
|
|
681
|
+
or auto-fill immediately when there is exactly one match.
|
|
682
|
+
|
|
683
|
+
Does not require an existing association.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
search: Optional search string (e.g. domain) to pre-filter entries.
|
|
687
|
+
KeePassXC ignores strings longer than 256 characters.
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
True if KeePassXC accepted the request.
|
|
691
|
+
"""
|
|
692
|
+
inner: dict = {"action": "request-autotype"}
|
|
693
|
+
if search:
|
|
694
|
+
inner["search"] = search
|
|
695
|
+
decrypted = self._send_encrypted("request-autotype", inner)
|
|
696
|
+
return decrypted is not None
|
|
697
|
+
|
|
698
|
+
def generate_password(self) -> str | None:
|
|
699
|
+
"""Ask KeePassXC to generate a password.
|
|
700
|
+
|
|
701
|
+
KeePassXC uses its own configured generator settings; there are no
|
|
702
|
+
client-side parameters — the password profile is configured in the
|
|
703
|
+
KeePassXC application settings.
|
|
704
|
+
|
|
705
|
+
Note: Unlike other actions, the request is sent unencrypted but the
|
|
706
|
+
response is encrypted using the session keys.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
Generated password string, or None on failure.
|
|
710
|
+
"""
|
|
711
|
+
if not self._ensure_session():
|
|
712
|
+
return None
|
|
713
|
+
nonce = nacl.utils.random(nacl.public.Box.NONCE_SIZE)
|
|
714
|
+
msg = {
|
|
715
|
+
"action": "generate-password",
|
|
716
|
+
"nonce": _b64encode(nonce),
|
|
717
|
+
}
|
|
718
|
+
response = self._send_json(msg)
|
|
719
|
+
if not response or "errorCode" in response:
|
|
720
|
+
return None
|
|
721
|
+
|
|
722
|
+
resp_nonce_b64 = response.get("nonce", "")
|
|
723
|
+
resp_message = response.get("message", "")
|
|
724
|
+
if not resp_message or not resp_nonce_b64:
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
resp_nonce = _b64decode(resp_nonce_b64)
|
|
728
|
+
decrypted = self._decrypt(resp_message, resp_nonce)
|
|
729
|
+
if not decrypted:
|
|
730
|
+
return None
|
|
731
|
+
|
|
732
|
+
entries = decrypted.get("entries", [])
|
|
733
|
+
if entries and isinstance(entries, list):
|
|
734
|
+
return entries[0].get("password") or None
|
|
735
|
+
return decrypted.get("password") or None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Persistent configuration for the KeePassXC browser API library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".keepassxc"
|
|
14
|
+
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "browser-api.json"
|
|
15
|
+
|
|
16
|
+
DEFAULT_UNLOCK_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Association:
|
|
23
|
+
"""Stored association with a KeePassXC database."""
|
|
24
|
+
|
|
25
|
+
id: str
|
|
26
|
+
id_key: str # base64-encoded identity public key
|
|
27
|
+
key: str # base64-encoded identity secret key
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {"id": self.id, "id_key": self.id_key, "key": self.key}
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, d: dict) -> Association:
|
|
34
|
+
return cls(id=d["id"], id_key=d["id_key"], key=d["key"])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BrowserConfig:
|
|
39
|
+
"""Configuration for the KeePassXC browser API connection.
|
|
40
|
+
|
|
41
|
+
Shared between keepassxc-cli and keepassxc-ssh-agent so that a single
|
|
42
|
+
association covers both tools.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
unlock_timeout: int = DEFAULT_UNLOCK_TIMEOUT
|
|
46
|
+
# NaCl keypair for browser protocol communication (base64-encoded)
|
|
47
|
+
client_public_key: str = ""
|
|
48
|
+
client_secret_key: str = ""
|
|
49
|
+
# Per-database associations (keyed by database hash)
|
|
50
|
+
associations: dict[str, Association] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"unlock_timeout": self.unlock_timeout,
|
|
55
|
+
"client_public_key": self.client_public_key,
|
|
56
|
+
"client_secret_key": self.client_secret_key,
|
|
57
|
+
"associations": {k: v.to_dict() for k, v in self.associations.items()},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, d: dict) -> BrowserConfig:
|
|
62
|
+
associations = {}
|
|
63
|
+
for k, v in d.get("associations", {}).items():
|
|
64
|
+
associations[k] = Association.from_dict(v)
|
|
65
|
+
return cls(
|
|
66
|
+
unlock_timeout=d.get("unlock_timeout", DEFAULT_UNLOCK_TIMEOUT),
|
|
67
|
+
client_public_key=d.get("client_public_key", ""),
|
|
68
|
+
client_secret_key=d.get("client_secret_key", ""),
|
|
69
|
+
associations=associations,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def save(self, path: Path | None = None) -> None:
|
|
73
|
+
path = path or DEFAULT_CONFIG_PATH
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
os.chmod(str(path.parent), stat.S_IRWXU)
|
|
76
|
+
# Write to a temp file with 0600 permissions, then atomically rename
|
|
77
|
+
# to avoid a race window where secrets are world-readable.
|
|
78
|
+
tmp_path = path.with_suffix(".tmp")
|
|
79
|
+
fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
80
|
+
try:
|
|
81
|
+
with os.fdopen(fd, "w") as f:
|
|
82
|
+
json.dump(self.to_dict(), f, indent=2)
|
|
83
|
+
except BaseException:
|
|
84
|
+
os.unlink(tmp_path)
|
|
85
|
+
raise
|
|
86
|
+
os.replace(tmp_path, path)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def load(cls, path: Path | None = None) -> BrowserConfig:
|
|
90
|
+
path = path or DEFAULT_CONFIG_PATH
|
|
91
|
+
if not path.exists():
|
|
92
|
+
return cls()
|
|
93
|
+
mode = path.stat().st_mode
|
|
94
|
+
if mode & 0o077:
|
|
95
|
+
logger.warning(
|
|
96
|
+
"Config file %s has insecure permissions %o; expected 0600. "
|
|
97
|
+
"Fix with: chmod 600 %s",
|
|
98
|
+
path, mode & 0o777, path,
|
|
99
|
+
)
|
|
100
|
+
with open(path) as f:
|
|
101
|
+
return cls.from_dict(json.load(f))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Custom exceptions for the KeePassXC browser API library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KeePassXCError(Exception):
|
|
7
|
+
"""Base exception for all KeePassXC browser API errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConnectionError(KeePassXCError):
|
|
11
|
+
"""Could not connect to KeePassXC."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AssociationError(KeePassXCError):
|
|
15
|
+
"""Association with KeePassXC failed or was denied."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotAssociatedError(KeePassXCError):
|
|
19
|
+
"""No valid association exists. Run setup() first."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DatabaseLockedError(KeePassXCError):
|
|
23
|
+
"""The KeePassXC database is locked and could not be unlocked."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProtocolError(KeePassXCError):
|
|
27
|
+
"""Unexpected response from KeePassXC (encryption, JSON, protocol errors)."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Data models for KeePassXC browser API responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Entry:
|
|
10
|
+
"""A KeePassXC database entry."""
|
|
11
|
+
|
|
12
|
+
uuid: str
|
|
13
|
+
name: str
|
|
14
|
+
login: str
|
|
15
|
+
password: str
|
|
16
|
+
totp: str = ""
|
|
17
|
+
group: str = ""
|
|
18
|
+
group_uuid: str = ""
|
|
19
|
+
# Additional string fields (e.g. Notes, custom fields)
|
|
20
|
+
string_fields: list[dict[str, str]] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, d: dict) -> Entry:
|
|
24
|
+
return cls(
|
|
25
|
+
uuid=d.get("uuid", ""),
|
|
26
|
+
name=d.get("name", ""),
|
|
27
|
+
login=d.get("login", ""),
|
|
28
|
+
password=d.get("password", ""),
|
|
29
|
+
totp=d.get("totp", ""),
|
|
30
|
+
group=d.get("group", ""),
|
|
31
|
+
group_uuid=d.get("groupUuid", ""),
|
|
32
|
+
string_fields=d.get("stringFields", []),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"uuid": self.uuid,
|
|
38
|
+
"name": self.name,
|
|
39
|
+
"login": self.login,
|
|
40
|
+
"password": self.password,
|
|
41
|
+
"totp": self.totp,
|
|
42
|
+
"group": self.group,
|
|
43
|
+
"groupUuid": self.group_uuid,
|
|
44
|
+
"stringFields": self.string_fields,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Group:
|
|
50
|
+
"""A KeePassXC database group."""
|
|
51
|
+
|
|
52
|
+
uuid: str
|
|
53
|
+
name: str
|
|
54
|
+
children: list[Group] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, d: dict) -> Group:
|
|
58
|
+
children = [Group.from_dict(c) for c in d.get("children", [])]
|
|
59
|
+
return cls(
|
|
60
|
+
uuid=d.get("uuid", ""),
|
|
61
|
+
name=d.get("name", ""),
|
|
62
|
+
children=children,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
return {
|
|
67
|
+
"uuid": self.uuid,
|
|
68
|
+
"name": self.name,
|
|
69
|
+
"children": [c.to_dict() for c in self.children],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def flat_list(self) -> list[Group]:
|
|
73
|
+
"""Return a flat list of this group and all descendants."""
|
|
74
|
+
result = [self]
|
|
75
|
+
for child in self.children:
|
|
76
|
+
result.extend(child.flat_list())
|
|
77
|
+
return result
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepassxc-browser-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for the KeePassXC browser extension protocol
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: PyNaCl==1.6.2
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# KeePassXC Browser API
|
|
16
|
+
|
|
17
|
+
Python library for communicating with [KeePassXC](https://keepassxc.org/) via the browser extension protocol (NaCl-encrypted JSON over a Unix socket).
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- NaCl-encrypted communication with KeePassXC
|
|
22
|
+
- One-time association flow (user approves in KeePassXC window)
|
|
23
|
+
- Biometric unlock (TouchID / system unlock) via `triggerUnlock`
|
|
24
|
+
- Full browser API support: read entries, write entries, manage groups, TOTP, password generation, lock database
|
|
25
|
+
- Cross-platform: macOS and Linux
|
|
26
|
+
- Shared config (`~/.keepassxc/browser-api.json`) — associate once, use with all tools
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```shell
|
|
31
|
+
pip install keepassxc-browser-api
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from keepassxc_browser_api import BrowserClient, BrowserConfig
|
|
38
|
+
|
|
39
|
+
config = BrowserConfig.load()
|
|
40
|
+
client = BrowserClient(config)
|
|
41
|
+
|
|
42
|
+
# First time: associate with KeePassXC (requires user approval)
|
|
43
|
+
if not config.associations:
|
|
44
|
+
client.setup()
|
|
45
|
+
config.save()
|
|
46
|
+
|
|
47
|
+
# Ensure DB is unlocked (triggers TouchID/biometrics if locked)
|
|
48
|
+
client.ensure_unlocked()
|
|
49
|
+
|
|
50
|
+
# API methods auto-connect when needed
|
|
51
|
+
entries = client.get_logins("https://example.com")
|
|
52
|
+
for e in entries:
|
|
53
|
+
print(e.name, e.login)
|
|
54
|
+
|
|
55
|
+
# Clean up when done
|
|
56
|
+
client.disconnect()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or use the context manager for automatic cleanup:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
with BrowserClient(config) as client:
|
|
63
|
+
entries = client.get_logins("https://example.com")
|
|
64
|
+
totp = client.get_totp(entries[0].uuid)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `BrowserClient`
|
|
70
|
+
|
|
71
|
+
| Method | Description |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `setup()` | First-time association (user approves in KeePassXC) |
|
|
74
|
+
| `ensure_unlocked()` | Connect and unlock (triggers TouchID if locked) |
|
|
75
|
+
| `get_logins(url, ...)` | Find entries matching a URL |
|
|
76
|
+
| `set_login(url, username, password, ...)` | Create or update an entry |
|
|
77
|
+
| `get_database_entries()` | Return all entries |
|
|
78
|
+
| `get_database_groups()` | Return all groups (tree) |
|
|
79
|
+
| `create_group(name, parent_uuid)` | Create a new group |
|
|
80
|
+
| `get_totp(uuid)` | Get TOTP code for an entry |
|
|
81
|
+
| `delete_entry(uuid)` | Delete an entry |
|
|
82
|
+
| `lock_database()` | Lock the database |
|
|
83
|
+
| `generate_password()` | Generate a password (uses KeePassXC settings) |
|
|
84
|
+
| `request_autotype(search)` | Trigger KeePassXC global auto-type |
|
|
85
|
+
|
|
86
|
+
> **Note**: `passkeys-get` and `passkeys-register` are not implemented. They require complex WebAuthn/CBOR data structures and are only available in KeePassXC builds compiled with `WITH_XC_BROWSER_PASSKEYS`.
|
|
87
|
+
|
|
88
|
+
### `BrowserConfig`
|
|
89
|
+
|
|
90
|
+
Configuration stored at `~/.keepassxc/browser-api.json` (mode 0600).
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
config = BrowserConfig.load() # Load from default path
|
|
94
|
+
config = BrowserConfig.load(path) # Load from custom path
|
|
95
|
+
config.save() # Save to default path
|
|
96
|
+
config.save(path) # Save to custom path
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Protocol documentation
|
|
100
|
+
|
|
101
|
+
For a detailed description of the KeePassXC browser extension protocol (wire format, encryption, all actions, error codes), see **[PROTOCOL.md](PROTOCOL.md)**.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```shell
|
|
106
|
+
# Install in editable mode with dev dependencies
|
|
107
|
+
pip install -e ".[dev]"
|
|
108
|
+
|
|
109
|
+
# Run tests
|
|
110
|
+
pytest
|
|
111
|
+
|
|
112
|
+
# Run tests with coverage
|
|
113
|
+
pytest --cov=keepassxc_browser_api
|
|
114
|
+
|
|
115
|
+
# Lint
|
|
116
|
+
ruff check --ignore=E501 --exclude=__init__.py ./keepassxc_browser_api
|
|
117
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
keepassxc_browser_api/__init__.py,sha256=xfhR4r7opKml-2d1D9AlOUdFyjVpZJrXIHh8FRHFO_0,568
|
|
2
|
+
keepassxc_browser_api/client.py,sha256=omIcmjhrsm0SZmjb4uuB4zsbhJgDmogs6MOtjYR3Tfw,24789
|
|
3
|
+
keepassxc_browser_api/config.py,sha256=I-wmrgc-oCgcupLDf0vFXefXMDhsg6bQFElAuo9mLag,3369
|
|
4
|
+
keepassxc_browser_api/exceptions.py,sha256=tdzRxxtDzAGYL_M6A6bd5emu_bTp7sLzlroxHb1LVcU,719
|
|
5
|
+
keepassxc_browser_api/models.py,sha256=9qVZuB55Vkae0g76HQrP8Qr0Wdis9euf6axd_Wtxk5Y,2049
|
|
6
|
+
keepassxc_browser_api-0.1.0.dist-info/licenses/LICENSE,sha256=i7l1iI-cTPEtMrRCNzPD2_ZMZV0LQna7gPvYG0lVoCs,1061
|
|
7
|
+
keepassxc_browser_api-0.1.0.dist-info/METADATA,sha256=NG6jAWuorE46RxvAk_D9rGo6Eq9dqu-wKIy1mV7wIt8,3617
|
|
8
|
+
keepassxc_browser_api-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
keepassxc_browser_api-0.1.0.dist-info/top_level.txt,sha256=zu-vCqfTZLbp3CdEEG5TwPqS9wOvo_uP45q9uT8Ir5s,22
|
|
10
|
+
keepassxc_browser_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nils
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepassxc_browser_api
|