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,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
@@ -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