pykeepassgate 0.0.1__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.
- pykeepassgate/README.md +92 -0
- pykeepassgate/__init__.py +57 -0
- pykeepassgate/api.py +96 -0
- pykeepassgate/cli.py +377 -0
- pykeepassgate/client.py +416 -0
- pykeepassgate/credentials.py +323 -0
- pykeepassgate/errors.py +98 -0
- pykeepassgate/model.py +210 -0
- pykeepassgate/pairing.py +261 -0
- pykeepassgate/proofs.py +89 -0
- pykeepassgate/py.typed +0 -0
- pykeepassgate/sni.py +63 -0
- pykeepassgate/token.py +62 -0
- pykeepassgate/transport.py +374 -0
- pykeepassgate-0.0.1.dist-info/METADATA +105 -0
- pykeepassgate-0.0.1.dist-info/RECORD +19 -0
- pykeepassgate-0.0.1.dist-info/WHEEL +4 -0
- pykeepassgate-0.0.1.dist-info/entry_points.txt +2 -0
- pykeepassgate-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""One TLS connection, one request, then closed. The client half of what the gate serves.
|
|
2
|
+
|
|
3
|
+
The gate answers one request per connection and never offers keep-alive, so this does not pretend
|
|
4
|
+
otherwise: a connection is opened, a request is written, an answer is read to the end, and the
|
|
5
|
+
socket is closed. That is also why the name is rebuilt every time.
|
|
6
|
+
|
|
7
|
+
**Why this is written out rather than handed to a library.** The name in the ClientHello rotates
|
|
8
|
+
per connection and is not a host name anybody resolves, so the connection has to be made to an
|
|
9
|
+
address and the name given separately. `http.client` derives the name it sends from the host it
|
|
10
|
+
dials, which is exactly the coupling this protocol breaks. What is left to write is a status line
|
|
11
|
+
and a header block.
|
|
12
|
+
|
|
13
|
+
**Why the server certificate is exposed before the body is written.** During pairing the request
|
|
14
|
+
body carries a proof over ``tls-server-end-point``, which is a hash of the certificate the server
|
|
15
|
+
is serving with. So the handshake has to have happened, and its certificate has to be readable,
|
|
16
|
+
while the body is still being built.
|
|
17
|
+
|
|
18
|
+
**Why one identity is refused before a request reaches it.** A locked database answers with a
|
|
19
|
+
responder certificate, and `CA.md` 208 marks that certificate so a client can refuse to send real
|
|
20
|
+
requests over it. Refusing has to happen before the write rather than after the answer, because a
|
|
21
|
+
request body carries whatever it was going to store.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import json
|
|
28
|
+
import socket
|
|
29
|
+
import ssl
|
|
30
|
+
from collections.abc import Iterator
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from cryptography import x509
|
|
37
|
+
|
|
38
|
+
from .errors import DatabaseLocked, ProtocolError, TransportError
|
|
39
|
+
|
|
40
|
+
#: Where the gate binds, and therefore where a client dials unless it is told otherwise. Reaching
|
|
41
|
+
#: a gate from another machine is a tunnel's job, and a tunnel puts its own end somewhere: that is
|
|
42
|
+
#: the whole of why `connect` takes a host at all, and why the default is this one.
|
|
43
|
+
LOOPBACK = "127.0.0.1"
|
|
44
|
+
|
|
45
|
+
#: How much of an answer to read before giving up on it. This is the only bound there is: the gate
|
|
46
|
+
#: bounds what it accepts, not what it sends, and an attachment answer is as large as whatever the
|
|
47
|
+
#: operator attached in KeePass. So this also decides the largest attachment this client can read,
|
|
48
|
+
#: and a bigger one fails here rather than being truncated.
|
|
49
|
+
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
#: What marks the certificate a locked database answers with, and the whole of what tells one
|
|
52
|
+
#: apart: `AuthorityIssuer.ResponderClaim` on the plugin's side, written into the subject
|
|
53
|
+
#: alternative name as a URI. A responder is minted at unlock and held only in memory, and it can
|
|
54
|
+
#: say nothing but that the database is locked, so a request is never sent over one.
|
|
55
|
+
RESPONDER_CLAIM = "kpg://host/responder"
|
|
56
|
+
|
|
57
|
+
#: How long to wait, in seconds, for anything that is not a pairing.
|
|
58
|
+
DEFAULT_TIMEOUT = 120.0
|
|
59
|
+
|
|
60
|
+
#: What a pairing gets instead, which is the one exchange that waits on a person. It has to outlast
|
|
61
|
+
#: the plugin's own sum of `PairingOptions.ApprovalTimeout` two minutes, plus
|
|
62
|
+
#: `PairingApprovalPrompt.Grace` fifteen seconds, plus `DatabaseWriter.LockTimeout` twenty and
|
|
63
|
+
#: `WriteTimeout` thirty. That sum is 185 seconds, and is why `GateOptions.PairingBudget` is five
|
|
64
|
+
#: minutes. Hanging up early cancels nothing: the invitation is spent and the record is written and
|
|
65
|
+
#: saved regardless, and only the answer is lost, leaving a record for a certificate nobody holds.
|
|
66
|
+
PAIRING_TIMEOUT = 300.0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Response:
|
|
71
|
+
"""What came back from one request."""
|
|
72
|
+
|
|
73
|
+
status: int
|
|
74
|
+
body: str
|
|
75
|
+
|
|
76
|
+
def json(self) -> dict[str, Any]:
|
|
77
|
+
"""The body as an object, or a refusal if it is not one."""
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(self.body)
|
|
80
|
+
except ValueError as error:
|
|
81
|
+
raise ProtocolError(
|
|
82
|
+
f"the gate answered {self.status} with something that is not JSON"
|
|
83
|
+
) from error
|
|
84
|
+
|
|
85
|
+
if not isinstance(parsed, dict):
|
|
86
|
+
raise ProtocolError(f"the gate answered {self.status} with JSON that is not an object")
|
|
87
|
+
|
|
88
|
+
return parsed
|
|
89
|
+
|
|
90
|
+
def error(self) -> str:
|
|
91
|
+
"""The one word the gate refuses with, or an empty string if it did not give one."""
|
|
92
|
+
try:
|
|
93
|
+
named = self.json().get("error")
|
|
94
|
+
except ProtocolError:
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
return named if isinstance(named, str) else ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unpaired_context() -> ssl.SSLContext:
|
|
101
|
+
"""The context for a client that has no anchor yet, which is every client while it pairs.
|
|
102
|
+
|
|
103
|
+
Nothing is verified here and nothing can be: the authority is what pairing is for. The server's
|
|
104
|
+
proof over the certificate in use is what decides, and it is checked after the handshake rather
|
|
105
|
+
than during it. Refusing the certificate here instead would refuse every honest server too.
|
|
106
|
+
"""
|
|
107
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
108
|
+
context.check_hostname = False
|
|
109
|
+
context.verify_mode = ssl.CERT_NONE
|
|
110
|
+
_pin_to_tls13(context)
|
|
111
|
+
return context
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def paired_context(authority: Path, identity: Path) -> ssl.SSLContext:
|
|
115
|
+
"""The context for a client that has been through pairing.
|
|
116
|
+
|
|
117
|
+
The authority handed over at pairing is the only anchor loaded, so a certificate that does not
|
|
118
|
+
chain to it fails the handshake rather than being reported afterwards. That is the difference
|
|
119
|
+
from the plugin's own side of this, where the platform's chain builder had to be told to pin
|
|
120
|
+
and then checked by hand: here the trust store holds one certificate and nothing else.
|
|
121
|
+
|
|
122
|
+
Host name checking is off because the name rotates by design and is not a name anybody
|
|
123
|
+
resolves. What replaces it is the anchor: only the authority that issued this client's own
|
|
124
|
+
certificate can produce a host certificate this will accept.
|
|
125
|
+
"""
|
|
126
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
127
|
+
context.check_hostname = False
|
|
128
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
129
|
+
context.load_verify_locations(cafile=str(authority))
|
|
130
|
+
context.load_cert_chain(certfile=str(identity))
|
|
131
|
+
_pin_to_tls13(context)
|
|
132
|
+
return context
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Connection:
|
|
136
|
+
"""One open connection. Not reusable, because the gate does not reuse them either."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, wrapped: ssl.SSLSocket) -> None:
|
|
139
|
+
"""Take a socket that has already shaken hands.
|
|
140
|
+
|
|
141
|
+
Keeps the certificate it was served with, which the proofs are computed over, and reads
|
|
142
|
+
off it whether the identity that answered is the one a locked database has.
|
|
143
|
+
"""
|
|
144
|
+
self._socket = wrapped
|
|
145
|
+
der = wrapped.getpeercert(binary_form=True)
|
|
146
|
+
if not der:
|
|
147
|
+
raise TransportError("the server sent no certificate")
|
|
148
|
+
|
|
149
|
+
self.server_certificate = der
|
|
150
|
+
self.responder = _responder(der)
|
|
151
|
+
|
|
152
|
+
def send(self, method: str, path: str, body: str = "") -> Response:
|
|
153
|
+
"""Write one request and read the whole answer, unless a responder answered the handshake.
|
|
154
|
+
|
|
155
|
+
Nothing is written to a responder. What a locked database answers is `503 locked` on
|
|
156
|
+
every path and method, decided before it reads anything, so there is no request this
|
|
157
|
+
could carry that would be answered; and an impostor that came by the responder key while
|
|
158
|
+
the database was locked reads nothing but could answer with a fabricated secret. Waiting
|
|
159
|
+
for the answer to decide that has already handed over the request, which on a write is
|
|
160
|
+
the value being stored.
|
|
161
|
+
"""
|
|
162
|
+
if self.responder:
|
|
163
|
+
raise DatabaseLocked(
|
|
164
|
+
"the connection was served with the responder identity, which only a locked "
|
|
165
|
+
"database has, so no request was sent over it"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
_check(method, path)
|
|
169
|
+
payload = body.encode("utf-8")
|
|
170
|
+
head = (
|
|
171
|
+
f"{method} {path} HTTP/1.1\r\n"
|
|
172
|
+
# The name this connection was made under, which is what the gate resolved it by. It is
|
|
173
|
+
# sent because HTTP/1.1 requires a `Host`, not because the gate reads it.
|
|
174
|
+
f"Host: {self._socket.server_hostname}\r\n"
|
|
175
|
+
"Content-Type: application/json\r\n"
|
|
176
|
+
f"Content-Length: {len(payload)}\r\n"
|
|
177
|
+
"\r\n"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
self._socket.sendall(head.encode("ascii") + payload)
|
|
182
|
+
raw = self._read_all()
|
|
183
|
+
except (OSError, ssl.SSLError) as error:
|
|
184
|
+
raise TransportError(f"the connection failed: {error}") from error
|
|
185
|
+
|
|
186
|
+
return _parse(raw)
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
"""Close the socket, whatever state it is in."""
|
|
190
|
+
# Suppressed: a socket that is already broken is what this method is for.
|
|
191
|
+
with contextlib.suppress(OSError):
|
|
192
|
+
self._socket.close()
|
|
193
|
+
|
|
194
|
+
def _read_all(self) -> bytes:
|
|
195
|
+
"""Read until the answer is whole, which is what it said rather than when it ends.
|
|
196
|
+
|
|
197
|
+
The gate frames every response and closes after it, so reading to the close gives the
|
|
198
|
+
right answer for one that completed. It gives the wrong one for everything else: a
|
|
199
|
+
response cut short reads as a complete one, and a peer that frames correctly and does
|
|
200
|
+
not close costs the whole timeout and then has its answer thrown away. Neither peer
|
|
201
|
+
has to be the gate, because during pairing this client has no anchor and verifies
|
|
202
|
+
nothing.
|
|
203
|
+
"""
|
|
204
|
+
buffer = bytearray()
|
|
205
|
+
declared: int | None = None
|
|
206
|
+
body_at = -1
|
|
207
|
+
|
|
208
|
+
while True:
|
|
209
|
+
chunk = self._socket.recv(65536)
|
|
210
|
+
if not chunk:
|
|
211
|
+
# Nothing declared a length, or the peer stopped early. `_parse` says which.
|
|
212
|
+
return bytes(buffer)
|
|
213
|
+
|
|
214
|
+
buffer += chunk
|
|
215
|
+
|
|
216
|
+
# Checked after the append rather than before it. Before, the loop enters `recv` with
|
|
217
|
+
# the buffer already at the cap, so a peer feeding it gets one whole chunk past the
|
|
218
|
+
# number this is here to enforce.
|
|
219
|
+
if len(buffer) > MAX_RESPONSE_BYTES:
|
|
220
|
+
raise TransportError(f"the answer is over {MAX_RESPONSE_BYTES} bytes")
|
|
221
|
+
|
|
222
|
+
if body_at < 0:
|
|
223
|
+
blank = buffer.find(b"\r\n\r\n")
|
|
224
|
+
if blank < 0:
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
body_at = blank + 4
|
|
228
|
+
declared = _declared_length(bytes(buffer[:blank]).decode("ascii", "replace"))
|
|
229
|
+
|
|
230
|
+
if declared is not None and len(buffer) - body_at >= declared:
|
|
231
|
+
return bytes(buffer)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@contextmanager
|
|
235
|
+
def connect(
|
|
236
|
+
port: int,
|
|
237
|
+
context: ssl.SSLContext,
|
|
238
|
+
server_name: str,
|
|
239
|
+
host: str = LOOPBACK,
|
|
240
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
241
|
+
) -> Iterator[Connection]:
|
|
242
|
+
"""Open one connection, hand it over, and close it however this ends."""
|
|
243
|
+
try:
|
|
244
|
+
raw = socket.create_connection((host, port), timeout=timeout)
|
|
245
|
+
except OSError as error:
|
|
246
|
+
raise TransportError(f"nothing is listening on {host}:{port}: {error}") from error
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
wrapped = context.wrap_socket(raw, server_hostname=server_name)
|
|
250
|
+
except (OSError, ssl.SSLError) as error:
|
|
251
|
+
raw.close()
|
|
252
|
+
# A handshake that fails against a paired context is the interesting one: it means the
|
|
253
|
+
# certificate did not chain to the authority this client pinned at pairing.
|
|
254
|
+
raise TransportError(f"the handshake failed: {error}") from error
|
|
255
|
+
|
|
256
|
+
connection = Connection(wrapped)
|
|
257
|
+
try:
|
|
258
|
+
yield connection
|
|
259
|
+
finally:
|
|
260
|
+
connection.close()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _responder(der: bytes) -> bool:
|
|
264
|
+
"""Whether this certificate is the one a locked database answers with.
|
|
265
|
+
|
|
266
|
+
Read off the marker rather than off the subject name, because the subject is a convention and
|
|
267
|
+
`CA.md` 208 asks for something a client can decide on. The marker cannot be put on a certificate
|
|
268
|
+
or taken off one either: what the authority signed is what arrives, and a paired client accepts
|
|
269
|
+
nothing the authority did not sign.
|
|
270
|
+
|
|
271
|
+
An encoding this cannot read is not a responder, which is the direction that keeps an honest
|
|
272
|
+
connection working: a certificate the authority issued is one this reads, and one it did not
|
|
273
|
+
issue never gets past the pin.
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
certificate = x509.load_der_x509_certificate(der)
|
|
277
|
+
names = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
|
278
|
+
# Everything reading a certificate can refuse with: bytes that are not one, no subject
|
|
279
|
+
# alternative name at all, two of them, and a name of a kind this library will not decode.
|
|
280
|
+
except (
|
|
281
|
+
ValueError,
|
|
282
|
+
x509.ExtensionNotFound,
|
|
283
|
+
x509.DuplicateExtension,
|
|
284
|
+
x509.UnsupportedGeneralNameType,
|
|
285
|
+
):
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
return RESPONDER_CLAIM in names.value.get_values_for_type(x509.UniformResourceIdentifier)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _pin_to_tls13(context: ssl.SSLContext) -> None:
|
|
292
|
+
"""Nothing older is offered, so nothing older can be negotiated.
|
|
293
|
+
|
|
294
|
+
The gate serves TLS 1.3 only. Pinning both ends of the range rather than the minimum means a
|
|
295
|
+
downgrade is a failed handshake here rather than a connection this client did not intend to
|
|
296
|
+
make.
|
|
297
|
+
"""
|
|
298
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
299
|
+
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _check(method: str, path: str) -> None:
|
|
303
|
+
"""Refuse anything that could not be a request line, before it reaches the wire.
|
|
304
|
+
|
|
305
|
+
The gate reads a head as US-ASCII and refuses a byte that is not, so a path this client cannot
|
|
306
|
+
spell is refused here rather than sent. Two things go wrong otherwise, and neither is the
|
|
307
|
+
caller's fault to discover on the wire. A carriage return or a newline in a path
|
|
308
|
+
replaces the whole head the gate parses, headers and all, with whatever the caller wrote;
|
|
309
|
+
and a character outside ASCII raises `UnicodeEncodeError` out of `encode`, which is a
|
|
310
|
+
`ValueError` and so is not a `GateError` at all.
|
|
311
|
+
|
|
312
|
+
`send` is the documented way to reach a route this client has no method for, so its arguments
|
|
313
|
+
are caller data, and the threat model says a caller can be prompt-injected.
|
|
314
|
+
"""
|
|
315
|
+
if not method.isascii() or not method.isalpha():
|
|
316
|
+
raise ProtocolError(f"that is not a method: {method!r}")
|
|
317
|
+
|
|
318
|
+
if not path.startswith("/") or not path.isascii():
|
|
319
|
+
raise ProtocolError(f"that is not a path this client will send: {path!r}")
|
|
320
|
+
|
|
321
|
+
if any(character in path for character in " \r\n\t\x00"):
|
|
322
|
+
raise ProtocolError(f"that path holds something a request line cannot: {path!r}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _declared_length(head: str) -> int | None:
|
|
326
|
+
"""What the answer said its body would be, if it said."""
|
|
327
|
+
for line in head.split("\r\n")[1:]:
|
|
328
|
+
name, _, value = line.partition(":")
|
|
329
|
+
if name.strip().lower() != "content-length":
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
return int(value.strip())
|
|
334
|
+
except ValueError:
|
|
335
|
+
raise ProtocolError(f"that is not a content length: {value.strip()!r}") from None
|
|
336
|
+
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _parse(raw: bytes) -> Response:
|
|
341
|
+
"""Read the status line and the body, and nothing else.
|
|
342
|
+
|
|
343
|
+
The gate sends a `Content-Length` and closes, so the body is what follows the blank line. There
|
|
344
|
+
is no chunked encoding to handle because the gate refuses to produce one.
|
|
345
|
+
|
|
346
|
+
**The length is read rather than assumed.** Believing the close instead makes a truncated
|
|
347
|
+
answer indistinguishable from a complete one, and an answer whose body never arrived would
|
|
348
|
+
become an empty object. A caller cannot tell that from a gate that said so.
|
|
349
|
+
"""
|
|
350
|
+
if not raw:
|
|
351
|
+
raise TransportError("the connection was closed without an answer")
|
|
352
|
+
|
|
353
|
+
blank = raw.find(b"\r\n\r\n")
|
|
354
|
+
if blank < 0:
|
|
355
|
+
raise ProtocolError("the answer has no header block")
|
|
356
|
+
|
|
357
|
+
line = raw[: raw.find(b"\r\n")].decode("ascii", "replace")
|
|
358
|
+
parts = line.split(" ", 2)
|
|
359
|
+
if len(parts) < 2 or not parts[0].startswith("HTTP/"):
|
|
360
|
+
raise ProtocolError(f"that is not a status line: {line!r}")
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
status = int(parts[1])
|
|
364
|
+
except ValueError as error:
|
|
365
|
+
raise ProtocolError(f"that is not a status: {parts[1]!r}") from error
|
|
366
|
+
|
|
367
|
+
body = raw[blank + 4 :]
|
|
368
|
+
declared = _declared_length(raw[:blank].decode("ascii", "replace"))
|
|
369
|
+
if declared is not None and len(body) < declared:
|
|
370
|
+
raise TransportError(
|
|
371
|
+
f"the answer stopped {declared - len(body)} bytes short of its own content length"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return Response(status=status, body=body.decode("utf-8", "replace"))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pykeepassgate
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: The reference client for KeePassGate: pairing, and everything the gate serves.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mfrnd/KeePassGate
|
|
6
|
+
Author: KeePassGate contributors
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: keepass,keepassgate,mtls,password-manager
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# PyKeePassGate
|
|
15
|
+
|
|
16
|
+
The reference client for [KeePassGate](https://github.com/mfrnd/KeePassGate), a KeePass 2.x plugin
|
|
17
|
+
that serves a small specification-defined API over mutual TLS so that automation and AI agents can
|
|
18
|
+
each be given a narrow, revocable, audited slice of a database.
|
|
19
|
+
|
|
20
|
+
**Reference implementation** means the code here is the second copy of the specification. Where the
|
|
21
|
+
plugin and this disagree, one of them is wrong and a test should already have said so. It also means
|
|
22
|
+
what is missing is a statement: a route with no method here is a route the gate does not serve.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```shell
|
|
27
|
+
uv add pykeepassgate
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
One dependency, `cryptography`, because the gate hands over a PKCS#12 and Python's `ssl` reads PEM.
|
|
31
|
+
Everything else, the command line included, is the standard library.
|
|
32
|
+
|
|
33
|
+
## Pair
|
|
34
|
+
|
|
35
|
+
The operator presses **Renew invite** on the **KPG** tab of the database's settings dialog and sends
|
|
36
|
+
the invitation over a second channel. What they see while doing it is
|
|
37
|
+
[`docs/pairing.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pairing.md), and worked examples of everything below are
|
|
38
|
+
[`docs/pykeepassgate.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pykeepassgate.md). It is spent by the first client to use it
|
|
39
|
+
and it does not survive KeePass closing.
|
|
40
|
+
|
|
41
|
+
```shell
|
|
42
|
+
pykeepassgate pair --port 45678 --name agent-invented --profile-name reader
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Leave `--invitation` off and the token is read from standard input, so it stays out of the shell
|
|
46
|
+
history and out of the process list.
|
|
47
|
+
|
|
48
|
+
The same thing from a program:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from pykeepassgate import api
|
|
52
|
+
|
|
53
|
+
credentials = api.pair(
|
|
54
|
+
port=45678,
|
|
55
|
+
invitation="...",
|
|
56
|
+
name="agent-invented",
|
|
57
|
+
profiles=["reader"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print(credentials.profiles) # what the operator approved, not what was asked for
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Use
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from pykeepassgate import GateClient
|
|
67
|
+
|
|
68
|
+
client = GateClient.open()
|
|
69
|
+
print(client.status())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```shell
|
|
73
|
+
pykeepassgate status
|
|
74
|
+
pykeepassgate whoami
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## What to catch
|
|
78
|
+
|
|
79
|
+
Every exception says what to do next rather than what went wrong, because that is the part a caller
|
|
80
|
+
has to decide.
|
|
81
|
+
|
|
82
|
+
| Exception | What it means, and what to do |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `DatabaseLocked` | The database is locked. Wait, and try again. |
|
|
85
|
+
| `GateBusy` | The gate cannot write right now, usually because a group dialog is open. Retry. |
|
|
86
|
+
| `NotAdmitted` | This database holds no record for this certificate. It was never admitted, or it was revoked. Pair again. |
|
|
87
|
+
| `ProofRefused` | During pairing: the token is wrong, or whatever answered is not the KeePass whose operator holds it. Stop. |
|
|
88
|
+
| `PairingRefused` | The operator said no. The invitation is spent, so ask for another. |
|
|
89
|
+
| `PairingUnanswered` | Nobody answered the dialog. The invitation is **not** spent, so this can be tried again. |
|
|
90
|
+
| `RouteRefused` | Everything else the gate refuses, carrying the status and the one word it used. A `404` is deliberately both "no such node" and "not yours", so a client cannot map a database by asking: if the node should be there, the rule is too narrow and the operator has to widen it. |
|
|
91
|
+
| `MalformedToken` | The text handed over is not an invitation, so nothing was sent. Check what was pasted. |
|
|
92
|
+
| `ProtocolError` | Something on the wire is not what the protocol allows: an answer this client cannot read, or a request it will not send. Do not retry an answer it cannot read: the two halves disagree about the wire, which `tests/pykeepassgate/test_protocol.py` exists to stop. |
|
|
93
|
+
| `StorageError` | What is stored for this client will not read, or could not be written. Look at the directory `status` names. |
|
|
94
|
+
| `TransportError` | Nothing is listening, or the handshake failed. |
|
|
95
|
+
|
|
96
|
+
## What it does not do
|
|
97
|
+
|
|
98
|
+
**It does not open a `.kdbx`.** There is no file and no master password on this side of the API, by
|
|
99
|
+
design. What that means for anybody expecting a `pykeepass` object is measured on the
|
|
100
|
+
`spikes/pykeepass-compatibility` branch: 23 of its 56 public members are servable over the routes the
|
|
101
|
+
gate already has, and the object itself is not one of them.
|
|
102
|
+
|
|
103
|
+
**It defends nothing against local code execution as the user.** The client's key is on the disk,
|
|
104
|
+
because a client has to still be the same client tomorrow. The whole design concedes this
|
|
105
|
+
throughout, and so does this.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
pykeepassgate/README.md,sha256=SPDCtvhZqnHBGBcVt3BNCkuGXfeaiWUVT4VNs2HUF-4,4254
|
|
2
|
+
pykeepassgate/__init__.py,sha256=x1zpRBcAMb_qeFxJ9oLCvogiZgJ3cRKifEITxa1V3mU,1438
|
|
3
|
+
pykeepassgate/api.py,sha256=4AeKOw5oX2LnJDXDlB-dgGj8u_wbU8Uhht_bZSWB6Is,3051
|
|
4
|
+
pykeepassgate/cli.py,sha256=1TzHjlgCjv3ub1xrF-9NnGufcA4RNrbeOTjPhOJnvLc,13574
|
|
5
|
+
pykeepassgate/client.py,sha256=j_yledBdVyGATOeX0CwvVX5wN_ujmqlt7IzQav5O9tw,17653
|
|
6
|
+
pykeepassgate/credentials.py,sha256=6Kj11E0fPxg_qpy8GKcESzcIOqu52eQ07RCeEnr_9nc,14414
|
|
7
|
+
pykeepassgate/errors.py,sha256=XEde6BjJ6UcfimnXr48DNIxledKOZ8bjxmzZAmWTxog,3606
|
|
8
|
+
pykeepassgate/model.py,sha256=UmaiXpn5Lqd4kd7DFUmCyI9T1d2F0Q2weRjHGvkcm24,7744
|
|
9
|
+
pykeepassgate/pairing.py,sha256=Sg1WUdWPxL5tgdIfxczzIEgMyjABFZOvTRCZ7awBMng,11465
|
|
10
|
+
pykeepassgate/proofs.py,sha256=xyYZkYSMmoEeHKSkQYd325K9aGEPF5Uhy5rnEKI0xbM,3946
|
|
11
|
+
pykeepassgate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
pykeepassgate/sni.py,sha256=UskduO3PJEPwIG3-MSgxBw0Vk9iHwOY2BNMKibqtgIk,2763
|
|
13
|
+
pykeepassgate/token.py,sha256=gfw5DpGKGL7A0J_nYqSmc97ZHNDyB1UuQ3HUsibgWrs,2625
|
|
14
|
+
pykeepassgate/transport.py,sha256=4TH14Tvf5GoaHj3c7Evr0eUJt4DExLBLVwODdRgb3PU,16786
|
|
15
|
+
pykeepassgate-0.0.1.dist-info/METADATA,sha256=M3uBt76vedflEwYHNEEayec3oSk2pPhgQ5uR5_9H63w,4676
|
|
16
|
+
pykeepassgate-0.0.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
17
|
+
pykeepassgate-0.0.1.dist-info/entry_points.txt,sha256=So3UZISPwK4VzDVOvRbVz7clsX6kYUUoJqiKZmTZ0uQ,57
|
|
18
|
+
pykeepassgate-0.0.1.dist-info/licenses/LICENSE,sha256=uCertC9NeOzBTVLtAfAgtD9HznOtGxAdSD711DiCMhs,1091
|
|
19
|
+
pykeepassgate-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martin Freund
|
|
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.
|