echoact 0.1.0__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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
"""Issuing, storing, and checking the credentials the local REST service uses.
|
|
2
|
+
|
|
3
|
+
F-71 fixes the hard part: the credential is shown once at creation and the app
|
|
4
|
+
keeps only a verifier from which it cannot be recovered. So nothing here ever
|
|
5
|
+
writes a token to disk, to a log, or to a backup -- ``Verifier`` holds a salt
|
|
6
|
+
and an scrypt digest, and that is the whole of what survives ``issue``.
|
|
7
|
+
|
|
8
|
+
N-31 decides the rest of the shape. Because the service listens from first
|
|
9
|
+
launch, this module fails closed: an empty store authenticates nothing, there
|
|
10
|
+
is no default or well-known credential anywhere in a distribution, and the
|
|
11
|
+
owner credential exists only because ``ensure_owner_credential`` *mints* one on
|
|
12
|
+
first launch -- a minted secret is unique to the installation, a shipped one
|
|
13
|
+
would be public the day the installer is.
|
|
14
|
+
|
|
15
|
+
The module is pure policy. It imports no web framework, so F-71's client
|
|
16
|
+
management screen in the GUI evaluates exactly the same rules the service does
|
|
17
|
+
and cannot drift from them (N-24).
|
|
18
|
+
|
|
19
|
+
What this module deliberately does not do: cancel jobs. 5.3 requires a
|
|
20
|
+
revocation to cancel that client's in-progress jobs, but the job engine owns
|
|
21
|
+
that. ``revoke`` and ``set_capabilities`` return the affected client id so the
|
|
22
|
+
caller can do it, which keeps this module free of a dependency on the engine
|
|
23
|
+
and keeps the GUI able to preview a revocation's consequences.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import errno
|
|
30
|
+
import hmac
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import secrets
|
|
35
|
+
import threading
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from contextlib import suppress
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from enum import StrEnum
|
|
40
|
+
from hashlib import scrypt
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Any, Final
|
|
43
|
+
|
|
44
|
+
from ..domain import Capability
|
|
45
|
+
from ..errors import Code, EchoActError
|
|
46
|
+
from ..paths import data_dir
|
|
47
|
+
from ..policy import (
|
|
48
|
+
CREDENTIAL_DAYS_DEFAULT,
|
|
49
|
+
CREDENTIAL_DAYS_MAX,
|
|
50
|
+
CREDENTIAL_DAYS_MIN,
|
|
51
|
+
CREDENTIAL_EXPIRY_WARNING_DAYS,
|
|
52
|
+
)
|
|
53
|
+
from ..util.ids import client_id as mint_client_id
|
|
54
|
+
from ..util.ids import now as wall_now
|
|
55
|
+
|
|
56
|
+
DAY_S: Final = 86_400.0
|
|
57
|
+
|
|
58
|
+
#: Token shape: ``eak_<reference>_<44 base64url characters>``.
|
|
59
|
+
#:
|
|
60
|
+
#: The reference is readable so a person holding two tokens can tell them
|
|
61
|
+
#: apart, and it is also the store's lookup key. It never contains ``_``,
|
|
62
|
+
#: which is what makes ``split("_", 2)`` unambiguous even though base64url's
|
|
63
|
+
#: own alphabet does contain ``_``.
|
|
64
|
+
TOKEN_PREFIX: Final = "eak"
|
|
65
|
+
TOKEN_SECRET_BYTES: Final = 33 # 264 bits -> exactly 44 base64url characters
|
|
66
|
+
TOKEN_SECRET_CHARS: Final = 44
|
|
67
|
+
_REF_RE: Final = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$")
|
|
68
|
+
_SECRET_RE: Final = re.compile(r"^[A-Za-z0-9_-]{32,}$")
|
|
69
|
+
|
|
70
|
+
# scrypt cost. Measured at 35 ms on the development machine (16 MiB), which is
|
|
71
|
+
# 3.5% of N-22's one-second p95 and is paid on every authenticated request. A
|
|
72
|
+
# heavier setting is not obviously right: the secret is 264 bits of CSPRNG
|
|
73
|
+
# output, so the KDF is not protecting a guessable password. It only slows an
|
|
74
|
+
# attacker who has already read the verifier file, and N-19 states outright
|
|
75
|
+
# that this app does not defend against an attacker inside the same OS account.
|
|
76
|
+
# The cost is here because a verifier should not be a bare digest, not because
|
|
77
|
+
# it is the load-bearing defence.
|
|
78
|
+
SCRYPT_N: Final = 1 << 14
|
|
79
|
+
SCRYPT_R: Final = 8
|
|
80
|
+
SCRYPT_P: Final = 1
|
|
81
|
+
SCRYPT_DKLEN: Final = 32
|
|
82
|
+
_SCRYPT_MAXMEM: Final = 64 * 1024 * 1024
|
|
83
|
+
|
|
84
|
+
CREDENTIALS_FILENAME: Final = "credentials.json"
|
|
85
|
+
_SCHEMA_VERSION: Final = 1
|
|
86
|
+
|
|
87
|
+
#: 4.2 excludes credentials from backups. A backup that walks the data
|
|
88
|
+
#: directory must skip this name; there is nothing in the file a restore could
|
|
89
|
+
#: usefully carry anyway, since the tokens themselves are unrecoverable.
|
|
90
|
+
BACKUP_EXCLUDED_FILENAMES: Final = frozenset({CREDENTIALS_FILENAME})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def credentials_path() -> Path:
|
|
94
|
+
"""Where the verifier store lives.
|
|
95
|
+
|
|
96
|
+
``echoact.paths`` has no entry for this file, so it is derived from
|
|
97
|
+
``data_dir()`` here and the ``ECHOACT_DATA_DIR`` override still applies.
|
|
98
|
+
"""
|
|
99
|
+
return data_dir() / CREDENTIALS_FILENAME
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class CredentialStatus(StrEnum):
|
|
103
|
+
"""What F-71's management screen shows next to a client."""
|
|
104
|
+
|
|
105
|
+
ACTIVE = "active"
|
|
106
|
+
EXPIRED = "expired"
|
|
107
|
+
REVOKED = "revoked"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _b64e(raw: bytes) -> str:
|
|
111
|
+
return base64.urlsafe_b64encode(raw).decode("ascii")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _b64d(text: str) -> bytes:
|
|
115
|
+
return base64.urlsafe_b64decode(text.encode("ascii"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _as_object(value: Any, what: str) -> dict[str, Any]:
|
|
119
|
+
"""Insist a value decoded from the store file is a JSON object.
|
|
120
|
+
|
|
121
|
+
Every ``from_record`` below indexes what it is given. A file holding
|
|
122
|
+
``[]``, ``5``, or ``null`` where an object belongs would otherwise reach
|
|
123
|
+
``.get`` and leave the module as a bare ``AttributeError``, which is the
|
|
124
|
+
one thing CLAUDE.md rule 3 forbids. ``ValueError`` is what the callers
|
|
125
|
+
already treat as "the file is damaged".
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(value, dict):
|
|
128
|
+
raise ValueError(f"{what} is {type(value).__name__}, not an object")
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class Verifier:
|
|
134
|
+
"""What replaces the credential in storage (F-71, N-17).
|
|
135
|
+
|
|
136
|
+
scrypt over the *whole* token with a per-credential salt. Hashing the
|
|
137
|
+
reference along with the secret means a verifier lifted from one entry
|
|
138
|
+
cannot be pasted onto another and still match.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
salt: bytes
|
|
142
|
+
digest: bytes
|
|
143
|
+
n: int = SCRYPT_N
|
|
144
|
+
r: int = SCRYPT_R
|
|
145
|
+
p: int = SCRYPT_P
|
|
146
|
+
dklen: int = SCRYPT_DKLEN
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def for_token(cls, token: str, *, salt: bytes | None = None) -> Verifier:
|
|
150
|
+
salt = salt if salt is not None else secrets.token_bytes(16)
|
|
151
|
+
return cls(salt=salt, digest=cls(salt=salt, digest=b"")._derive(token))
|
|
152
|
+
|
|
153
|
+
def _derive(self, token: str) -> bytes:
|
|
154
|
+
return scrypt(
|
|
155
|
+
token.encode("utf-8"),
|
|
156
|
+
salt=self.salt,
|
|
157
|
+
n=self.n,
|
|
158
|
+
r=self.r,
|
|
159
|
+
p=self.p,
|
|
160
|
+
dklen=self.dklen,
|
|
161
|
+
maxmem=_SCRYPT_MAXMEM,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def matches(self, token: str) -> bool:
|
|
165
|
+
"""Constant-time comparison: a byte-by-byte one leaks the digest."""
|
|
166
|
+
return hmac.compare_digest(self._derive(token), self.digest)
|
|
167
|
+
|
|
168
|
+
def to_record(self) -> dict[str, Any]:
|
|
169
|
+
return {
|
|
170
|
+
"algorithm": "scrypt",
|
|
171
|
+
"salt": _b64e(self.salt),
|
|
172
|
+
"digest": _b64e(self.digest),
|
|
173
|
+
"n": self.n,
|
|
174
|
+
"r": self.r,
|
|
175
|
+
"p": self.p,
|
|
176
|
+
"dklen": self.dklen,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_record(cls, d: dict[str, Any]) -> Verifier:
|
|
181
|
+
d = _as_object(d, "verifier")
|
|
182
|
+
if d.get("algorithm") != "scrypt":
|
|
183
|
+
raise ValueError(f"unsupported verifier algorithm {d.get('algorithm')!r}")
|
|
184
|
+
n, r, p = int(d["n"]), int(d["r"]), int(d["p"])
|
|
185
|
+
# The file sits in the user's own data directory, but a corrupted cost
|
|
186
|
+
# still has to be refused rather than handed to scrypt, where a large
|
|
187
|
+
# n turns every later authentication into a memory error.
|
|
188
|
+
if n < 1024 or r < 1 or p < 1 or 128 * r * n > _SCRYPT_MAXMEM:
|
|
189
|
+
raise ValueError("verifier cost is outside the supported range")
|
|
190
|
+
return cls(
|
|
191
|
+
salt=_b64d(d["salt"]),
|
|
192
|
+
digest=_b64d(d["digest"]),
|
|
193
|
+
n=n,
|
|
194
|
+
r=r,
|
|
195
|
+
p=p,
|
|
196
|
+
dklen=int(d["dklen"]),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def __repr__(self) -> str: # never render key material, not even in a traceback
|
|
200
|
+
return f"Verifier(algorithm='scrypt', n={self.n}, r={self.r}, p={self.p})"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(slots=True)
|
|
204
|
+
class Credential:
|
|
205
|
+
"""One integration client: everything F-71 displays, plus a verifier.
|
|
206
|
+
|
|
207
|
+
Mutable because it has a lifecycle -- capabilities change, access is
|
|
208
|
+
recorded, revocation is stamped -- and because the store hands out the live
|
|
209
|
+
object, so a revocation reaches a request that is already in flight (F-71:
|
|
210
|
+
revocation applies immediately, including to result retrieval).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
ref: str
|
|
214
|
+
client_id: str
|
|
215
|
+
name: str
|
|
216
|
+
capabilities: frozenset[Capability]
|
|
217
|
+
created_at: float
|
|
218
|
+
expires_at: float
|
|
219
|
+
verifier: Verifier
|
|
220
|
+
last_access_at: float | None = None
|
|
221
|
+
revoked_at: float | None = None
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def is_owner(self) -> bool:
|
|
225
|
+
return Capability.OWNER in self.capabilities
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def effective_capabilities(self) -> frozenset[Capability]:
|
|
229
|
+
"""F-61 grants the three separately; owner implies all of them."""
|
|
230
|
+
if self.is_owner:
|
|
231
|
+
return frozenset(Capability)
|
|
232
|
+
return self.capabilities
|
|
233
|
+
|
|
234
|
+
def has_capability(self, capability: Capability) -> bool:
|
|
235
|
+
return capability in self.effective_capabilities
|
|
236
|
+
|
|
237
|
+
def status(self, now: float | None = None) -> CredentialStatus:
|
|
238
|
+
if self.revoked_at is not None:
|
|
239
|
+
return CredentialStatus.REVOKED
|
|
240
|
+
if (now if now is not None else wall_now()) >= self.expires_at:
|
|
241
|
+
return CredentialStatus.EXPIRED
|
|
242
|
+
return CredentialStatus.ACTIVE
|
|
243
|
+
|
|
244
|
+
def is_usable(self, now: float | None = None) -> bool:
|
|
245
|
+
return self.status(now) is CredentialStatus.ACTIVE
|
|
246
|
+
|
|
247
|
+
def seconds_until_expiry(self, now: float | None = None) -> float:
|
|
248
|
+
return self.expires_at - (now if now is not None else wall_now())
|
|
249
|
+
|
|
250
|
+
def days_until_expiry(self, now: float | None = None) -> float:
|
|
251
|
+
return self.seconds_until_expiry(now) / DAY_S
|
|
252
|
+
|
|
253
|
+
def expiry_warning(self, now: float | None = None) -> bool:
|
|
254
|
+
"""4.1: a notice appears in the app 7 days before expiry.
|
|
255
|
+
|
|
256
|
+
A revoked or already-expired credential is not "about to expire"; that
|
|
257
|
+
is a different notice, so this stays false for both.
|
|
258
|
+
"""
|
|
259
|
+
if self.status(now) is not CredentialStatus.ACTIVE:
|
|
260
|
+
return False
|
|
261
|
+
return self.seconds_until_expiry(now) <= CREDENTIAL_EXPIRY_WARNING_DAYS * DAY_S
|
|
262
|
+
|
|
263
|
+
def to_public_dict(self, now: float | None = None) -> dict[str, Any]:
|
|
264
|
+
"""The projection F-71's screen, F-72's export, and any log may see.
|
|
265
|
+
|
|
266
|
+
It omits the verifier entirely -- salt and digest included. Neither is
|
|
267
|
+
the credential, but 4.2 keeps credentials out of backups and logs, and
|
|
268
|
+
the surest way to honour that is for the only general-purpose
|
|
269
|
+
serialisation of this type to contain no key material at all.
|
|
270
|
+
"""
|
|
271
|
+
return {
|
|
272
|
+
"ref": self.ref,
|
|
273
|
+
"client_id": self.client_id,
|
|
274
|
+
"name": self.name,
|
|
275
|
+
"capabilities": sorted(c.value for c in self.capabilities),
|
|
276
|
+
"effective_capabilities": sorted(c.value for c in self.effective_capabilities),
|
|
277
|
+
"created_at": self.created_at,
|
|
278
|
+
"expires_at": self.expires_at,
|
|
279
|
+
"last_access_at": self.last_access_at,
|
|
280
|
+
"revoked_at": self.revoked_at,
|
|
281
|
+
"status": self.status(now).value,
|
|
282
|
+
"expiry_warning": self.expiry_warning(now),
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def to_record(self) -> dict[str, Any]:
|
|
286
|
+
"""The on-disk form: the public projection plus the verifier.
|
|
287
|
+
|
|
288
|
+
The token is not derivable from anything in here -- scrypt is one-way
|
|
289
|
+
and a 264-bit secret is not searchable -- which is how F-71's "stores
|
|
290
|
+
only a verifier from which the credential cannot be recovered" becomes
|
|
291
|
+
a property of the file rather than a promise about the code.
|
|
292
|
+
|
|
293
|
+
``status`` and ``expiry_warning`` ride along because the public
|
|
294
|
+
projection carries them; they are derived from the timestamps and
|
|
295
|
+
:meth:`from_record` ignores them, so a stale value in the file changes
|
|
296
|
+
nothing.
|
|
297
|
+
"""
|
|
298
|
+
return {**self.to_public_dict(), "verifier": self.verifier.to_record()}
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def from_record(cls, d: dict[str, Any]) -> Credential:
|
|
302
|
+
d = _as_object(d, "credential record")
|
|
303
|
+
return cls(
|
|
304
|
+
ref=str(d["ref"]),
|
|
305
|
+
client_id=str(d["client_id"]),
|
|
306
|
+
name=str(d["name"]),
|
|
307
|
+
capabilities=frozenset(Capability(c) for c in d["capabilities"]),
|
|
308
|
+
created_at=float(d["created_at"]),
|
|
309
|
+
expires_at=float(d["expires_at"]),
|
|
310
|
+
verifier=Verifier.from_record(d["verifier"]),
|
|
311
|
+
last_access_at=None if d.get("last_access_at") is None else float(d["last_access_at"]),
|
|
312
|
+
revoked_at=None if d.get("revoked_at") is None else float(d["revoked_at"]),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def __repr__(self) -> str:
|
|
316
|
+
return (
|
|
317
|
+
f"Credential(ref={self.ref!r}, client_id={self.client_id!r}, "
|
|
318
|
+
f"capabilities={sorted(c.value for c in self.capabilities)})"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass(frozen=True, slots=True)
|
|
323
|
+
class IssuedCredential:
|
|
324
|
+
"""The one moment the token exists (F-71: shown once at creation).
|
|
325
|
+
|
|
326
|
+
Hand it to the owner, let them copy it, drop it. Nothing else in the app
|
|
327
|
+
may keep it, so ``__repr__`` refuses to render it: an accidental
|
|
328
|
+
``log.info("%s", issued)`` would otherwise put a live credential into a
|
|
329
|
+
file N-20 says must never contain one.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
credential: Credential
|
|
333
|
+
token: str
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def ref(self) -> str:
|
|
337
|
+
return self.credential.ref
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def client_id(self) -> str:
|
|
341
|
+
return self.credential.client_id
|
|
342
|
+
|
|
343
|
+
def __repr__(self) -> str:
|
|
344
|
+
return f"IssuedCredential(ref={self.credential.ref!r}, token=<shown once>)"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True, slots=True)
|
|
348
|
+
class PermissionChange:
|
|
349
|
+
"""What a capability edit implies for jobs already running (5.3, F-52)."""
|
|
350
|
+
|
|
351
|
+
client_id: str
|
|
352
|
+
before: frozenset[Capability]
|
|
353
|
+
after: frozenset[Capability]
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def narrowed(self) -> bool:
|
|
357
|
+
return bool(self.before - self.after)
|
|
358
|
+
|
|
359
|
+
@property
|
|
360
|
+
def cancels_jobs(self) -> bool:
|
|
361
|
+
"""5.3 cancels a client's in-progress jobs when its permission is
|
|
362
|
+
revoked. Losing GENERATE (or OWNER, which implied it) is that case.
|
|
363
|
+
Gaining a capability is not, and losing only a read capability leaves a
|
|
364
|
+
running job legitimate -- its result simply becomes unreadable."""
|
|
365
|
+
lost = self.before - self.after
|
|
366
|
+
return Capability.GENERATE in lost or Capability.OWNER in lost
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _slug(name: str) -> str:
|
|
370
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-")[:24].strip("-")
|
|
371
|
+
return slug or "client"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _validate_days(days: int) -> int:
|
|
375
|
+
if not isinstance(days, int) or days < CREDENTIAL_DAYS_MIN or days > CREDENTIAL_DAYS_MAX:
|
|
376
|
+
raise EchoActError(
|
|
377
|
+
Code.INTERNAL,
|
|
378
|
+
f"Credential lifetime must be between {CREDENTIAL_DAYS_MIN} and "
|
|
379
|
+
f"{CREDENTIAL_DAYS_MAX} days.",
|
|
380
|
+
detail={"days": days, "min": CREDENTIAL_DAYS_MIN, "max": CREDENTIAL_DAYS_MAX},
|
|
381
|
+
)
|
|
382
|
+
return days
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def authorise(
|
|
386
|
+
credential: Credential,
|
|
387
|
+
job_owner_id: str | None,
|
|
388
|
+
capability: Capability,
|
|
389
|
+
*,
|
|
390
|
+
now: float | None = None,
|
|
391
|
+
) -> None:
|
|
392
|
+
"""F-50, F-61, and N-19 in one check. Returns None, or raises.
|
|
393
|
+
|
|
394
|
+
Both halves are checked, always. N-19 says knowing a job id must not by
|
|
395
|
+
itself grant access, so holding the capability is not enough: the object
|
|
396
|
+
has to belong to the caller. Pass ``job_owner_id=None`` for an operation
|
|
397
|
+
that is not about an existing object -- creating a job, listing one's own
|
|
398
|
+
history -- and never as a shortcut for "the owner is unknown".
|
|
399
|
+
|
|
400
|
+
The two failures answer with different codes on purpose. A missing
|
|
401
|
+
capability is FORBIDDEN: the caller's own credential is the problem and
|
|
402
|
+
saying so helps them fix it. A caller asking about *someone else's* job
|
|
403
|
+
gets NOT_FOUND rather than FORBIDDEN, because FORBIDDEN would confirm the
|
|
404
|
+
id exists and turn the endpoint into an oracle for job ids, which is the
|
|
405
|
+
thing N-19 is about. Capability is checked first so that answer never
|
|
406
|
+
depends on whether the id was real.
|
|
407
|
+
"""
|
|
408
|
+
status = credential.status(now)
|
|
409
|
+
if status is CredentialStatus.REVOKED:
|
|
410
|
+
raise EchoActError(Code.CREDENTIAL_REVOKED, detail={"ref": credential.ref})
|
|
411
|
+
if status is CredentialStatus.EXPIRED:
|
|
412
|
+
# F-71: expiry blocks result retrieval, not only new access, so this is
|
|
413
|
+
# re-evaluated per operation rather than once at authentication.
|
|
414
|
+
raise EchoActError(Code.CREDENTIAL_EXPIRED, detail={"ref": credential.ref})
|
|
415
|
+
if not credential.has_capability(capability):
|
|
416
|
+
raise EchoActError(Code.FORBIDDEN, detail={"capability": capability.value})
|
|
417
|
+
if job_owner_id is None or credential.is_owner:
|
|
418
|
+
# F-50: the GUI owner reviews and cancels all jobs.
|
|
419
|
+
return
|
|
420
|
+
if job_owner_id != credential.client_id:
|
|
421
|
+
raise EchoActError(Code.NOT_FOUND)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def can_access(
|
|
425
|
+
credential: Credential,
|
|
426
|
+
job_owner_id: str | None,
|
|
427
|
+
capability: Capability,
|
|
428
|
+
*,
|
|
429
|
+
now: float | None = None,
|
|
430
|
+
) -> bool:
|
|
431
|
+
"""The same rule as :func:`authorise`, as a predicate.
|
|
432
|
+
|
|
433
|
+
F-71's client screen and F-69's job list need to grey an action out rather
|
|
434
|
+
than provoke an error to discover it is unavailable.
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
authorise(credential, job_owner_id, capability, now=now)
|
|
438
|
+
except EchoActError:
|
|
439
|
+
return False
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class CredentialStore:
|
|
444
|
+
"""The set of issued credentials, persisted as verifiers.
|
|
445
|
+
|
|
446
|
+
Thread-safe: the REST service authenticates from several threads while the
|
|
447
|
+
GUI edits permissions on its own. The lock is deliberately *not* held
|
|
448
|
+
across the scrypt derivation -- that would serialise every request behind a
|
|
449
|
+
35 ms hash -- so ``authenticate`` copies the verifier out, derives outside
|
|
450
|
+
the lock, then re-reads the entry to decide on state that may have changed
|
|
451
|
+
meanwhile. A revocation therefore wins a race against an in-flight
|
|
452
|
+
authentication, which is the direction F-71 wants it decided.
|
|
453
|
+
"""
|
|
454
|
+
|
|
455
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
456
|
+
self._path = path if path is not None else credentials_path()
|
|
457
|
+
self._lock = threading.RLock()
|
|
458
|
+
self._by_ref: dict[str, Credential] = {}
|
|
459
|
+
self._write_error: EchoActError | None = None
|
|
460
|
+
# An unknown reference must cost what a known one costs. Without this
|
|
461
|
+
# decoy, response time answers "does this client exist?" for free.
|
|
462
|
+
self._decoy = Verifier.for_token(
|
|
463
|
+
f"{TOKEN_PREFIX}_decoy_{secrets.token_urlsafe(TOKEN_SECRET_BYTES)}"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# -- construction ---------------------------------------------------
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
def load(cls, path: Path | None = None) -> CredentialStore:
|
|
470
|
+
"""Read the store, or return an empty one on first launch.
|
|
471
|
+
|
|
472
|
+
A file that exists but cannot be parsed is an error, not an empty
|
|
473
|
+
store. Treating it as empty would mint a fresh owner credential over
|
|
474
|
+
the top on the next call and silently revoke every client the user had
|
|
475
|
+
registered; F-79's answer -- report the integration unavailable and
|
|
476
|
+
keep the rest of the app working -- is the better failure.
|
|
477
|
+
|
|
478
|
+
That answer only holds if *every* damaged shape arrives as an
|
|
479
|
+
``EchoActError``. A file is read as bytes and decoded inside the
|
|
480
|
+
parse guard on purpose: ``UnicodeDecodeError`` is a ``ValueError``,
|
|
481
|
+
never an ``OSError``, so decoding in the read guard would let a file
|
|
482
|
+
of arbitrary bytes -- a truncated write, a UTF-16 editor -- escape as
|
|
483
|
+
a raw builtin. The shape of the decoded document is checked for the
|
|
484
|
+
same reason: this runs at launch, and a caller that catches
|
|
485
|
+
``EchoActError`` to mark the integrations unavailable (5.3) must not
|
|
486
|
+
be bypassed by whatever happens to be in the file.
|
|
487
|
+
"""
|
|
488
|
+
store = cls(path)
|
|
489
|
+
try:
|
|
490
|
+
raw = store._path.read_bytes()
|
|
491
|
+
except FileNotFoundError:
|
|
492
|
+
return store
|
|
493
|
+
except OSError as exc:
|
|
494
|
+
raise EchoActError(
|
|
495
|
+
Code.INTERNAL, "The credential store could not be read.", cause=exc
|
|
496
|
+
) from exc
|
|
497
|
+
try:
|
|
498
|
+
doc = _as_object(json.loads(raw.decode("utf-8")), "credential store")
|
|
499
|
+
if int(doc.get("schema", 0)) != _SCHEMA_VERSION:
|
|
500
|
+
raise ValueError(f"unsupported credential schema {doc.get('schema')!r}")
|
|
501
|
+
records = doc["credentials"]
|
|
502
|
+
if not isinstance(records, list):
|
|
503
|
+
raise ValueError(f"credentials is {type(records).__name__}, not a list")
|
|
504
|
+
for record in records:
|
|
505
|
+
cred = Credential.from_record(record)
|
|
506
|
+
store._by_ref[cred.ref] = cred
|
|
507
|
+
except (ValueError, KeyError, TypeError, AttributeError) as exc:
|
|
508
|
+
# AttributeError is a backstop, not the plan: the shape checks
|
|
509
|
+
# above are what keep it from arising. It is caught anyway
|
|
510
|
+
# because rule 3 is about what leaves the module, and a store file
|
|
511
|
+
# is untrusted input in the sense that matters here -- nothing in
|
|
512
|
+
# the app wrote the bytes that are actually there.
|
|
513
|
+
raise EchoActError(
|
|
514
|
+
Code.INTERNAL, "The credential store is damaged.", cause=exc
|
|
515
|
+
) from exc
|
|
516
|
+
return store
|
|
517
|
+
|
|
518
|
+
# -- reading --------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
def __len__(self) -> int:
|
|
521
|
+
with self._lock:
|
|
522
|
+
return len(self._by_ref)
|
|
523
|
+
|
|
524
|
+
def list(self) -> list[Credential]:
|
|
525
|
+
"""Every client, newest first -- F-71's management screen."""
|
|
526
|
+
with self._lock:
|
|
527
|
+
return sorted(self._by_ref.values(), key=lambda c: c.created_at, reverse=True)
|
|
528
|
+
|
|
529
|
+
def get(self, ref: str) -> Credential:
|
|
530
|
+
with self._lock:
|
|
531
|
+
cred = self._by_ref.get(ref)
|
|
532
|
+
if cred is None:
|
|
533
|
+
raise EchoActError(Code.NOT_FOUND, detail={"ref": ref})
|
|
534
|
+
return cred
|
|
535
|
+
|
|
536
|
+
def find_by_client(self, client_id: str) -> Credential | None:
|
|
537
|
+
with self._lock:
|
|
538
|
+
for cred in self._by_ref.values():
|
|
539
|
+
if cred.client_id == client_id:
|
|
540
|
+
return cred
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
def owner_credential(self) -> Credential | None:
|
|
544
|
+
with self._lock:
|
|
545
|
+
for cred in self._by_ref.values():
|
|
546
|
+
if cred.is_owner and cred.revoked_at is None:
|
|
547
|
+
return cred
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
def expiring_soon(self, *, now: float | None = None) -> list[Credential]:
|
|
551
|
+
"""4.1's seven-day notice, for whichever surface draws it."""
|
|
552
|
+
at = now if now is not None else wall_now()
|
|
553
|
+
return [c for c in self.list() if c.expiry_warning(at)]
|
|
554
|
+
|
|
555
|
+
def export_public(self, *, now: float | None = None) -> list[dict[str, Any]]:
|
|
556
|
+
"""Credential state with no key material, for the GUI and F-72.
|
|
557
|
+
|
|
558
|
+
There is no ``export_private`` counterpart. A backup gets nothing at
|
|
559
|
+
all from this module (4.2); a diagnostic export gets this.
|
|
560
|
+
"""
|
|
561
|
+
at = now if now is not None else wall_now()
|
|
562
|
+
return [c.to_public_dict(at) for c in self.list()]
|
|
563
|
+
|
|
564
|
+
# -- issuing --------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
def issue(
|
|
567
|
+
self,
|
|
568
|
+
name: str,
|
|
569
|
+
capabilities: frozenset[Capability] | set[Capability] | tuple[Capability, ...],
|
|
570
|
+
*,
|
|
571
|
+
days: int = CREDENTIAL_DAYS_DEFAULT,
|
|
572
|
+
now: float | None = None,
|
|
573
|
+
) -> IssuedCredential:
|
|
574
|
+
"""Mint a credential for a new client. The token is returned once.
|
|
575
|
+
|
|
576
|
+
4.1 sets the default lifetime at 90 days and lets the owner choose 1 to
|
|
577
|
+
365. Every call mints its own client id, its own salt, and its own
|
|
578
|
+
secret, so N-31's "no credential is shared between clients" is a
|
|
579
|
+
property of the minting rather than of anyone's discipline.
|
|
580
|
+
"""
|
|
581
|
+
_validate_days(days)
|
|
582
|
+
at = now if now is not None else wall_now()
|
|
583
|
+
caps = frozenset(capabilities)
|
|
584
|
+
with self._lock:
|
|
585
|
+
ref = self._unique_ref(name)
|
|
586
|
+
token = f"{TOKEN_PREFIX}_{ref}_{secrets.token_urlsafe(TOKEN_SECRET_BYTES)}"
|
|
587
|
+
cred = Credential(
|
|
588
|
+
ref=ref,
|
|
589
|
+
client_id=mint_client_id(),
|
|
590
|
+
name=name,
|
|
591
|
+
capabilities=caps,
|
|
592
|
+
created_at=at,
|
|
593
|
+
expires_at=at + days * DAY_S,
|
|
594
|
+
verifier=Verifier.for_token(token),
|
|
595
|
+
)
|
|
596
|
+
self._by_ref[ref] = cred
|
|
597
|
+
|
|
598
|
+
def _undo() -> None:
|
|
599
|
+
del self._by_ref[ref]
|
|
600
|
+
|
|
601
|
+
self._save_or_undo_locked(_undo)
|
|
602
|
+
return IssuedCredential(credential=cred, token=token)
|
|
603
|
+
|
|
604
|
+
def ensure_owner_credential(
|
|
605
|
+
self,
|
|
606
|
+
*,
|
|
607
|
+
name: str = "Owner",
|
|
608
|
+
days: int = CREDENTIAL_DAYS_DEFAULT,
|
|
609
|
+
now: float | None = None,
|
|
610
|
+
) -> IssuedCredential | None:
|
|
611
|
+
"""F-71: create the owner credential on first launch, or do nothing.
|
|
612
|
+
|
|
613
|
+
Returns the issued credential the first time so the caller can show it
|
|
614
|
+
for copying, and ``None`` afterwards -- there is no way to ask for it
|
|
615
|
+
again, because nothing kept it. A lost owner credential is reissued
|
|
616
|
+
(:meth:`reissue`), the same path every other client uses.
|
|
617
|
+
|
|
618
|
+
This is precisely why a distribution contains no credential at all
|
|
619
|
+
(N-31): the secret comes into existence on the user's own machine, at
|
|
620
|
+
first launch, and differs between every installation.
|
|
621
|
+
"""
|
|
622
|
+
with self._lock:
|
|
623
|
+
if self.owner_credential() is not None:
|
|
624
|
+
return None
|
|
625
|
+
return self.issue(name, frozenset({Capability.OWNER}), days=days, now=now)
|
|
626
|
+
|
|
627
|
+
def reissue(
|
|
628
|
+
self, ref: str, *, days: int | None = None, now: float | None = None
|
|
629
|
+
) -> IssuedCredential:
|
|
630
|
+
"""Replace a lost or expired credential for the same client (F-71, 4.1).
|
|
631
|
+
|
|
632
|
+
The client id and the capabilities are kept, so jobs and history the
|
|
633
|
+
client already owns stay reachable -- 4.1 says access is possible again
|
|
634
|
+
with reissued credentials for the same client. The old verifier is
|
|
635
|
+
replaced, so the previous token stops working the moment this returns.
|
|
636
|
+
|
|
637
|
+
A revoked credential is not reissued. Revocation is a deliberate act
|
|
638
|
+
that also cancelled that client's jobs (5.3); quietly undoing it here
|
|
639
|
+
would make the owner's decision reversible by accident. Issue a new
|
|
640
|
+
client instead.
|
|
641
|
+
"""
|
|
642
|
+
at = now if now is not None else wall_now()
|
|
643
|
+
with self._lock:
|
|
644
|
+
cred = self.get(ref)
|
|
645
|
+
if cred.revoked_at is not None:
|
|
646
|
+
raise EchoActError(Code.CREDENTIAL_REVOKED, detail={"ref": ref})
|
|
647
|
+
lifetime = _validate_days(days) if days is not None else CREDENTIAL_DAYS_DEFAULT
|
|
648
|
+
token = f"{TOKEN_PREFIX}_{ref}_{secrets.token_urlsafe(TOKEN_SECRET_BYTES)}"
|
|
649
|
+
was_verifier, was_expiry = cred.verifier, cred.expires_at
|
|
650
|
+
cred.verifier = Verifier.for_token(token)
|
|
651
|
+
cred.expires_at = at + lifetime * DAY_S
|
|
652
|
+
|
|
653
|
+
def _undo() -> None:
|
|
654
|
+
# The old token has to keep working if the new one was never
|
|
655
|
+
# written down: otherwise a failed save silently locks the
|
|
656
|
+
# client out until someone reissues again.
|
|
657
|
+
cred.verifier = was_verifier
|
|
658
|
+
cred.expires_at = was_expiry
|
|
659
|
+
|
|
660
|
+
self._save_or_undo_locked(_undo)
|
|
661
|
+
return IssuedCredential(credential=cred, token=token)
|
|
662
|
+
|
|
663
|
+
# -- changing -------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def set_capabilities(
|
|
666
|
+
self, ref: str, capabilities: frozenset[Capability] | set[Capability]
|
|
667
|
+
) -> PermissionChange:
|
|
668
|
+
"""F-71's permission change, effective immediately.
|
|
669
|
+
|
|
670
|
+
Immediately means no reissue and no restart: authentication and
|
|
671
|
+
:meth:`authorise` read the live entry, so the next call already sees
|
|
672
|
+
the new set. The returned change says whether the caller must now
|
|
673
|
+
cancel that client's jobs (5.3); this module never cancels anything.
|
|
674
|
+
"""
|
|
675
|
+
after = frozenset(capabilities)
|
|
676
|
+
with self._lock:
|
|
677
|
+
cred = self.get(ref)
|
|
678
|
+
before = cred.capabilities
|
|
679
|
+
cred.capabilities = after
|
|
680
|
+
|
|
681
|
+
def _undo() -> None:
|
|
682
|
+
cred.capabilities = before
|
|
683
|
+
|
|
684
|
+
self._save_or_undo_locked(_undo)
|
|
685
|
+
return PermissionChange(client_id=cred.client_id, before=before, after=after)
|
|
686
|
+
|
|
687
|
+
def revoke(self, ref: str, *, now: float | None = None) -> str:
|
|
688
|
+
"""Revoke a credential and report whose jobs must be cancelled.
|
|
689
|
+
|
|
690
|
+
Returns the client id. 5.3 requires that client's in-progress jobs to
|
|
691
|
+
be cancelled too, and F-52 requires the same when an integration is
|
|
692
|
+
switched off, but cancelling belongs to the job engine -- this returns
|
|
693
|
+
the one fact the engine needs. Revoking twice returns the same id and
|
|
694
|
+
changes nothing further (F-49's "repeated cancellation adds no side
|
|
695
|
+
effects", applied to permissions).
|
|
696
|
+
|
|
697
|
+
Either the revocation is persisted and the client id comes back, or it
|
|
698
|
+
raises and nothing happened. There is no third outcome: a revocation
|
|
699
|
+
kept only in memory would return no client id for the caller to cancel
|
|
700
|
+
jobs with, and would come back to life at the next launch -- the owner
|
|
701
|
+
would have been told the revocation failed while this process quietly
|
|
702
|
+
enforced it, which is the worst of both answers.
|
|
703
|
+
"""
|
|
704
|
+
at = now if now is not None else wall_now()
|
|
705
|
+
with self._lock:
|
|
706
|
+
cred = self.get(ref)
|
|
707
|
+
if cred.revoked_at is None:
|
|
708
|
+
cred.revoked_at = at
|
|
709
|
+
|
|
710
|
+
def _undo() -> None:
|
|
711
|
+
cred.revoked_at = None
|
|
712
|
+
|
|
713
|
+
self._save_or_undo_locked(_undo)
|
|
714
|
+
return cred.client_id
|
|
715
|
+
|
|
716
|
+
def forget(self, ref: str) -> str:
|
|
717
|
+
"""Delete the entry outright, for F-76's "revoke integration permissions".
|
|
718
|
+
|
|
719
|
+
Returns the client id for the same reason :meth:`revoke` does. Prefer
|
|
720
|
+
:meth:`revoke` wherever the owner should still see the client listed
|
|
721
|
+
with a revoked state.
|
|
722
|
+
"""
|
|
723
|
+
with self._lock:
|
|
724
|
+
cred = self.get(ref)
|
|
725
|
+
del self._by_ref[ref]
|
|
726
|
+
|
|
727
|
+
def _undo() -> None:
|
|
728
|
+
# The file still holds the entry, so dropping it from memory
|
|
729
|
+
# only invents a disagreement that the next launch undoes.
|
|
730
|
+
self._by_ref[ref] = cred
|
|
731
|
+
|
|
732
|
+
self._save_or_undo_locked(_undo)
|
|
733
|
+
return cred.client_id
|
|
734
|
+
|
|
735
|
+
# -- authenticating -------------------------------------------------
|
|
736
|
+
|
|
737
|
+
def authenticate(self, token: str, *, now: float | None = None) -> Credential:
|
|
738
|
+
"""Resolve a presented token, or raise.
|
|
739
|
+
|
|
740
|
+
Failures are deliberately uninformative: an unparsable token, an
|
|
741
|
+
unknown reference, and a wrong secret are all UNAUTHENTICATED and all
|
|
742
|
+
cost one scrypt derivation, so neither the answer nor the timing says
|
|
743
|
+
which clients exist. Only once the secret matches does the caller
|
|
744
|
+
learn that the credential is expired or revoked -- learning that is
|
|
745
|
+
harmless, since it required holding the credential.
|
|
746
|
+
|
|
747
|
+
Recording the access is best effort. ``last_access_at`` is something
|
|
748
|
+
F-71's screen displays, not something the decision depends on, and a
|
|
749
|
+
store that cannot be written is not a reason to turn every
|
|
750
|
+
authenticated request -- a status poll included -- into a 500 the
|
|
751
|
+
caller is told not to retry. The failure is kept in
|
|
752
|
+
:attr:`write_error` for the app to report (F-79, 5.3) and the
|
|
753
|
+
timestamp is rolled back, so memory still says what the file says.
|
|
754
|
+
"""
|
|
755
|
+
at = now if now is not None else wall_now()
|
|
756
|
+
parsed = _parse_token(token)
|
|
757
|
+
with self._lock:
|
|
758
|
+
cred = self._by_ref.get(parsed[0]) if parsed is not None else None
|
|
759
|
+
verifier = cred.verifier if cred is not None else self._decoy
|
|
760
|
+
|
|
761
|
+
matched = verifier.matches(token) if isinstance(token, str) else False
|
|
762
|
+
if cred is None or parsed is None or not matched:
|
|
763
|
+
raise EchoActError(Code.UNAUTHENTICATED)
|
|
764
|
+
|
|
765
|
+
with self._lock:
|
|
766
|
+
# Re-read under the lock: a revocation or a reissue may have landed
|
|
767
|
+
# while the derivation ran, and it has to win.
|
|
768
|
+
current = self._by_ref.get(cred.ref)
|
|
769
|
+
if current is None or current.verifier is not verifier:
|
|
770
|
+
raise EchoActError(Code.UNAUTHENTICATED)
|
|
771
|
+
status = current.status(at)
|
|
772
|
+
if status is CredentialStatus.REVOKED:
|
|
773
|
+
raise EchoActError(Code.CREDENTIAL_REVOKED, detail={"ref": current.ref})
|
|
774
|
+
if status is CredentialStatus.EXPIRED:
|
|
775
|
+
raise EchoActError(Code.CREDENTIAL_EXPIRED, detail={"ref": current.ref})
|
|
776
|
+
was_access = current.last_access_at
|
|
777
|
+
current.last_access_at = at
|
|
778
|
+
try:
|
|
779
|
+
self._save_locked()
|
|
780
|
+
except EchoActError:
|
|
781
|
+
current.last_access_at = was_access
|
|
782
|
+
return current
|
|
783
|
+
|
|
784
|
+
def authorise(
|
|
785
|
+
self,
|
|
786
|
+
credential: Credential,
|
|
787
|
+
job_owner_id: str | None,
|
|
788
|
+
capability: Capability,
|
|
789
|
+
*,
|
|
790
|
+
now: float | None = None,
|
|
791
|
+
) -> None:
|
|
792
|
+
""":func:`authorise`, re-resolved against the store's live entry.
|
|
793
|
+
|
|
794
|
+
A request holds a ``Credential`` from the moment it authenticates;
|
|
795
|
+
between then and reading a result the owner may have revoked or
|
|
796
|
+
narrowed it, and F-71 says that applies to result retrieval
|
|
797
|
+
immediately. Looking the reference up again is what makes
|
|
798
|
+
"immediately" true even for a request that has been running a while,
|
|
799
|
+
and it also covers an entry deleted outright by :meth:`forget`.
|
|
800
|
+
"""
|
|
801
|
+
with self._lock:
|
|
802
|
+
current = self._by_ref.get(credential.ref)
|
|
803
|
+
if current is None:
|
|
804
|
+
raise EchoActError(Code.CREDENTIAL_REVOKED, detail={"ref": credential.ref})
|
|
805
|
+
authorise(current, job_owner_id, capability, now=now)
|
|
806
|
+
|
|
807
|
+
# -- persistence ----------------------------------------------------
|
|
808
|
+
|
|
809
|
+
@property
|
|
810
|
+
def write_error(self) -> EchoActError | None:
|
|
811
|
+
"""The last save failure, or None if the store is writable.
|
|
812
|
+
|
|
813
|
+
Only :meth:`authenticate` declines to raise one, and 5.3 still wants
|
|
814
|
+
the save failure *reported*: this is where F-69's service screen and
|
|
815
|
+
F-79's "integrations unavailable" notice read it from. Cleared by the
|
|
816
|
+
next save that works, so it describes the store now rather than
|
|
817
|
+
whatever went wrong once.
|
|
818
|
+
"""
|
|
819
|
+
with self._lock:
|
|
820
|
+
return self._write_error
|
|
821
|
+
|
|
822
|
+
def save(self) -> None:
|
|
823
|
+
with self._lock:
|
|
824
|
+
self._save_locked()
|
|
825
|
+
|
|
826
|
+
def _save_or_undo_locked(self, undo: Callable[[], None]) -> None:
|
|
827
|
+
"""Persist a change, or put memory back as it was and raise.
|
|
828
|
+
|
|
829
|
+
5.3 requires a failed save to preserve previously sound data, and in
|
|
830
|
+
a store that hands out live ``Credential`` objects that has to include
|
|
831
|
+
the objects themselves. Mutating first and saving after is fine; what
|
|
832
|
+
is not fine is keeping the mutation once the caller has been told the
|
|
833
|
+
operation failed, because this process would then enforce a rule that
|
|
834
|
+
is in no file and that the next launch will not reproduce.
|
|
835
|
+
"""
|
|
836
|
+
try:
|
|
837
|
+
self._save_locked()
|
|
838
|
+
except EchoActError:
|
|
839
|
+
undo()
|
|
840
|
+
raise
|
|
841
|
+
|
|
842
|
+
def _save_locked(self) -> None:
|
|
843
|
+
doc = {
|
|
844
|
+
"schema": _SCHEMA_VERSION,
|
|
845
|
+
"credentials": [c.to_record() for c in self._by_ref.values()],
|
|
846
|
+
}
|
|
847
|
+
tmp = self._path.with_name(f"{self._path.name}.{os.getpid()}.tmp")
|
|
848
|
+
try:
|
|
849
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
850
|
+
tmp.write_text(json.dumps(doc, indent=1, sort_keys=True), encoding="utf-8")
|
|
851
|
+
try:
|
|
852
|
+
os.chmod(tmp, 0o600)
|
|
853
|
+
except OSError:
|
|
854
|
+
# Best effort: POSIX modes do not carry on every filesystem, and
|
|
855
|
+
# N-19 already places the same-account attacker out of scope.
|
|
856
|
+
pass
|
|
857
|
+
os.replace(tmp, self._path)
|
|
858
|
+
except OSError as exc:
|
|
859
|
+
with suppress(OSError):
|
|
860
|
+
# Failing to tidy up must not replace the failure worth
|
|
861
|
+
# reporting; the temp file is named per process and is
|
|
862
|
+
# overwritten by the next save either way.
|
|
863
|
+
tmp.unlink(missing_ok=True)
|
|
864
|
+
self._write_error = EchoActError(
|
|
865
|
+
_write_failure_code(exc),
|
|
866
|
+
"The credential store could not be written; the stored "
|
|
867
|
+
"credentials are unchanged.",
|
|
868
|
+
cause=exc,
|
|
869
|
+
)
|
|
870
|
+
raise self._write_error from exc
|
|
871
|
+
self._write_error = None
|
|
872
|
+
|
|
873
|
+
def _unique_ref(self, name: str) -> str:
|
|
874
|
+
base = _slug(name)
|
|
875
|
+
while True:
|
|
876
|
+
ref = f"{base}-{secrets.token_hex(3)}"
|
|
877
|
+
if ref not in self._by_ref:
|
|
878
|
+
return ref
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _write_failure_code(exc: OSError) -> Code:
|
|
882
|
+
"""5.3's "report the save failure", as a code rather than a shrug.
|
|
883
|
+
|
|
884
|
+
A blanket INTERNAL told the owner only that something broke, when the two
|
|
885
|
+
causes that actually happen -- a full disk and a data directory that
|
|
886
|
+
cannot be written -- each have a code that names them and a remedy that
|
|
887
|
+
follows from it. None of the three is retryable in F-57's sense: nothing
|
|
888
|
+
the caller can do to the identical request makes it succeed, so none of
|
|
889
|
+
them carries a retry-after hint (rule 8).
|
|
890
|
+
"""
|
|
891
|
+
if exc.errno in {errno.ENOSPC, errno.EDQUOT}:
|
|
892
|
+
return Code.STORAGE_FULL
|
|
893
|
+
if exc.errno in {errno.EACCES, errno.EPERM, errno.EROFS}:
|
|
894
|
+
return Code.FILE_PERMISSION
|
|
895
|
+
return Code.INTERNAL
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _parse_token(token: str) -> tuple[str, str] | None:
|
|
899
|
+
"""Split ``eak_<ref>_<secret>``, or return None if it is not one of ours.
|
|
900
|
+
|
|
901
|
+
Split from the left with a limit of two: the secret is base64url and may
|
|
902
|
+
contain ``_`` itself, so anything that splits from the right, or on every
|
|
903
|
+
separator, mis-parses a legitimate token roughly half the time.
|
|
904
|
+
"""
|
|
905
|
+
if not isinstance(token, str) or len(token) > 512:
|
|
906
|
+
return None
|
|
907
|
+
parts = token.split("_", 2)
|
|
908
|
+
if len(parts) != 3 or parts[0] != TOKEN_PREFIX:
|
|
909
|
+
return None
|
|
910
|
+
ref, secret = parts[1], parts[2]
|
|
911
|
+
if _REF_RE.match(ref) is None or _SECRET_RE.match(secret) is None:
|
|
912
|
+
return None
|
|
913
|
+
return ref, secret
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
__all__ = [
|
|
917
|
+
"BACKUP_EXCLUDED_FILENAMES",
|
|
918
|
+
"CREDENTIALS_FILENAME",
|
|
919
|
+
"TOKEN_PREFIX",
|
|
920
|
+
"TOKEN_SECRET_CHARS",
|
|
921
|
+
"Credential",
|
|
922
|
+
"CredentialStatus",
|
|
923
|
+
"CredentialStore",
|
|
924
|
+
"IssuedCredential",
|
|
925
|
+
"PermissionChange",
|
|
926
|
+
"Verifier",
|
|
927
|
+
"authorise",
|
|
928
|
+
"can_access",
|
|
929
|
+
"credentials_path",
|
|
930
|
+
]
|