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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. 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
+ ]