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,323 @@
|
|
|
1
|
+
"""What a paired client keeps, and where it keeps it.
|
|
2
|
+
|
|
3
|
+
Pairing hands over two secrets, a trust anchor and some text about them. The authority is a
|
|
4
|
+
certificate anybody may see; what has to stay unread is the client's own key and the gate key. This
|
|
5
|
+
writes each of the three to a file of its own and the text to a fourth, so that nothing secret is in
|
|
6
|
+
the file a person is most likely to open, paste or attach.
|
|
7
|
+
|
|
8
|
+
**The client's key is on the disk, and that is not the contradiction it looks like.** The plugin
|
|
9
|
+
goes to some trouble to keep the *host* key out of the user's profile, because a password manager
|
|
10
|
+
holding the database can hold its own key in memory for as long as it runs. A client is a program
|
|
11
|
+
that has to still be the same client tomorrow, so its key has to survive it exiting. What the
|
|
12
|
+
design concedes throughout, and concedes here as well, is local code execution as the user: nothing
|
|
13
|
+
in this file defends against it, and nothing claims to.
|
|
14
|
+
|
|
15
|
+
What it does do is refuse to be careless. The directory and the files are *created* with
|
|
16
|
+
owner-only permissions where the platform has them, rather than created and then narrowed, and
|
|
17
|
+
the key is written once rather than through a temporary file that a crash could leave behind.
|
|
18
|
+
Nothing is written at all until the whole bundle has been read and found to be what it claims.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import stat
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from cryptography import x509
|
|
33
|
+
from cryptography.hazmat.primitives import serialization
|
|
34
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
35
|
+
|
|
36
|
+
from . import sni
|
|
37
|
+
from .errors import ProtocolError, StorageError
|
|
38
|
+
|
|
39
|
+
IDENTITY_FILE = "identity.pem"
|
|
40
|
+
AUTHORITY_FILE = "authority.pem"
|
|
41
|
+
GATE_KEY_FILE = "gate.key"
|
|
42
|
+
DETAILS_FILE = "client.json"
|
|
43
|
+
|
|
44
|
+
#: Owner read and write, and nothing for anybody else. Meaningless on Windows, where the file's
|
|
45
|
+
#: inherited ACL is what actually decides, so it is applied anyway and relied on nowhere.
|
|
46
|
+
_OWNER_ONLY = stat.S_IRUSR | stat.S_IWUSR
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def default_directory(name: str = "default") -> Path:
|
|
50
|
+
"""Where credentials live when nobody says otherwise.
|
|
51
|
+
|
|
52
|
+
Per profile rather than one place, because a client that has paired with two databases holds
|
|
53
|
+
two identities and they are not interchangeable.
|
|
54
|
+
"""
|
|
55
|
+
# `os.name` rather than `sys.platform`, which a type checker narrows to whichever platform it
|
|
56
|
+
# happens to be running on and then calls the other branch unreachable.
|
|
57
|
+
if os.name == "nt":
|
|
58
|
+
# Local rather than roaming. What goes in here is an unencrypted private key and
|
|
59
|
+
# a gate key, and `%APPDATA%` is copied to a domain file server at logoff on a
|
|
60
|
+
# machine with a roaming profile or folder redirection, which are the ordinary
|
|
61
|
+
# enterprise settings. A credential that identifies this client should not
|
|
62
|
+
# follow the person to every machine they sign into.
|
|
63
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
64
|
+
base = Path(local) if local else Path.home() / "AppData" / "Local"
|
|
65
|
+
else:
|
|
66
|
+
config = os.environ.get("XDG_CONFIG_HOME")
|
|
67
|
+
base = Path(config) if config else Path.home() / ".config"
|
|
68
|
+
|
|
69
|
+
return base / "pykeepassgate" / name
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Credentials:
|
|
74
|
+
"""One paired identity: where it is stored, and what it is for."""
|
|
75
|
+
|
|
76
|
+
directory: Path
|
|
77
|
+
port: int
|
|
78
|
+
subject: str
|
|
79
|
+
database: str
|
|
80
|
+
profiles: tuple[str, ...]
|
|
81
|
+
expires: str
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def identity(self) -> Path:
|
|
85
|
+
"""The client's own certificate and key, which `ssl` loads as one file."""
|
|
86
|
+
return self.directory / IDENTITY_FILE
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def authority(self) -> Path:
|
|
90
|
+
"""The one certificate this client trusts to have issued a host certificate."""
|
|
91
|
+
return self.directory / AUTHORITY_FILE
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def gate_key(self) -> bytes:
|
|
95
|
+
"""The key every connection after pairing is named from."""
|
|
96
|
+
try:
|
|
97
|
+
return (self.directory / GATE_KEY_FILE).read_bytes()
|
|
98
|
+
except OSError as error:
|
|
99
|
+
# `FileNotFoundError` is one of these and the command line catches it by
|
|
100
|
+
# name. The rest would otherwise reach the caller mid request as a bare
|
|
101
|
+
# `OSError`: a file locked by a backup agent, a directory locked down
|
|
102
|
+
# after this client opened.
|
|
103
|
+
raise StorageError(f"the gate key in {self.directory} will not read") from error
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save(directory: Path, port: int, bundle: dict[str, Any]) -> Credentials:
|
|
107
|
+
"""Write what pairing handed over, and hand back what a client needs to use it.
|
|
108
|
+
|
|
109
|
+
The PKCS#12 the gate sends is converted to PEM here rather than at every connection, because
|
|
110
|
+
`ssl` reads PEM from a path and nothing else, and because converting once means the key is
|
|
111
|
+
handled once.
|
|
112
|
+
|
|
113
|
+
**Everything is read before anything is written.** A bundle refused halfway destroys a
|
|
114
|
+
working identity: the key and the gate key would already be the new ones while the anchor
|
|
115
|
+
and the details still described the old, so `load` would report a subject that does not
|
|
116
|
+
belong to the key beside it. On a first pairing it would leave an unencrypted key with no
|
|
117
|
+
`client.json`, which `load` refuses, so nothing would ever clean it up.
|
|
118
|
+
"""
|
|
119
|
+
# Everything is read before anything is written. See the note in the docstring above: a
|
|
120
|
+
# bundle refused halfway is a working identity destroyed, and the invitation that produced
|
|
121
|
+
# it is spent either way, so there is nothing left to try again with.
|
|
122
|
+
identity = _identity(_text(bundle, "identity"))
|
|
123
|
+
anchor = _anchor(_text(bundle, "authority"))
|
|
124
|
+
key = _gate_key(_text(bundle, "gateKey"))
|
|
125
|
+
details = _details(port, bundle)
|
|
126
|
+
|
|
127
|
+
_make(directory)
|
|
128
|
+
|
|
129
|
+
_write(directory / IDENTITY_FILE, identity)
|
|
130
|
+
_write(directory / GATE_KEY_FILE, key)
|
|
131
|
+
_write(directory / AUTHORITY_FILE, anchor)
|
|
132
|
+
_write(directory / DETAILS_FILE, details)
|
|
133
|
+
return load(directory)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load(directory: Path) -> Credentials:
|
|
137
|
+
"""Read back what `save` wrote.
|
|
138
|
+
|
|
139
|
+
This is the one place that reads a file somebody could have edited, truncated or filled a
|
|
140
|
+
disk halfway through, so it is the one place that refuses in this library's own terms. Left
|
|
141
|
+
alone, a `client.json` that will not parse arrives as `JSONDecodeError`, one missing its
|
|
142
|
+
port as `KeyError`, one holding a JSON array as `TypeError` and one whose port is not a
|
|
143
|
+
number as `ValueError`. That is four ways past a caller who was promised one root to catch,
|
|
144
|
+
and four tracebacks out of a command line that documents an exit code instead.
|
|
145
|
+
"""
|
|
146
|
+
details_path = directory / DETAILS_FILE
|
|
147
|
+
if not details_path.exists():
|
|
148
|
+
raise FileNotFoundError(f"no paired client in {directory}")
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
details = json.loads(details_path.read_text("utf-8"))
|
|
152
|
+
except (OSError, ValueError) as error:
|
|
153
|
+
raise StorageError(f"{details_path} will not read: {error}") from error
|
|
154
|
+
|
|
155
|
+
if not isinstance(details, dict):
|
|
156
|
+
raise StorageError(f"{details_path} is not what this client wrote")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
port = int(details["port"])
|
|
160
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
161
|
+
raise StorageError(f"{details_path} names no port this client can use") from error
|
|
162
|
+
|
|
163
|
+
return Credentials(
|
|
164
|
+
directory=directory,
|
|
165
|
+
port=port,
|
|
166
|
+
subject=str(details.get("subject", "")),
|
|
167
|
+
database=str(details.get("database", "")),
|
|
168
|
+
profiles=tuple(details.get("profiles") or []),
|
|
169
|
+
expires=str(details.get("expires", "")),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _identity(value: str) -> bytes:
|
|
174
|
+
"""The PKCS#12 the gate sent, as the PEM `ssl` will read."""
|
|
175
|
+
try:
|
|
176
|
+
key, certificate, _ = pkcs12.load_key_and_certificates(
|
|
177
|
+
_base64(value, "identity"),
|
|
178
|
+
# The gate writes the PKCS#12 with no password: what protects it in the database is
|
|
179
|
+
# the database, and what protects it here is the file.
|
|
180
|
+
password=None,
|
|
181
|
+
)
|
|
182
|
+
except ValueError as error:
|
|
183
|
+
raise ProtocolError("the bundle's identity is not a PKCS#12") from error
|
|
184
|
+
|
|
185
|
+
if key is None or certificate is None:
|
|
186
|
+
raise ProtocolError("the bundle's identity carries no certificate and key")
|
|
187
|
+
|
|
188
|
+
return certificate.public_bytes(serialization.Encoding.PEM) + key.private_bytes(
|
|
189
|
+
encoding=serialization.Encoding.PEM,
|
|
190
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
191
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _anchor(value: str) -> bytes:
|
|
196
|
+
"""The trust anchor, parsed rather than wrapped in delimiters.
|
|
197
|
+
|
|
198
|
+
Writing the PEM markers around whatever arrived stores a base64 blob that need not be a
|
|
199
|
+
certificate at all, and the refusal then turns up at the next connection out of
|
|
200
|
+
`SSLContext.load_verify_locations` as an `SSLError`, which is an `OSError` and so is not a
|
|
201
|
+
`GateError`. The identity beside it is parsed; this is the same care for the half of the
|
|
202
|
+
bundle that decides who this client will trust from here on.
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
certificate = x509.load_der_x509_certificate(_base64(value, "authority"))
|
|
206
|
+
except ValueError as error:
|
|
207
|
+
raise ProtocolError("the bundle's authority is not a certificate") from error
|
|
208
|
+
|
|
209
|
+
return certificate.public_bytes(serialization.Encoding.PEM)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _gate_key(value: str) -> bytes:
|
|
213
|
+
"""The key every name after pairing is built from, at the one length it can be."""
|
|
214
|
+
key = _base64(value, "gateKey")
|
|
215
|
+
if len(key) != sni.KEY_LENGTH:
|
|
216
|
+
raise ProtocolError(f"a gate key is {sni.KEY_LENGTH} bytes, not {len(key)}")
|
|
217
|
+
|
|
218
|
+
return key
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _details(port: int, bundle: dict[str, Any]) -> bytes:
|
|
222
|
+
"""The text about the identity, which is the part of the bundle nothing else checks."""
|
|
223
|
+
profiles = bundle.get("profiles") or []
|
|
224
|
+
if not isinstance(profiles, list) or not all(isinstance(name, str) for name in profiles):
|
|
225
|
+
# `list()` over a string spells it out one character at a time, and the operator is
|
|
226
|
+
# then shown six profiles nobody approved.
|
|
227
|
+
raise ProtocolError("the bundle's profiles are not a list of names")
|
|
228
|
+
|
|
229
|
+
details = {
|
|
230
|
+
"port": port,
|
|
231
|
+
"subject": _optional(bundle, "subject"),
|
|
232
|
+
"database": _optional(bundle, "database"),
|
|
233
|
+
"expires": _optional(bundle, "expires"),
|
|
234
|
+
"profiles": list(profiles),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return json.dumps(details, indent=2).encode("utf-8") + b"\n"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _optional(bundle: dict[str, Any], key: str) -> str:
|
|
241
|
+
"""Text the bundle may leave out, which is not the same as text it set to null.
|
|
242
|
+
|
|
243
|
+
`get` with a default fires only on absence, so a JSON `null` travels all the way to `str()`
|
|
244
|
+
and comes back as the four letters `None`.
|
|
245
|
+
"""
|
|
246
|
+
value = bundle.get(key)
|
|
247
|
+
return value if isinstance(value, str) else ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _make(directory: Path) -> None:
|
|
251
|
+
"""Create the directory and whatever it needs above it, narrowing only what this created.
|
|
252
|
+
|
|
253
|
+
The narrowing has to happen after the fact rather than in the call: `mkdir` applies its
|
|
254
|
+
`mode` to the leaf alone and creates everything above it with the process umask, so a
|
|
255
|
+
`pykeepassgate` directory this call brings into existence would name which profiles exist
|
|
256
|
+
to anybody while the files inside it are not readable at all.
|
|
257
|
+
|
|
258
|
+
**What was already there keeps the mode it had**, and that is the whole of what this
|
|
259
|
+
refuses to do. A directory the caller named is the caller's, and its parent is nobody's
|
|
260
|
+
business here: `--directory ~/agent` would otherwise change the mode of a home directory,
|
|
261
|
+
and a parent this process does not own, `/tmp` among them, would refuse the change and
|
|
262
|
+
raise. Either way the invitation is spent by then, the record is written and saved, and the
|
|
263
|
+
certificate is in hand with nowhere to put it, so a refusal here loses an identity that
|
|
264
|
+
cannot be asked for again.
|
|
265
|
+
"""
|
|
266
|
+
# Collected before the call, because afterwards there is no way to tell what it made.
|
|
267
|
+
fresh = [path for path in (directory, *directory.parents) if not path.exists()]
|
|
268
|
+
directory.mkdir(parents=True, exist_ok=True, mode=stat.S_IRWXU)
|
|
269
|
+
for path in fresh:
|
|
270
|
+
_own(path, stat.S_IRWXU)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _write(path: Path, content: bytes) -> None:
|
|
274
|
+
"""Create the file closed, rather than creating it open and narrowing it after.
|
|
275
|
+
|
|
276
|
+
`Path.write_bytes` creates with the process umask, which is world-readable under the
|
|
277
|
+
usual one, and the `chmod` is the statement after it: the client's key exists at
|
|
278
|
+
0644 for the interval between the two. Opening with the mode closes that interval.
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _OWNER_ONLY)
|
|
282
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
283
|
+
handle.write(content)
|
|
284
|
+
except OSError as error:
|
|
285
|
+
raise StorageError(f"{path} could not be written: {error}") from error
|
|
286
|
+
|
|
287
|
+
# The mode above applies only where the file is created, so an overwrite keeps
|
|
288
|
+
# whatever the old one had. A re-pair is an overwrite.
|
|
289
|
+
_own(path, _OWNER_ONLY)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _own(path: Path, mode: int) -> None:
|
|
293
|
+
"""Narrow what a platform with permissions will honor.
|
|
294
|
+
|
|
295
|
+
Suppressed on Windows only, where the inherited ACL is what actually decides and a
|
|
296
|
+
`chmod` means almost nothing either way. Everywhere else a refusal means the key is
|
|
297
|
+
still readable by whoever could read it before, which is the one thing this function
|
|
298
|
+
exists to prevent, so it is reported rather than swallowed.
|
|
299
|
+
"""
|
|
300
|
+
if os.name == "nt":
|
|
301
|
+
with contextlib.suppress(OSError):
|
|
302
|
+
path.chmod(mode)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
path.chmod(mode)
|
|
307
|
+
except OSError as error:
|
|
308
|
+
raise StorageError(f"{path} could not be made owner-only: {error}") from error
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _text(bundle: dict[str, Any], key: str) -> str:
|
|
312
|
+
value = bundle.get(key)
|
|
313
|
+
if not isinstance(value, str) or not value:
|
|
314
|
+
raise ProtocolError(f"the bundle is missing {key}")
|
|
315
|
+
|
|
316
|
+
return value
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _base64(value: str, key: str) -> bytes:
|
|
320
|
+
try:
|
|
321
|
+
return base64.b64decode(value, validate=True)
|
|
322
|
+
except ValueError as error:
|
|
323
|
+
raise ProtocolError(f"the bundle's {key} is not base64") from error
|
pykeepassgate/errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""What this client raises, and nothing else.
|
|
2
|
+
|
|
3
|
+
One base class, so a caller can catch everything this library does without also catching what the
|
|
4
|
+
standard library does. The tree mirrors where a failure happened rather than what it felt like: the
|
|
5
|
+
transport, the pairing exchange, or a request the gate refused. A caller deciding whether to retry,
|
|
6
|
+
to wait, or to give up needs that distinction, and a single exception type would take it away.
|
|
7
|
+
|
|
8
|
+
Nothing here carries a token, a key or a certificate. An exception is the value most likely to be
|
|
9
|
+
logged, printed, and pasted into an issue.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GateError(Exception):
|
|
16
|
+
"""Anything this library refuses or cannot do."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MalformedToken(GateError):
|
|
20
|
+
"""The text handed over is not a pairing token."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TransportError(GateError):
|
|
24
|
+
"""The connection failed, or was closed before an answer arrived."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProtocolError(GateError):
|
|
28
|
+
"""Something on this wire is not what the protocol allows.
|
|
29
|
+
|
|
30
|
+
Either direction: an answer this client cannot read, or a request it will not send.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class StorageError(GateError):
|
|
35
|
+
"""What is stored for a paired client could not be written, read or protected.
|
|
36
|
+
|
|
37
|
+
Separate from `ProtocolError`, which is about the wire. This is about the four files: one that
|
|
38
|
+
will not parse, a port that is not a number, a key the platform would not narrow. All of them
|
|
39
|
+
are ordinary states, reached by a partial write, a full disk or an edit by hand. Every one of
|
|
40
|
+
them would otherwise leave the standard library's own exception where a caller is promised
|
|
41
|
+
this hierarchy.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PairingError(GateError):
|
|
46
|
+
"""The pairing exchange did not reach a certificate."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProofRefused(PairingError):
|
|
50
|
+
"""A proof did not verify.
|
|
51
|
+
|
|
52
|
+
On the client's own proof this means the token is wrong. On the server's, it means whatever
|
|
53
|
+
answered is not the KeePass whose operator holds the token, and the exchange stops here: a
|
|
54
|
+
client with no anchor yet has nothing else to go on.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PairingRefused(PairingError):
|
|
59
|
+
"""The operator said no, and the invitation is spent."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PairingUnanswered(PairingError):
|
|
63
|
+
"""Nobody answered the dialog. The invitation is not spent, so this can be tried again."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class GateBusy(GateError):
|
|
67
|
+
"""The gate cannot serve this right now and the caller should try again.
|
|
68
|
+
|
|
69
|
+
A dialog open on the group or entry being written is the usual reason: the plugin
|
|
70
|
+
refuses a write that the dialog would take straight back off again when it is accepted.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DatabaseLocked(GateError):
|
|
75
|
+
"""Every route answers this while the database is locked.
|
|
76
|
+
|
|
77
|
+
It names the lock and nothing else, deliberately: not which database, not whether the caller
|
|
78
|
+
would have been admitted. A client learns that it should wait, which is the only thing it can
|
|
79
|
+
act on.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NotAdmitted(GateError):
|
|
84
|
+
"""The certificate is not one this database holds a record for.
|
|
85
|
+
|
|
86
|
+
Every connection is checked against the record, not against the certificate alone, so this is
|
|
87
|
+
also what a revoked client sees, immediately.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RouteRefused(GateError):
|
|
92
|
+
"""The gate refused this request, with the status and the word it used."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, status: int, error: str) -> None:
|
|
95
|
+
"""Keep the status and the one-word reason the gate gave."""
|
|
96
|
+
super().__init__(f"the gate answered {status}: {error}")
|
|
97
|
+
self.status = status
|
|
98
|
+
self.error = error
|
pykeepassgate/model.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""What the gate answers with, as objects rather than dictionaries.
|
|
2
|
+
|
|
3
|
+
**Duck-typed after `pykeepass`, and honest about not being it.** A `PyKeePass` cannot be
|
|
4
|
+
synthesized from this side of an API: that class is a decrypted `.kdbx`, and this client has
|
|
5
|
+
neither the file nor the master password on purpose. The `spikes/pykeepass-compatibility` branch
|
|
6
|
+
measures what that costs, member by member. What is offered instead is the
|
|
7
|
+
shape a reader expects, so code written against `pykeepass` mostly moves across, under two rules
|
|
8
|
+
decided before any of it was built.
|
|
9
|
+
|
|
10
|
+
**No `save`.** A method that looks like `pykeepass`'s and does something else is worse than no
|
|
11
|
+
method. Writes go through named calls on the client that say they are talking to a gate.
|
|
12
|
+
|
|
13
|
+
**Absent rather than empty.** Where the gate does not serve something, the attribute does not exist,
|
|
14
|
+
so `hasattr` is the compatibility test and a missing feature is never an empty list. An `Entry` from
|
|
15
|
+
a listing therefore has no `password`: it was not withheld, it was never asked for, and a client
|
|
16
|
+
that treats absence as emptiness would report a database of blank passwords.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
TITLE = "Title"
|
|
26
|
+
USERNAME = "UserName"
|
|
27
|
+
# The name of a field, not one.
|
|
28
|
+
PASSWORD = "Password" # noqa: S105
|
|
29
|
+
URL = "URL"
|
|
30
|
+
NOTES = "Notes"
|
|
31
|
+
|
|
32
|
+
STANDARD = (TITLE, USERNAME, PASSWORD, URL, NOTES)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _optional(value: Any) -> str | None:
|
|
36
|
+
"""A string the gate sent, or nothing when it withheld one."""
|
|
37
|
+
return None if value is None else str(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _moment(text: str | None) -> datetime | None:
|
|
41
|
+
"""RFC 3339 as the gate writes it, or None when it said nothing or said it wrongly."""
|
|
42
|
+
if not text:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Group:
|
|
53
|
+
"""A group, as the gate describes one."""
|
|
54
|
+
|
|
55
|
+
uuid: str
|
|
56
|
+
name: str
|
|
57
|
+
parent: str | None
|
|
58
|
+
notes: str | None = None
|
|
59
|
+
created: datetime | None = None
|
|
60
|
+
modified: datetime | None = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def is_root(self) -> bool:
|
|
64
|
+
"""The one group with nothing above it, which is how a client finds the top."""
|
|
65
|
+
return self.parent is None
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def of(cls, body: dict[str, Any]) -> Group:
|
|
69
|
+
"""Read one out of an answer."""
|
|
70
|
+
return cls(
|
|
71
|
+
uuid=str(body.get("uuid", "")),
|
|
72
|
+
name=str(body.get("name", "")),
|
|
73
|
+
parent=body.get("parent"),
|
|
74
|
+
notes=body.get("notes"),
|
|
75
|
+
created=_moment(body.get("created")),
|
|
76
|
+
modified=_moment(body.get("modified")),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Entry:
|
|
82
|
+
"""An entry, with as much of it as the gate was asked for.
|
|
83
|
+
|
|
84
|
+
A listing fills in what a listing gives: the identifier, the group, the title, the URL and
|
|
85
|
+
the two times. Reading one
|
|
86
|
+
fills in `fields`, and only then do `username`, `password` and `notes` exist at all. Asking for
|
|
87
|
+
one that was never read raises `AttributeError`, which is the point: absence is not emptiness.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
uuid: str
|
|
91
|
+
group: str
|
|
92
|
+
title: str | None
|
|
93
|
+
url: str | None
|
|
94
|
+
created: datetime | None = None
|
|
95
|
+
modified: datetime | None = None
|
|
96
|
+
fields: dict[str, str | None] | None = None
|
|
97
|
+
protected: tuple[str, ...] = field(default_factory=tuple)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def username(self) -> str:
|
|
101
|
+
"""The user name, when this entry has been read."""
|
|
102
|
+
return self._field(USERNAME)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def password(self) -> str:
|
|
106
|
+
"""The password, when this entry has been read."""
|
|
107
|
+
return self._field(PASSWORD)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def notes(self) -> str:
|
|
111
|
+
"""The notes, when this entry has been read."""
|
|
112
|
+
return self._field(NOTES)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_read(self) -> bool:
|
|
116
|
+
"""Whether this came from reading an entry rather than from listing one."""
|
|
117
|
+
return self.fields is not None
|
|
118
|
+
|
|
119
|
+
def custom(self) -> dict[str, str | None]:
|
|
120
|
+
"""Everything that is not one of the five standard strings."""
|
|
121
|
+
if self.fields is None:
|
|
122
|
+
raise AttributeError("this entry was listed rather than read, so it holds no fields")
|
|
123
|
+
|
|
124
|
+
return {name: value for name, value in self.fields.items() if name not in STANDARD}
|
|
125
|
+
|
|
126
|
+
def _field(self, name: str) -> str:
|
|
127
|
+
if self.fields is None:
|
|
128
|
+
raise AttributeError(
|
|
129
|
+
f"this entry was listed rather than read, so it has no {name}. "
|
|
130
|
+
"Read it with GateClient.entry() first."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
value = self.fields.get(name, "")
|
|
134
|
+
if value is None:
|
|
135
|
+
# Withheld rather than absent: the field is there and this client was not given
|
|
136
|
+
# `protected`. Raising keeps the promise the class docstring makes, and stops a
|
|
137
|
+
# stand-in value being mistaken for the real one.
|
|
138
|
+
raise AttributeError(
|
|
139
|
+
f"{name} is protected and this client may not read protected values. "
|
|
140
|
+
"Pair with a profile granting `P` to see it."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def listed(cls, body: dict[str, Any]) -> Entry:
|
|
147
|
+
"""One out of a listing, which carries no values."""
|
|
148
|
+
# A protected title or URL comes back as `null` when the rule lacks `P`, so neither is
|
|
149
|
+
# forced through `str`: the word "None" is not what the gate said.
|
|
150
|
+
return cls(
|
|
151
|
+
uuid=str(body.get("uuid", "")),
|
|
152
|
+
group=str(body.get("group", "")),
|
|
153
|
+
title=_optional(body.get("title")),
|
|
154
|
+
url=_optional(body.get("url")),
|
|
155
|
+
created=_moment(body.get("created")),
|
|
156
|
+
modified=_moment(body.get("modified")),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@classmethod
|
|
160
|
+
def read(cls, body: dict[str, Any]) -> Entry:
|
|
161
|
+
"""One out of a read, which carries everything the entry holds."""
|
|
162
|
+
# `None` survives as `None`: the gate says `null` for a protected value this
|
|
163
|
+
# client may not read, and `str` would turn that into the word "None".
|
|
164
|
+
fields = {
|
|
165
|
+
str(k): (None if v is None else str(v))
|
|
166
|
+
for k, v in dict(body.get("fields", {})).items()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return cls(
|
|
170
|
+
uuid=str(body.get("uuid", "")),
|
|
171
|
+
group=str(body.get("group", "")),
|
|
172
|
+
title=fields.get(TITLE),
|
|
173
|
+
url=fields.get(URL),
|
|
174
|
+
created=_moment(body.get("created")),
|
|
175
|
+
modified=_moment(body.get("modified")),
|
|
176
|
+
fields=fields,
|
|
177
|
+
protected=tuple(str(name) for name in body.get("protected", [])),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class Field:
|
|
183
|
+
"""One of an entry's strings, named and possibly quoted."""
|
|
184
|
+
|
|
185
|
+
name: str
|
|
186
|
+
protected: bool
|
|
187
|
+
value: str | None = None
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def is_read(self) -> bool:
|
|
191
|
+
"""Whether this came from reading the field rather than from listing the names.
|
|
192
|
+
|
|
193
|
+
A protected value the rule does not reach reads as withheld rather than as read: it arrives
|
|
194
|
+
as `None`, the same as a name that was only listed. `protected` is what tells those apart.
|
|
195
|
+
"""
|
|
196
|
+
return self.value is not None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class Attachment:
|
|
201
|
+
"""One of an entry's attachments, named and sized, with its bytes when they were asked for."""
|
|
202
|
+
|
|
203
|
+
name: str
|
|
204
|
+
size: int
|
|
205
|
+
content: bytes | None = None
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def is_read(self) -> bool:
|
|
209
|
+
"""Whether the bytes were fetched, as opposed to only the name and the size."""
|
|
210
|
+
return self.content is not None
|