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,92 @@
1
+ # PyKeePassGate
2
+
3
+ The reference client for [KeePassGate](https://github.com/mfrnd/KeePassGate), a KeePass 2.x plugin
4
+ that serves a small specification-defined API over mutual TLS so that automation and AI agents can
5
+ each be given a narrow, revocable, audited slice of a database.
6
+
7
+ **Reference implementation** means the code here is the second copy of the specification. Where the
8
+ plugin and this disagree, one of them is wrong and a test should already have said so. It also means
9
+ what is missing is a statement: a route with no method here is a route the gate does not serve.
10
+
11
+ ## Install
12
+
13
+ ```shell
14
+ uv add pykeepassgate
15
+ ```
16
+
17
+ One dependency, `cryptography`, because the gate hands over a PKCS#12 and Python's `ssl` reads PEM.
18
+ Everything else, the command line included, is the standard library.
19
+
20
+ ## Pair
21
+
22
+ The operator presses **Renew invite** on the **KPG** tab of the database's settings dialog and sends
23
+ the invitation over a second channel. What they see while doing it is
24
+ [`docs/pairing.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pairing.md), and worked examples of everything below are
25
+ [`docs/pykeepassgate.md`](https://github.com/mfrnd/KeePassGate/blob/master/docs/pykeepassgate.md). It is spent by the first client to use it
26
+ and it does not survive KeePass closing.
27
+
28
+ ```shell
29
+ pykeepassgate pair --port 45678 --name agent-invented --profile-name reader
30
+ ```
31
+
32
+ Leave `--invitation` off and the token is read from standard input, so it stays out of the shell
33
+ history and out of the process list.
34
+
35
+ The same thing from a program:
36
+
37
+ ```python
38
+ from pykeepassgate import api
39
+
40
+ credentials = api.pair(
41
+ port=45678,
42
+ invitation="...",
43
+ name="agent-invented",
44
+ profiles=["reader"],
45
+ )
46
+
47
+ print(credentials.profiles) # what the operator approved, not what was asked for
48
+ ```
49
+
50
+ ## Use
51
+
52
+ ```python
53
+ from pykeepassgate import GateClient
54
+
55
+ client = GateClient.open()
56
+ print(client.status())
57
+ ```
58
+
59
+ ```shell
60
+ pykeepassgate status
61
+ pykeepassgate whoami
62
+ ```
63
+
64
+ ## What to catch
65
+
66
+ Every exception says what to do next rather than what went wrong, because that is the part a caller
67
+ has to decide.
68
+
69
+ | Exception | What it means, and what to do |
70
+ | --- | --- |
71
+ | `DatabaseLocked` | The database is locked. Wait, and try again. |
72
+ | `GateBusy` | The gate cannot write right now, usually because a group dialog is open. Retry. |
73
+ | `NotAdmitted` | This database holds no record for this certificate. It was never admitted, or it was revoked. Pair again. |
74
+ | `ProofRefused` | During pairing: the token is wrong, or whatever answered is not the KeePass whose operator holds it. Stop. |
75
+ | `PairingRefused` | The operator said no. The invitation is spent, so ask for another. |
76
+ | `PairingUnanswered` | Nobody answered the dialog. The invitation is **not** spent, so this can be tried again. |
77
+ | `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. |
78
+ | `MalformedToken` | The text handed over is not an invitation, so nothing was sent. Check what was pasted. |
79
+ | `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. |
80
+ | `StorageError` | What is stored for this client will not read, or could not be written. Look at the directory `status` names. |
81
+ | `TransportError` | Nothing is listening, or the handshake failed. |
82
+
83
+ ## What it does not do
84
+
85
+ **It does not open a `.kdbx`.** There is no file and no master password on this side of the API, by
86
+ design. What that means for anybody expecting a `pykeepass` object is measured on the
87
+ `spikes/pykeepass-compatibility` branch: 23 of its 56 public members are servable over the routes the
88
+ gate already has, and the object itself is not one of them.
89
+
90
+ **It defends nothing against local code execution as the user.** The client's key is on the disk,
91
+ because a client has to still be the same client tomorrow. The whole design concedes this
92
+ throughout, and so does this.
@@ -0,0 +1,57 @@
1
+ """PyKeePassGate: the reference client for KeePassGate.
2
+
3
+ Two halves, and the split is deliberate. `pykeepassgate.api` is the library: pairing, the paired
4
+ client, and the pieces of the protocol on their own so that each can be read and tested without a
5
+ KeePass in the room. `pykeepassgate.cli` is a command line built on that library and nothing else,
6
+ so anything the command line can do, a program can do the same way.
7
+
8
+ Being the reference implementation means the code is the second copy of the specification, so what
9
+ it does not do is a statement too: a route with no method here is a route the gate does not serve.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .api import (
15
+ Credentials,
16
+ DatabaseLocked,
17
+ Exchange,
18
+ GateBusy,
19
+ GateClient,
20
+ GateError,
21
+ MalformedToken,
22
+ NotAdmitted,
23
+ Pairing,
24
+ PairingError,
25
+ PairingRefused,
26
+ PairingUnanswered,
27
+ ProofRefused,
28
+ ProtocolError,
29
+ RouteRefused,
30
+ Status,
31
+ TransportError,
32
+ pair,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ "Credentials",
39
+ "DatabaseLocked",
40
+ "Exchange",
41
+ "GateBusy",
42
+ "GateClient",
43
+ "GateError",
44
+ "MalformedToken",
45
+ "NotAdmitted",
46
+ "Pairing",
47
+ "PairingError",
48
+ "PairingRefused",
49
+ "PairingUnanswered",
50
+ "ProofRefused",
51
+ "ProtocolError",
52
+ "RouteRefused",
53
+ "Status",
54
+ "TransportError",
55
+ "__version__",
56
+ "pair",
57
+ ]
pykeepassgate/api.py ADDED
@@ -0,0 +1,96 @@
1
+ """The library, gathered into one name so that a program imports one thing.
2
+
3
+ Everything a caller needs is re-exported here: pairing, the paired client, the credentials on disk,
4
+ and the exceptions that say what to do next. The modules underneath stay importable, because the
5
+ pieces of the protocol are worth reading on their own, but nothing outside this package has to know
6
+ which module a name lives in.
7
+
8
+ The one thing `api` adds rather than re-exports is `pair`, which is the whole exchange as one call.
9
+ It exists because the two-connection shape is the protocol's business rather than the caller's, and
10
+ a reference implementation that made every caller drive both halves by hand would be teaching the
11
+ wrong lesson.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from .client import ROOT, GateClient, Status
19
+ from .credentials import Credentials, default_directory, load, save
20
+ from .errors import (
21
+ DatabaseLocked,
22
+ GateBusy,
23
+ GateError,
24
+ MalformedToken,
25
+ NotAdmitted,
26
+ PairingError,
27
+ PairingRefused,
28
+ PairingUnanswered,
29
+ ProofRefused,
30
+ ProtocolError,
31
+ RouteRefused,
32
+ TransportError,
33
+ )
34
+ from .model import Attachment, Entry, Field, Group
35
+ from .pairing import Exchange, Pairing
36
+ from .transport import DEFAULT_TIMEOUT, LOOPBACK, PAIRING_TIMEOUT
37
+
38
+ __all__ = [
39
+ "DEFAULT_TIMEOUT",
40
+ "LOOPBACK",
41
+ "PAIRING_TIMEOUT",
42
+ "ROOT",
43
+ "Attachment",
44
+ "Credentials",
45
+ "DatabaseLocked",
46
+ "Entry",
47
+ "Exchange",
48
+ "Field",
49
+ "GateBusy",
50
+ "GateClient",
51
+ "GateError",
52
+ "Group",
53
+ "MalformedToken",
54
+ "NotAdmitted",
55
+ "Pairing",
56
+ "PairingError",
57
+ "PairingRefused",
58
+ "PairingUnanswered",
59
+ "ProofRefused",
60
+ "ProtocolError",
61
+ "RouteRefused",
62
+ "Status",
63
+ "TransportError",
64
+ "default_directory",
65
+ "load",
66
+ "pair",
67
+ "save",
68
+ ]
69
+
70
+
71
+ def pair(
72
+ port: int,
73
+ invitation: str,
74
+ name: str,
75
+ profiles: list[str] | None = None,
76
+ directory: Path | None = None,
77
+ profile: str = "default",
78
+ # Not `DEFAULT_TIMEOUT`. This is the one exchange that waits on a person, and the gate
79
+ # gives itself five minutes for it because its own worst case is 185 seconds. Hanging up
80
+ # first cancels nothing: the invitation is spent and the record is written and saved
81
+ # anyway, and only the answer is lost.
82
+ timeout: float = PAIRING_TIMEOUT,
83
+ host: str = LOOPBACK,
84
+ ) -> Credentials:
85
+ """Pair with a KeePass and keep what it hands over.
86
+
87
+ Blocks while the operator reads the dialog, which is the slow half and is meant to be. What
88
+ comes back says which profiles were actually approved, and that is not always what was asked
89
+ for: the dialog can take any of them away.
90
+ """
91
+ exchange = Pairing(port, invitation, timeout=timeout, host=host)
92
+ started = exchange.hello()
93
+ bundle = exchange.request(started, name, profiles or [])
94
+
95
+ where = directory or default_directory(profile)
96
+ return save(where, port, bundle)
pykeepassgate/cli.py ADDED
@@ -0,0 +1,377 @@
1
+ """The command line, which does nothing the library cannot.
2
+
3
+ Every command here is a few lines over `pykeepassgate.api`, deliberately: if the command line could
4
+ do something a program could not, the library would be the wrong shape. So this parses arguments,
5
+ calls one function, and prints what came back.
6
+
7
+ **It never prints a credential.** The invitation is read from an argument or from standard
8
+ input, and what is printed afterwards is what the credentials are, not what they hold. A
9
+ protected field is a different thing: `show` prints one when the gate answers with it, which
10
+ is when the rule granted `P`, and prints `(withheld)` when it did not.
11
+
12
+ Exit codes are the point of a command line: `0` worked, `1` the gate refused or could not be
13
+ reached, `2` the arguments were wrong. A script can branch on that without reading English.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import api
23
+ from .errors import GateError
24
+
25
+ #: Everything went wrong that this program knows how to be wrong about.
26
+ FAILED = 1
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ """Run one command and return what the shell should see."""
31
+ parser = _parser()
32
+ arguments = parser.parse_args(argv)
33
+
34
+ try:
35
+ return int(arguments.run(arguments))
36
+ except GateError as error:
37
+ # The exception hierarchy already says what happened in a sentence a person can act on, so
38
+ # the command line adds nothing to it beyond the class name for anybody grepping.
39
+ print(f"{type(error).__name__}: {error}", file=sys.stderr)
40
+ return FAILED
41
+ except FileNotFoundError as error:
42
+ print(str(error), file=sys.stderr)
43
+ return FAILED
44
+
45
+
46
+ def _parser() -> argparse.ArgumentParser:
47
+ parser = argparse.ArgumentParser(
48
+ prog="pykeepassgate",
49
+ description="The reference client for KeePassGate.",
50
+ )
51
+
52
+ commands = parser.add_subparsers(dest="command", required=True)
53
+
54
+ pairing = commands.add_parser(
55
+ "pair",
56
+ help="become a client of a database, using an invitation from the KPG tab",
57
+ description=(
58
+ "Pairs with a KeePass over an invitation. The operator has to approve it on screen, "
59
+ "so this waits, and what it stores is what they approved rather than what was asked."
60
+ ),
61
+ )
62
+
63
+ pairing.add_argument(
64
+ "--port", type=int, required=True, help="the port the gate is listening on"
65
+ )
66
+ pairing.add_argument("--name", required=True, help="what this client should be called")
67
+ pairing.add_argument(
68
+ "--profile-name",
69
+ action="append",
70
+ default=[],
71
+ dest="profiles",
72
+ metavar="PROFILE",
73
+ help="a profile to ask for; repeat for more, and the operator may take any of them away",
74
+ )
75
+
76
+ pairing.add_argument(
77
+ "--invitation",
78
+ help="the invitation from the tab. Omit it and it is read from standard input instead.",
79
+ )
80
+
81
+ _shared(pairing)
82
+ _dialled(pairing)
83
+ pairing.set_defaults(run=_pair)
84
+
85
+ status = commands.add_parser(
86
+ "status",
87
+ help="ask the gate whether it is listening, unlocked, and still admitting this client",
88
+ )
89
+
90
+ _shared(status)
91
+ _dialled(status)
92
+ status.set_defaults(run=_status)
93
+
94
+ who = commands.add_parser("whoami", help="say what this client is, without connecting")
95
+ _shared(who)
96
+ who.set_defaults(run=_whoami)
97
+
98
+ groups = commands.add_parser(
99
+ "groups",
100
+ help="list the groups this client may see",
101
+ description=(
102
+ "Anchored at the root, so it needs `list` there. Name a group to walk one level "
103
+ "instead, which is a different question and a different grant."
104
+ ),
105
+ )
106
+
107
+ groups.add_argument("--under", help="a group identifier, or root, to list one level below")
108
+ _shared(groups)
109
+ _dialled(groups)
110
+ groups.set_defaults(run=_groups)
111
+
112
+ entries = commands.add_parser(
113
+ "entries",
114
+ help="list the entries this client may see",
115
+ )
116
+
117
+ entries.add_argument(
118
+ "--group",
119
+ help="a group identifier, or root, for that group's own entries",
120
+ )
121
+ _shared(entries)
122
+ _dialled(entries)
123
+ entries.set_defaults(run=_entries)
124
+
125
+ show = commands.add_parser(
126
+ "show",
127
+ help="read one entry, secrets included",
128
+ description=(
129
+ "Prints every string the entry holds. A protected one arrives only when the rule "
130
+ "grants `protected`. Redirect it or read it directly: this is the one command "
131
+ "that puts a password on a terminal."
132
+ ),
133
+ )
134
+
135
+ show.add_argument("uuid", help="the entry's identifier")
136
+ _shared(show)
137
+ _dialled(show)
138
+ show.set_defaults(run=_show)
139
+
140
+ add = commands.add_parser("add", help="create an entry in a group")
141
+ add.add_argument("--group", default=api.ROOT, help="where to put it, by identifier or root")
142
+ add.add_argument("--title", help="what to call it")
143
+ add.add_argument("--username", help="the user name")
144
+ add.add_argument("--url", help="the address")
145
+ add.add_argument("--notes", help="the notes")
146
+ add.add_argument(
147
+ "--password",
148
+ action="store_true",
149
+ help="read the password from standard input, so it stays out of the process list",
150
+ )
151
+
152
+ _shared(add)
153
+ _dialled(add)
154
+ add.set_defaults(run=_add)
155
+
156
+ remove = commands.add_parser("remove", help="delete an entry")
157
+ remove.add_argument("uuid", help="the entry's identifier")
158
+ _shared(remove)
159
+ _dialled(remove)
160
+ remove.set_defaults(run=_remove)
161
+
162
+ files = commands.add_parser("attachments", help="list an entry's attachments")
163
+ files.add_argument("uuid", help="the entry's identifier")
164
+ _shared(files)
165
+ _dialled(files)
166
+ files.set_defaults(run=_attachments)
167
+
168
+ fetch = commands.add_parser("fetch", help="write one attachment to a file")
169
+ fetch.add_argument("uuid", help="the entry's identifier")
170
+ fetch.add_argument("name", help="the attachment's name")
171
+ fetch.add_argument("--to", type=Path, required=True, help="where to write it")
172
+ _shared(fetch)
173
+ _dialled(fetch)
174
+ fetch.set_defaults(run=_fetch)
175
+
176
+ return parser
177
+
178
+
179
+
180
+ def _shared(parser: argparse.ArgumentParser) -> None:
181
+ # One slot, so saying it twice is an error rather than a silent precedence. Both name
182
+ # the directory an identity lives in. `--profile` chooses the usual place for a name and
183
+ # `--directory` gives one outright, and `pair --directory D --profile work` used to store
184
+ # where `status --profile work` would not look. Exit code 2, which the module docstring
185
+ # already reserves for arguments that are wrong.
186
+ where = parser.add_mutually_exclusive_group()
187
+ where.add_argument(
188
+ "--profile",
189
+ default="default",
190
+ help="which stored identity to use, since a client can pair with more than one database",
191
+ )
192
+
193
+ where.add_argument(
194
+ "--directory",
195
+ type=Path,
196
+ help="where the identity is stored, if not the usual place for this platform",
197
+ )
198
+
199
+ parser.add_argument(
200
+ "--timeout",
201
+ type=float,
202
+ default=None,
203
+ help="seconds to wait for the gate, if not the default for what is being done",
204
+ )
205
+
206
+
207
+ def _dialled(parser: argparse.ArgumentParser) -> None:
208
+ """Where to open the socket, for the commands that open one.
209
+
210
+ Not on `whoami`, which answers out of the stored identity and never connects: an option that
211
+ cannot change what a command does is an option that misleads whoever reads `--help`.
212
+ """
213
+ parser.add_argument(
214
+ "--host",
215
+ default=api.LOOPBACK,
216
+ help=(
217
+ "where to dial, if the gate is not reachable on loopback. It binds to loopback, so "
218
+ "anything else is the local end of a tunnel somebody put in front of it."
219
+ ),
220
+ )
221
+
222
+
223
+ def _waiting(arguments: argparse.Namespace) -> dict[str, float]:
224
+ """`--timeout` when it was given, and nothing at all when it was not.
225
+
226
+ Passing nothing is what lets each caller keep its own default: a pairing waits on a
227
+ person and gets `transport.PAIRING_TIMEOUT`, and everything else gets the ordinary
228
+ one. Passing `None` through would flatten both into whatever this file decided.
229
+ """
230
+ return {} if arguments.timeout is None else {"timeout": arguments.timeout}
231
+
232
+
233
+ def _pair(arguments: argparse.Namespace) -> int:
234
+ invitation = arguments.invitation
235
+ if not invitation:
236
+ # Read rather than argued, so the token does not end up in a shell history or in the
237
+ # process list, where anything running as this user can read it.
238
+ invitation = sys.stdin.readline().strip()
239
+
240
+ credentials = api.pair(
241
+ port=arguments.port,
242
+ invitation=invitation,
243
+ name=arguments.name,
244
+ profiles=list(arguments.profiles),
245
+ directory=arguments.directory,
246
+ profile=arguments.profile,
247
+ host=arguments.host,
248
+ **_waiting(arguments),
249
+ )
250
+
251
+ print(f"paired as {credentials.subject} with database {credentials.database}")
252
+ print(f"profiles approved: {', '.join(credentials.profiles) or 'none'}")
253
+ print(f"stored in {credentials.directory}")
254
+ return 0
255
+
256
+
257
+ def _status(arguments: argparse.Namespace) -> int:
258
+ client = api.GateClient.open(
259
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
260
+ )
261
+ status = client.status()
262
+ print(f"gate {status.version} serving database {status.database}")
263
+ return 0 if status.ok else FAILED
264
+
265
+
266
+ def _whoami(arguments: argparse.Namespace) -> int:
267
+ where = arguments.directory or api.default_directory(arguments.profile)
268
+ credentials = api.load(where)
269
+ print(f"subject: {credentials.subject}")
270
+ print(f"database: {credentials.database}")
271
+ print(f"profiles: {', '.join(credentials.profiles) or 'none'}")
272
+ print(f"expires: {credentials.expires}")
273
+ print(f"port: {credentials.port}")
274
+ print(f"stored: {credentials.directory}")
275
+ return 0
276
+
277
+
278
+ def _groups(arguments: argparse.Namespace) -> int:
279
+ client = api.GateClient.open(
280
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
281
+ )
282
+ found = client.subgroups(arguments.under) if arguments.under else client.groups()
283
+
284
+ for group in found:
285
+ print(f"{group.uuid} {group.name}")
286
+
287
+ return 0
288
+
289
+
290
+ def _entries(arguments: argparse.Namespace) -> int:
291
+ client = api.GateClient.open(
292
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
293
+ )
294
+
295
+ for entry in client.entries(arguments.group):
296
+ # A withheld title is not a title. Saying so beats printing the word "None".
297
+ print(f"{entry.uuid} {entry.title if entry.title is not None else '(withheld)'}")
298
+
299
+ return 0
300
+
301
+
302
+ def _show(arguments: argparse.Namespace) -> int:
303
+ client = api.GateClient.open(
304
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
305
+ )
306
+ entry = client.entry(arguments.uuid)
307
+
308
+ print(f"uuid: {entry.uuid}")
309
+ print(f"group: {entry.group}")
310
+
311
+ for name, value in sorted((entry.fields or {}).items()):
312
+ guarded = " (protected)" if name in entry.protected else ""
313
+ # A withheld value is not a value. Printing it as one would put the word "None" where a
314
+ # password goes, which reads exactly like a password somebody chose badly.
315
+ shown = "(withheld)" if value is None else value
316
+ print(f"{name}: {shown}{guarded}")
317
+
318
+ return 0
319
+
320
+
321
+ def _add(arguments: argparse.Namespace) -> int:
322
+ client = api.GateClient.open(
323
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
324
+ )
325
+
326
+ password = None
327
+ if arguments.password:
328
+ # Read rather than argued, for the same reason the invitation is: an argument is in the
329
+ # process list, where anything running as this user can read it.
330
+ password = sys.stdin.readline().rstrip("\n")
331
+
332
+ uuid = client.create_entry(
333
+ group=arguments.group,
334
+ title=arguments.title,
335
+ username=arguments.username,
336
+ password=password,
337
+ url=arguments.url,
338
+ notes=arguments.notes,
339
+ )
340
+
341
+ print(uuid)
342
+ return 0
343
+
344
+
345
+ def _remove(arguments: argparse.Namespace) -> int:
346
+ client = api.GateClient.open(
347
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
348
+ )
349
+ client.delete_entry(arguments.uuid)
350
+ print(f"removed {arguments.uuid}")
351
+ return 0
352
+
353
+
354
+ def _attachments(arguments: argparse.Namespace) -> int:
355
+ client = api.GateClient.open(
356
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
357
+ )
358
+
359
+ for attachment in client.attachments(arguments.uuid):
360
+ print(f"{attachment.size:>10} {attachment.name}")
361
+
362
+ return 0
363
+
364
+
365
+ def _fetch(arguments: argparse.Namespace) -> int:
366
+ client = api.GateClient.open(
367
+ arguments.directory, arguments.profile, arguments.host, **_waiting(arguments)
368
+ )
369
+ attachment = client.attachment(arguments.uuid, arguments.name)
370
+
371
+ arguments.to.write_bytes(attachment.content or b"")
372
+ print(f"wrote {len(attachment.content or b'')} bytes to {arguments.to}")
373
+ return 0
374
+
375
+
376
+ if __name__ == "__main__": # pragma: no cover, the module entry point
377
+ raise SystemExit(main())