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
pykeepassgate/client.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""A paired client, and every route the gate serves it.
|
|
2
|
+
|
|
3
|
+
**This is the reference implementation, so what is here is what exists.** Every operation the
|
|
4
|
+
routing table declares has a method, and the absence of a method reads as the absence of a route
|
|
5
|
+
rather than as something nobody got round to. `tests/pykeepassgate/test_reference.py` holds this to
|
|
6
|
+
the document itself, so a route added to the plugin and not to this fails there.
|
|
7
|
+
|
|
8
|
+
Every request is its own connection, named freshly from the gate key. That is not a cost worth
|
|
9
|
+
optimizing away: it is what makes a passive observer see a different name each time.
|
|
10
|
+
|
|
11
|
+
**`root` is a group identifier.** Every method taking a group takes it, so a client granted only
|
|
12
|
+
`create` can put something somewhere without first being granted `list` to find out where.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from urllib.parse import quote
|
|
23
|
+
|
|
24
|
+
from . import credentials as credentials_module
|
|
25
|
+
from . import sni, transport
|
|
26
|
+
from .errors import DatabaseLocked, GateBusy, NotAdmitted, ProtocolError, RouteRefused
|
|
27
|
+
from .model import Attachment, Entry, Field, Group
|
|
28
|
+
|
|
29
|
+
STATUS_PATH = "/gate/status"
|
|
30
|
+
|
|
31
|
+
ROOT = "root"
|
|
32
|
+
"""What every group identifier may say instead of a UUID."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _segment(text: str) -> str:
|
|
36
|
+
"""One path segment, escaped.
|
|
37
|
+
|
|
38
|
+
Nothing is left unescaped, the separator least of all: the gate splits a path before it decodes
|
|
39
|
+
it, so an attachment called `a/b` is addressable exactly because its slash arrives escaped.
|
|
40
|
+
"""
|
|
41
|
+
return quote(text, safe="")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Status:
|
|
46
|
+
"""What the gate says about itself and the database this client reached."""
|
|
47
|
+
|
|
48
|
+
ok: bool
|
|
49
|
+
database: str
|
|
50
|
+
version: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GateClient:
|
|
54
|
+
"""One paired identity, talking to one gate."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
credentials: credentials_module.Credentials,
|
|
59
|
+
timeout: float = transport.DEFAULT_TIMEOUT,
|
|
60
|
+
host: str = transport.LOOPBACK,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Take a paired identity, how long to wait for it, and where to dial it.
|
|
63
|
+
|
|
64
|
+
The host is where the socket goes and nothing else. It is not part of the identity and is
|
|
65
|
+
not stored with it: the same paired client reaches the same KeePass through a tunnel or
|
|
66
|
+
directly, and which of those is happening is a property of the caller's machine rather than
|
|
67
|
+
of the pairing. So it is given here rather than read back from the credentials, and it
|
|
68
|
+
defaults to loopback, which is where the gate itself binds.
|
|
69
|
+
|
|
70
|
+
It does not reach the name in the ClientHello either. That is minted per connection from
|
|
71
|
+
the gate's key and is what the gate resolves this client by; a tunnel changes the address
|
|
72
|
+
the bytes travel to and changes nothing the gate sees.
|
|
73
|
+
"""
|
|
74
|
+
self._credentials = credentials
|
|
75
|
+
self._timeout = timeout
|
|
76
|
+
self._host = host
|
|
77
|
+
self._context = transport.paired_context(credentials.authority, credentials.identity)
|
|
78
|
+
|
|
79
|
+
# Read once, beside the context, because the two are one identity. `gate_key` is a
|
|
80
|
+
# property that reads its file on every access, so leaving it to `send` would let a
|
|
81
|
+
# re-pair under a live client send names built from the new key while presenting the
|
|
82
|
+
# old certificate and pinning the old anchor. A client is one identity, and a
|
|
83
|
+
# re-pair is a new client.
|
|
84
|
+
self._gate_key = credentials.gate_key
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def open(
|
|
88
|
+
cls,
|
|
89
|
+
directory: Path | None = None,
|
|
90
|
+
profile: str = "default",
|
|
91
|
+
host: str = transport.LOOPBACK,
|
|
92
|
+
timeout: float = transport.DEFAULT_TIMEOUT,
|
|
93
|
+
) -> GateClient:
|
|
94
|
+
"""Load a paired identity from where `pair` put it.
|
|
95
|
+
|
|
96
|
+
Both of the things that are not the identity are given here rather than read back
|
|
97
|
+
with it, and for the same reason: where to dial and how long to wait are the
|
|
98
|
+
caller's, not the pairing's. This is the way a caller is meant to build one, so a
|
|
99
|
+
setting it cannot reach is a setting nobody has.
|
|
100
|
+
"""
|
|
101
|
+
where = directory or credentials_module.default_directory(profile)
|
|
102
|
+
return cls(credentials_module.load(where), timeout=timeout, host=host)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def host(self) -> str:
|
|
106
|
+
"""Where this client dials, which is loopback unless something is tunnelling."""
|
|
107
|
+
return self._host
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def credentials(self) -> credentials_module.Credentials:
|
|
111
|
+
"""What this client is, which is worth showing a person before it does anything."""
|
|
112
|
+
return self._credentials
|
|
113
|
+
|
|
114
|
+
def status(self) -> Status:
|
|
115
|
+
"""The route that proves a connection and nothing else.
|
|
116
|
+
|
|
117
|
+
It reads nothing out of the database on purpose, which is why it is one of the three
|
|
118
|
+
routes declaring `x-kpg-verb: none`, the other two being the pairing ones: no grant
|
|
119
|
+
could allow or refuse it, because it reaches nothing a grant is about. It exists to
|
|
120
|
+
prove a connection rather than to be useful.
|
|
121
|
+
|
|
122
|
+
A client can use it to answer three questions at once: whether the gate is listening,
|
|
123
|
+
whether this certificate is still admitted, and whether the database is unlocked.
|
|
124
|
+
"""
|
|
125
|
+
body = self.get(STATUS_PATH)
|
|
126
|
+
return Status(
|
|
127
|
+
ok=bool(body.get("ok")),
|
|
128
|
+
database=str(body.get("database", "")),
|
|
129
|
+
version=str(body.get("version", "")),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Groups
|
|
133
|
+
|
|
134
|
+
def groups(self) -> list[Group]:
|
|
135
|
+
"""Every group in the database, root first.
|
|
136
|
+
|
|
137
|
+
Anchored at the root, so it needs `list` there. A client granted less asks the group it was
|
|
138
|
+
granted, with `subgroups`.
|
|
139
|
+
"""
|
|
140
|
+
return [Group.of(item) for item in self.get("/groups").get("groups", [])]
|
|
141
|
+
|
|
142
|
+
def group(self, uuid: str = ROOT) -> Group:
|
|
143
|
+
"""One group's own settings, which needs `read` rather than `list`."""
|
|
144
|
+
return Group.of(self.get(f"/groups/{_segment(uuid)}"))
|
|
145
|
+
|
|
146
|
+
def subgroups(self, uuid: str = ROOT) -> list[Group]:
|
|
147
|
+
"""The groups directly inside this one."""
|
|
148
|
+
body = self.get(f"/groups/{_segment(uuid)}/groups")
|
|
149
|
+
return [Group.of(item) for item in body.get("groups", [])]
|
|
150
|
+
|
|
151
|
+
def create_group(self, name: str, parent: str = ROOT, notes: str | None = None) -> str:
|
|
152
|
+
"""Put a new group inside this one, and answer with what addresses it."""
|
|
153
|
+
body: dict[str, Any] = {"Name": name}
|
|
154
|
+
if notes is not None:
|
|
155
|
+
body["Notes"] = notes
|
|
156
|
+
|
|
157
|
+
return str(self.send("POST", f"/groups/{_segment(parent)}/groups", body)["uuid"])
|
|
158
|
+
|
|
159
|
+
def update_group(self, uuid: str, name: str | None = None, notes: str | None = None) -> None:
|
|
160
|
+
"""Rename a group, or change its notes. Only what is named is written."""
|
|
161
|
+
body: dict[str, Any] = {}
|
|
162
|
+
if name is not None:
|
|
163
|
+
body["Name"] = name
|
|
164
|
+
if notes is not None:
|
|
165
|
+
body["Notes"] = notes
|
|
166
|
+
|
|
167
|
+
self.send("PUT", f"/groups/{_segment(uuid)}", body)
|
|
168
|
+
|
|
169
|
+
def delete_group(self, uuid: str) -> None:
|
|
170
|
+
"""Remove a group, which the gate refuses while it still holds anything."""
|
|
171
|
+
self.send("DELETE", f"/groups/{_segment(uuid)}")
|
|
172
|
+
|
|
173
|
+
# Entries
|
|
174
|
+
|
|
175
|
+
def entries(self, group: str | None = None) -> list[Entry]:
|
|
176
|
+
"""Every entry, or the ones directly inside a group.
|
|
177
|
+
|
|
178
|
+
With no group this is the flat sweep, anchored at the root and recursive. With one it is
|
|
179
|
+
that group's own entries, one level: a different question rather than the same one narrowed.
|
|
180
|
+
"""
|
|
181
|
+
path = "/entries" if group is None else f"/groups/{_segment(group)}/entries"
|
|
182
|
+
return [Entry.listed(item) for item in self.get(path).get("entries", [])]
|
|
183
|
+
|
|
184
|
+
def entry(self, uuid: str) -> Entry:
|
|
185
|
+
"""One entry and every string it holds.
|
|
186
|
+
|
|
187
|
+
The protected ones arrive only when this client may read them.
|
|
188
|
+
"""
|
|
189
|
+
return Entry.read(self.get(f"/entries/{_segment(uuid)}"))
|
|
190
|
+
|
|
191
|
+
def create_entry(
|
|
192
|
+
self,
|
|
193
|
+
group: str = ROOT,
|
|
194
|
+
title: str | None = None,
|
|
195
|
+
username: str | None = None,
|
|
196
|
+
password: str | None = None,
|
|
197
|
+
url: str | None = None,
|
|
198
|
+
notes: str | None = None,
|
|
199
|
+
) -> str:
|
|
200
|
+
"""Put a new entry in a group, and answer with what addresses it.
|
|
201
|
+
|
|
202
|
+
Only the five standard strings, because adding anything else is a different verb: use
|
|
203
|
+
`create_field` afterwards, which is what `create` and `update` being separate means.
|
|
204
|
+
"""
|
|
205
|
+
body = self._standard(title, username, password, url, notes)
|
|
206
|
+
return str(self.send("POST", f"/groups/{_segment(group)}/entries", body)["uuid"])
|
|
207
|
+
|
|
208
|
+
def update_entry(
|
|
209
|
+
self,
|
|
210
|
+
uuid: str,
|
|
211
|
+
title: str | None = None,
|
|
212
|
+
username: str | None = None,
|
|
213
|
+
password: str | None = None,
|
|
214
|
+
url: str | None = None,
|
|
215
|
+
notes: str | None = None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Change the standard fields of an entry. Only what is named is written."""
|
|
218
|
+
body = self._standard(title, username, password, url, notes)
|
|
219
|
+
self.send("PUT", f"/entries/{_segment(uuid)}", body)
|
|
220
|
+
|
|
221
|
+
def delete_entry(self, uuid: str) -> None:
|
|
222
|
+
"""Remove an entry, to the recycle bin when the database keeps one."""
|
|
223
|
+
self.send("DELETE", f"/entries/{_segment(uuid)}")
|
|
224
|
+
|
|
225
|
+
# Fields
|
|
226
|
+
|
|
227
|
+
def fields(self, uuid: str) -> list[Field]:
|
|
228
|
+
"""The names of an entry's strings, never their values."""
|
|
229
|
+
body = self.get(f"/entries/{_segment(uuid)}/fields")
|
|
230
|
+
return [
|
|
231
|
+
Field(name=str(item["name"]), protected=bool(item["protected"]))
|
|
232
|
+
for item in body.get("fields", [])
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
def field(self, uuid: str, name: str) -> Field:
|
|
236
|
+
"""One string's value."""
|
|
237
|
+
body = self.get(f"/entries/{_segment(uuid)}/fields/{_segment(name)}")
|
|
238
|
+
# `None` survives as `None`, for the reason `Entry.read` gives: the gate answers a
|
|
239
|
+
# protected value this client may not read with `null`, and `str` would make that the word
|
|
240
|
+
# "None". `Field.is_read` is what asks, and it can only answer while the `None` is intact.
|
|
241
|
+
raw = body.get("value")
|
|
242
|
+
|
|
243
|
+
return Field(
|
|
244
|
+
name=str(body.get("name", name)),
|
|
245
|
+
protected=bool(body.get("protected")),
|
|
246
|
+
value=None if raw is None else str(raw),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def create_field(self, uuid: str, name: str, value: str, protected: bool = False) -> None:
|
|
250
|
+
"""Add a string this entry does not have. Refused when the name is already taken."""
|
|
251
|
+
body: dict[str, Any] = {"name": name, "value": value}
|
|
252
|
+
if protected:
|
|
253
|
+
body["protected"] = "true"
|
|
254
|
+
|
|
255
|
+
self.send("POST", f"/entries/{_segment(uuid)}/fields", body)
|
|
256
|
+
|
|
257
|
+
def update_field(self, uuid: str, name: str, value: str) -> None:
|
|
258
|
+
"""Change a string this entry already has. Refused when there is no such name.
|
|
259
|
+
|
|
260
|
+
What it does not change is whether the field is protected: the operator arranged that, and
|
|
261
|
+
writing a value is not the moment to decide it differently.
|
|
262
|
+
"""
|
|
263
|
+
self.send("PUT", f"/entries/{_segment(uuid)}/fields/{_segment(name)}", {"value": value})
|
|
264
|
+
|
|
265
|
+
def strip_field(self, uuid: str, name: str) -> None:
|
|
266
|
+
"""Take a string off an entry.
|
|
267
|
+
|
|
268
|
+
A strip rather than a delete: it removes a part of the entry and leaves the entry, so it
|
|
269
|
+
needs `strip` on the entry and says nothing about whether the entry itself may be removed.
|
|
270
|
+
"""
|
|
271
|
+
self.send("DELETE", f"/entries/{_segment(uuid)}/fields/{_segment(name)}")
|
|
272
|
+
|
|
273
|
+
# Attachments
|
|
274
|
+
|
|
275
|
+
def attachments(self, uuid: str) -> list[Attachment]:
|
|
276
|
+
"""The names and sizes of an entry's attachments, never their contents.
|
|
277
|
+
|
|
278
|
+
Needs the attachment switch as well as `list`, which is what the seventh letter of a grant
|
|
279
|
+
means: not "may read attachments" but "and the attachments too".
|
|
280
|
+
"""
|
|
281
|
+
body = self.get(f"/entries/{_segment(uuid)}/attachments")
|
|
282
|
+
return [
|
|
283
|
+
Attachment(name=str(item["name"]), size=int(item["size"]))
|
|
284
|
+
for item in body.get("attachments", [])
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
def attachment(self, uuid: str, name: str) -> Attachment:
|
|
288
|
+
"""One attachment, contents and all."""
|
|
289
|
+
body = self.get(f"/entries/{_segment(uuid)}/attachments/{_segment(name)}")
|
|
290
|
+
return Attachment(
|
|
291
|
+
name=str(body.get("name", name)),
|
|
292
|
+
size=int(body.get("size", 0)),
|
|
293
|
+
content=_bytes(body.get("content")),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def create_attachment(self, uuid: str, name: str, content: bytes) -> None:
|
|
297
|
+
"""Add an attachment this entry does not have."""
|
|
298
|
+
self.send(
|
|
299
|
+
"POST",
|
|
300
|
+
f"/entries/{_segment(uuid)}/attachments",
|
|
301
|
+
{"name": name, "content": base64.b64encode(content).decode("ascii")},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def update_attachment(self, uuid: str, name: str, content: bytes) -> None:
|
|
305
|
+
"""Replace an attachment this entry already has."""
|
|
306
|
+
self.send(
|
|
307
|
+
"PUT",
|
|
308
|
+
f"/entries/{_segment(uuid)}/attachments/{_segment(name)}",
|
|
309
|
+
{"content": base64.b64encode(content).decode("ascii")},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def strip_attachment(self, uuid: str, name: str) -> None:
|
|
313
|
+
"""Take an attachment off an entry.
|
|
314
|
+
|
|
315
|
+
Needs `strip`, and not the attachment switch: removing shows nobody anything.
|
|
316
|
+
"""
|
|
317
|
+
self.send("DELETE", f"/entries/{_segment(uuid)}/attachments/{_segment(name)}")
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _standard(
|
|
321
|
+
title: str | None,
|
|
322
|
+
username: str | None,
|
|
323
|
+
password: str | None,
|
|
324
|
+
url: str | None,
|
|
325
|
+
notes: str | None,
|
|
326
|
+
) -> dict[str, Any]:
|
|
327
|
+
"""The five strings, by KeePass's own names, and only the ones that were given."""
|
|
328
|
+
named = {
|
|
329
|
+
"Title": title,
|
|
330
|
+
"UserName": username,
|
|
331
|
+
"Password": password,
|
|
332
|
+
"URL": url,
|
|
333
|
+
"Notes": notes,
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {name: value for name, value in named.items() if value is not None}
|
|
337
|
+
|
|
338
|
+
def head(self, path: str) -> None:
|
|
339
|
+
"""Whether a route answers, and whether this client may reach it, without the answer.
|
|
340
|
+
|
|
341
|
+
One method for every `head` the document declares, because a `head` is generic in a way
|
|
342
|
+
a `get` is not: it carries no content, so there is nothing to shape per route. What it
|
|
343
|
+
reports is the status, which is the whole of what was asked.
|
|
344
|
+
|
|
345
|
+
It hands back nothing: a refusal is an exception here as it is everywhere else in this
|
|
346
|
+
client, so reaching the next line is the answer. The gate omits `Content-Length` on
|
|
347
|
+
these, which RFC 9110 section 9.3.2 permits for a value determined only while
|
|
348
|
+
generating content, so this one reads to the close the gate sends anyway.
|
|
349
|
+
"""
|
|
350
|
+
self.send("HEAD", path)
|
|
351
|
+
|
|
352
|
+
def get(self, path: str) -> dict[str, Any]:
|
|
353
|
+
"""One GET, on its own connection, under a name built for it."""
|
|
354
|
+
return self.send("GET", path)
|
|
355
|
+
|
|
356
|
+
def send(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
357
|
+
"""One request, on its own connection.
|
|
358
|
+
|
|
359
|
+
Public because the gate is a specification-defined API and a client that could only call
|
|
360
|
+
the methods this class happens to name would be a worse reference than one that can reach
|
|
361
|
+
anything the document declares.
|
|
362
|
+
"""
|
|
363
|
+
name, _ = sni.new_name(self._gate_key)
|
|
364
|
+
payload = "" if body is None else json.dumps(body, separators=(",", ":"))
|
|
365
|
+
|
|
366
|
+
with transport.connect(
|
|
367
|
+
self._credentials.port,
|
|
368
|
+
self._context,
|
|
369
|
+
name,
|
|
370
|
+
host=self._host,
|
|
371
|
+
timeout=self._timeout,
|
|
372
|
+
) as connection:
|
|
373
|
+
reply = connection.send(method, path, payload)
|
|
374
|
+
|
|
375
|
+
_refuse(reply)
|
|
376
|
+
|
|
377
|
+
if not reply.body:
|
|
378
|
+
return {}
|
|
379
|
+
|
|
380
|
+
return reply.json()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _bytes(value: object) -> bytes:
|
|
384
|
+
"""An attachment's content, or a refusal to guess at one.
|
|
385
|
+
|
|
386
|
+
`str` around this would turn a `null` into the word "None", which is valid base64 and
|
|
387
|
+
decodes to three bytes of something. Wrong data is worse than an error, so this is an error.
|
|
388
|
+
"""
|
|
389
|
+
if not isinstance(value, str):
|
|
390
|
+
raise ProtocolError("the gate sent an attachment with no content")
|
|
391
|
+
|
|
392
|
+
return base64.b64decode(value)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _refuse(reply: transport.Response) -> None:
|
|
396
|
+
"""What each refusal means to a caller deciding what to do next."""
|
|
397
|
+
# Both of the document's successes, not just the common one. Every create answers `201`,
|
|
398
|
+
# and a client that knows only `200` turns all four of them into refusals.
|
|
399
|
+
if reply.status in (200, 201):
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
error = reply.error()
|
|
403
|
+
|
|
404
|
+
if reply.status == 503 and error == "locked":
|
|
405
|
+
raise DatabaseLocked("the database is locked, so every route answers this until it is not")
|
|
406
|
+
|
|
407
|
+
if reply.status == 503:
|
|
408
|
+
raise GateBusy("the gate cannot serve this right now")
|
|
409
|
+
|
|
410
|
+
if reply.status == 401:
|
|
411
|
+
# The record is read from the live database on every connection and never cached, so this
|
|
412
|
+
# is also what revocation looks like: immediate, and from the client's side no different
|
|
413
|
+
# from never having been admitted, which is the point.
|
|
414
|
+
raise NotAdmitted("this database holds no admission record for this certificate")
|
|
415
|
+
|
|
416
|
+
raise RouteRefused(reply.status, error)
|