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.
@@ -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,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,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