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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pykeepassgate = pykeepassgate.cli:main
@@ -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.