sci-protocol 0.2.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. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,435 @@
1
+ """Index client: descriptor discovery (§8.1), lookups, submissions, and catalog queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.parse
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from pysci.discovery import (
11
+ IndexDescriptor,
12
+ is_absolute_url,
13
+ parse_index_descriptor,
14
+ parse_publication_lookup,
15
+ parse_timestamp_lookup,
16
+ )
17
+ from pysci.errors import Outcome, SciError
18
+ from pysci.jsonstrict import JsonValue
19
+ from pysci.submission import SubmissionRequest, parse_submission_response
20
+ from pysci.templates import UrlTemplate, parse_template
21
+
22
+ from ..errors import CliError, ErrorRecord, Exit
23
+ from .http import Deadline, HttpClient, NetworkError, Response, status_error
24
+
25
+ DESCRIPTOR_LIMIT = 1 << 20
26
+ CATALOG_SCHEMA = "sciscape_catalog_v1"
27
+ API_SCHEMA = "sciscape-api/0.1"
28
+ CATALOG_FIELDS = "id,title,abstract,public_key,accepted_at"
29
+
30
+
31
+ def descriptor_url(origin: str) -> str:
32
+ return origin.rstrip("/") + "/.well-known/sci/index.json"
33
+
34
+
35
+ def unsupported(capability: str, origin: str, detail: str) -> CliError:
36
+ return CliError(
37
+ "unsupported_capability",
38
+ f"index {origin} does not support {capability}: {detail}",
39
+ Exit.UNSUPPORTED,
40
+ capability=capability,
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Index:
46
+ origin: str
47
+ descriptor: IndexDescriptor
48
+
49
+ def extra_url(self, name: str) -> str | None:
50
+ value = self.descriptor.extra.get(name)
51
+ return value if isinstance(value, str) and is_absolute_url(value) else None
52
+
53
+ def extra_str(self, name: str) -> str | None:
54
+ value = self.descriptor.extra.get(name)
55
+ return value if isinstance(value, str) else None
56
+
57
+ def publication_page(self, digest: str) -> str | None:
58
+ template = self.extra_str("publication_url")
59
+ if template is None:
60
+ return None
61
+ try:
62
+ parsed: UrlTemplate = parse_template(template)
63
+ return parsed.expand({"id": digest})
64
+ except (ValueError, KeyError, SciError):
65
+ return None
66
+
67
+
68
+ def classify_service_error(status: int, code: str, retryable: bool) -> Exit:
69
+ """Map a submission or Sciscape API error to an exit code."""
70
+ if code == "unsupported_schema":
71
+ return Exit.UNSUPPORTED
72
+ if retryable or status in (408, 409, 425, 429) or status >= 500:
73
+ return Exit.INCOMPLETE
74
+ if status in (403, 413) or code in ("policy_refused", "not_baseline"):
75
+ return Exit.REFUSED
76
+ return Exit.INVALID
77
+
78
+
79
+ class IndexClient:
80
+ def __init__(self, http: HttpClient, origin: str) -> None:
81
+ self.http = http
82
+ self.origin = origin
83
+ self._index: Index | None = None
84
+
85
+ def discover(self, deadline: Deadline) -> Index:
86
+ if self._index is not None:
87
+ return self._index
88
+ url = descriptor_url(self.origin)
89
+ response = self.http.get(url, deadline=deadline, max_bytes=DESCRIPTOR_LIMIT)
90
+ if response.status != 200:
91
+ raise status_error(response, f"index descriptor {url}")
92
+ try:
93
+ descriptor = parse_index_descriptor(response.body, file=url)
94
+ except SciError as exc:
95
+ error = CliError.from_sci(exc, f"index descriptor {url} is not usable")
96
+ raise error from None
97
+ self._index = Index(self.origin, descriptor)
98
+ return self._index
99
+
100
+ # ------------------------------------------------------------------ §8.1.1-8.1.3
101
+
102
+ def lookup(self, digest: str, deadline: Deadline) -> list[str]:
103
+ index = self.discover(deadline)
104
+ url = index.descriptor.publication_lookup(digest)
105
+ response = self.http.get(url, deadline=deadline, max_bytes=DESCRIPTOR_LIMIT)
106
+ try:
107
+ return parse_publication_lookup(response.status, response.body)
108
+ except SciError as exc:
109
+ if exc.outcome is Outcome.INCOMPLETE:
110
+ raise status_error(response, "publication lookup") from None
111
+ raise CliError.from_sci(exc, "publication lookup response is invalid") from None
112
+
113
+ def timestamps(self, digest: str, deadline: Deadline) -> bytes | None:
114
+ index = self.discover(deadline)
115
+ url = index.descriptor.timestamp_lookup(digest)
116
+ response = self.http.get(url, deadline=deadline, max_bytes=16 << 20)
117
+ try:
118
+ lookup = parse_timestamp_lookup(response.status, response.body)
119
+ except SciError:
120
+ raise status_error(response, "timestamp lookup") from None
121
+ return lookup.data
122
+
123
+ # ------------------------------------------------------------------ §8.1.4
124
+
125
+ def submit(self, request: SubmissionRequest, deadline: Deadline) -> dict[str, Any]:
126
+ """Submit one publication; return a per-ID result with an exit code.
127
+
128
+ Temporary failures and service errors marked retryable are retried
129
+ with backoff. A lost response stays ``outcome_unknown`` unless a
130
+ repeated request confirms the result.
131
+ """
132
+ index = self.discover(deadline)
133
+ url = index.descriptor.submit_url
134
+ if url is None:
135
+ raise unsupported("publication submission", self.origin, "no submit_url")
136
+ body = request.to_json_bytes()
137
+ lost = False
138
+ try:
139
+ response = self.http.request(
140
+ "POST",
141
+ url,
142
+ deadline=deadline,
143
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
144
+ body=body,
145
+ paced=True,
146
+ should_retry=_retryable_error,
147
+ )
148
+ except NetworkError as exc:
149
+ lost = exc.lost_response
150
+ return {
151
+ "id": request.id,
152
+ "status": "outcome_unknown" if lost else "failed",
153
+ "exit_code": Exit.INCOMPLETE,
154
+ "error": ErrorRecord(
155
+ "outcome_unknown" if lost else "network_error",
156
+ (
157
+ "the submission response was lost; repeat the same request to "
158
+ "confirm the result"
159
+ )
160
+ if lost
161
+ else str(exc),
162
+ True,
163
+ ).to_json(),
164
+ }
165
+ except CliError as exc:
166
+ if exc.code == "deadline_exceeded":
167
+ return {
168
+ "id": request.id,
169
+ "status": "failed",
170
+ "exit_code": Exit.INCOMPLETE,
171
+ "error": exc.records[0].to_json(),
172
+ }
173
+ raise
174
+ try:
175
+ parsed = parse_submission_response(
176
+ response.status, response.header("content-type"), response.body, request.id
177
+ )
178
+ except SciError as exc:
179
+ temporary = response.status >= 500 or response.status == 429
180
+ return {
181
+ "id": request.id,
182
+ "status": "failed",
183
+ "http_status": response.status,
184
+ "exit_code": Exit.INCOMPLETE if temporary else Exit.INVALID,
185
+ "error": ErrorRecord(
186
+ "invalid_response",
187
+ f"submission response violates §8.1.4: {exc.issue.requirement}",
188
+ temporary,
189
+ observed=exc.issue.observed,
190
+ ).to_json(),
191
+ }
192
+ if parsed.error is not None:
193
+ exit_code = classify_service_error(
194
+ response.status, parsed.error.code, parsed.error.retryable
195
+ )
196
+ record = ErrorRecord(parsed.error.code, parsed.error.message, parsed.error.retryable)
197
+ out: dict[str, Any] = {
198
+ "id": request.id,
199
+ "status": "refused" if exit_code == Exit.REFUSED else "failed",
200
+ "http_status": response.status,
201
+ "exit_code": exit_code,
202
+ "error": record.to_json(),
203
+ "response": parsed.body,
204
+ }
205
+ if response.header("retry-after"):
206
+ out["retry_after"] = response.header("retry-after")
207
+ return out
208
+ return {
209
+ "id": request.id,
210
+ "status": "registered",
211
+ "http_status": response.status,
212
+ "exit_code": Exit.OK,
213
+ "attempts": response.attempts,
214
+ "response": parsed.body,
215
+ }
216
+
217
+ # ------------------------------------------------------------------ Sciscape extensions
218
+
219
+ def _api(self, field: str, deadline: Deadline, capability: str) -> str:
220
+ index = self.discover(deadline)
221
+ if index.extra_str("api_schema") != API_SCHEMA:
222
+ raise unsupported(capability, self.origin, f"api_schema is not {API_SCHEMA}")
223
+ url = index.extra_url(field)
224
+ if url is None:
225
+ raise unsupported(capability, self.origin, f"no {field}")
226
+ return url
227
+
228
+ def submit_multipart(
229
+ self,
230
+ field: str,
231
+ capability: str,
232
+ parts: list[tuple[str, str | None, bytes, str]],
233
+ deadline: Deadline,
234
+ ) -> tuple[Response | None, dict[str, Any]]:
235
+ from .http import multipart
236
+
237
+ url = self._api(field, deadline, capability)
238
+ body, content_type = multipart(parts)
239
+ try:
240
+ response = self.http.request(
241
+ "POST",
242
+ url,
243
+ deadline=deadline,
244
+ headers={"Content-Type": content_type, "Accept": "application/json"},
245
+ body=body,
246
+ paced=True,
247
+ should_retry=_retryable_error,
248
+ )
249
+ except NetworkError as exc:
250
+ status = "outcome_unknown" if exc.lost_response else "failed"
251
+ return None, {
252
+ "status": status,
253
+ "exit_code": Exit.INCOMPLETE,
254
+ "error": ErrorRecord(
255
+ status if exc.lost_response else "network_error",
256
+ "the response was lost; repeat the same request to confirm the result"
257
+ if exc.lost_response
258
+ else str(exc),
259
+ True,
260
+ ).to_json(),
261
+ }
262
+ return response, service_result(response, capability)
263
+
264
+ # ------------------------------------------------------------------ catalog
265
+
266
+ def catalog(self, deadline: Deadline) -> tuple[str, Index]:
267
+ index = self.discover(deadline)
268
+ schema = index.extra_str("catalog_schema")
269
+ url = index.extra_url("catalog_url")
270
+ if url is None or schema != CATALOG_SCHEMA:
271
+ raise unsupported(
272
+ "catalog search",
273
+ self.origin,
274
+ f"requires catalog_url with catalog_schema {CATALOG_SCHEMA} (found {schema!r})",
275
+ )
276
+ return url.rstrip("/"), index
277
+
278
+ def search(
279
+ self,
280
+ deadline: Deadline,
281
+ *,
282
+ query: str | None,
283
+ category: tuple[str, str] | None,
284
+ domain: str | None,
285
+ key_x: str | None,
286
+ after: tuple[str, str] | None,
287
+ limit: int,
288
+ ) -> tuple[list[dict[str, Any]], bool, Index]:
289
+ base, index = self.catalog(deadline)
290
+ params: list[tuple[str, str]] = [("select", CATALOG_FIELDS)]
291
+ if query:
292
+ params.append(("search_text", f"plfts(simple).{query}"))
293
+ if category is not None:
294
+ value = json.dumps(
295
+ [{"scheme": category[0], "code": category[1]}], separators=(",", ":")
296
+ )
297
+ params.append(("categories", f"cs.{value}"))
298
+ if domain is not None:
299
+ value = json.dumps([{"type": "domain", "id": domain}], separators=(",", ":"))
300
+ params.append(("admission_accounts", f"cs.{value}"))
301
+ if key_x is not None:
302
+ params.append(("public_key->>x", f"eq.{key_x}"))
303
+ if after is not None:
304
+ time, last = after
305
+ params.append(
306
+ ("or", f"(accepted_at.lt.{time},and(accepted_at.eq.{time},id.lt.{last}))")
307
+ )
308
+ params.append(("order", "accepted_at.desc,id.desc"))
309
+ params.append(("limit", str(limit + 1)))
310
+ url = f"{base}/publications?" + urllib.parse.urlencode(params, safe="(),.>-:")
311
+ response = self.http.get(
312
+ url,
313
+ deadline=deadline,
314
+ headers={"Accept": "application/json", "Accept-Profile": CATALOG_SCHEMA},
315
+ max_bytes=16 << 20,
316
+ )
317
+ if response.status != 200:
318
+ error = status_error(response, "catalog query")
319
+ detail = _postgrest_message(response)
320
+ if detail:
321
+ error.records[0] = ErrorRecord(
322
+ error.code, f"{error.records[0].message}: {detail}", error.records[0].retryable
323
+ )
324
+ raise error
325
+ rows = response.json("catalog response")
326
+ if not isinstance(rows, list):
327
+ raise CliError(
328
+ "invalid_response", "catalog response must be a JSON array", Exit.INVALID
329
+ )
330
+ results = [_catalog_row(row, index) for row in rows[: limit + 1]]
331
+ return results[:limit], len(results) > limit, index
332
+
333
+
334
+ def _retryable_error(response: Response) -> bool:
335
+ if response.content_type != "application/json":
336
+ return False
337
+ try:
338
+ record = json.loads(response.body)
339
+ except ValueError:
340
+ return False
341
+ error = record.get("error") if isinstance(record, dict) else None
342
+ return isinstance(error, dict) and error.get("retryable") is True
343
+
344
+
345
+ def _postgrest_message(response: Response) -> str | None:
346
+ try:
347
+ record = json.loads(response.body)
348
+ except ValueError:
349
+ return None
350
+ if isinstance(record, dict) and isinstance(record.get("message"), str):
351
+ return str(record["message"])[:300]
352
+ return None
353
+
354
+
355
+ def service_result(response: Response, capability: str) -> dict[str, Any]:
356
+ """Interpret a Sciscape custom-write response (success object or error object)."""
357
+ try:
358
+ record = response.json_object(f"{capability} response")
359
+ except CliError as exc:
360
+ temporary = response.status >= 500 or response.status == 429
361
+ return {
362
+ "status": "failed",
363
+ "http_status": response.status,
364
+ "exit_code": Exit.INCOMPLETE if temporary else Exit.INVALID,
365
+ "error": exc.records[0].to_json(),
366
+ }
367
+ if 200 <= response.status < 300:
368
+ return {
369
+ "status": "ok",
370
+ "http_status": response.status,
371
+ "exit_code": Exit.OK,
372
+ "response": record,
373
+ }
374
+ error = record.get("error")
375
+ if (
376
+ isinstance(error, dict)
377
+ and isinstance(error.get("code"), str)
378
+ and isinstance(error.get("message"), str)
379
+ and isinstance(error.get("retryable"), bool)
380
+ ):
381
+ code, message, retryable = (
382
+ str(error["code"]),
383
+ str(error["message"]),
384
+ bool(error["retryable"]),
385
+ )
386
+ exit_code = classify_service_error(response.status, code, retryable)
387
+ return {
388
+ "status": "refused" if exit_code == Exit.REFUSED else "failed",
389
+ "http_status": response.status,
390
+ "exit_code": exit_code,
391
+ "error": ErrorRecord(code, message, retryable).to_json(),
392
+ }
393
+ temporary = response.status >= 500 or response.status == 429
394
+ return {
395
+ "status": "failed",
396
+ "http_status": response.status,
397
+ "exit_code": Exit.INCOMPLETE if temporary else Exit.INVALID,
398
+ "error": ErrorRecord(
399
+ "invalid_response", f"{capability} returned HTTP {response.status}", temporary
400
+ ).to_json(),
401
+ }
402
+
403
+
404
+ def _catalog_row(row: JsonValue, index: Index) -> dict[str, Any]:
405
+ def bad(field: str, value: object) -> CliError:
406
+ return CliError(
407
+ "invalid_response",
408
+ f"catalog row field {field} has an unexpected type",
409
+ Exit.INVALID,
410
+ field=f"/{field}",
411
+ observed=repr(value)[:120],
412
+ )
413
+
414
+ if not isinstance(row, dict):
415
+ raise bad("", row)
416
+ digest = row.get("id")
417
+ if not isinstance(digest, str) or len(digest) != 64:
418
+ raise bad("id", digest)
419
+ for name in ("title", "abstract"):
420
+ if not isinstance(row.get(name), str):
421
+ raise bad(name, row.get(name))
422
+ accepted = row.get("accepted_at")
423
+ if not isinstance(accepted, str):
424
+ raise bad("accepted_at", accepted)
425
+ key = row.get("public_key")
426
+ if not isinstance(key, dict) or not isinstance(key.get("x"), str):
427
+ raise bad("public_key", key)
428
+ return {
429
+ "id": "sha256:" + digest,
430
+ "title": row["title"],
431
+ "abstract": row["abstract"],
432
+ "signer": key,
433
+ "accepted_at": accepted,
434
+ "publication_url": index.publication_page(digest),
435
+ }
@@ -0,0 +1,223 @@
1
+ """RFC 3161 timestamp authority client, trust configuration, and sidecar files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from cryptography import x509
9
+
10
+ from pysci.der import frame_values
11
+ from pysci.timestamps import (
12
+ FREETSA_CRL_URL,
13
+ FREETSA_URL,
14
+ TIMESTAMP_CONTENT_TYPE,
15
+ ProofVerification,
16
+ RevocationPolicy,
17
+ TimestampFileVerification,
18
+ TrustConfig,
19
+ append_proof,
20
+ build_timestamp_request,
21
+ check_response_matches_request,
22
+ freetsa_ca_certificate,
23
+ verify_timestamp_file,
24
+ verify_timestamp_response,
25
+ )
26
+
27
+ from ..config import TrustSettings
28
+ from ..errors import CliError, ErrorRecord, Exit, issue_record
29
+ from ..storage.files import atomic_write_bytes
30
+ from .http import Deadline, HttpClient, status_error
31
+
32
+ MAX_RESPONSE = 1 << 20
33
+
34
+
35
+ def load_crl(blob: bytes) -> x509.CertificateRevocationList:
36
+ """Load a PEM or DER CRL through the library's trust loader."""
37
+ return TrustConfig.from_pem(crls=[blob]).crls[0]
38
+
39
+
40
+ MAX_SIDECAR = 16 << 20
41
+
42
+ __all__ = [
43
+ "FREETSA_URL",
44
+ "TsaResult",
45
+ "append_sidecar",
46
+ "build_trust",
47
+ "read_sidecar",
48
+ "request_proof",
49
+ "verify_sidecar_bytes",
50
+ ]
51
+
52
+
53
+ def build_trust(
54
+ settings: TrustSettings,
55
+ *,
56
+ online: bool = False,
57
+ http: HttpClient | None = None,
58
+ deadline: Deadline | None = None,
59
+ ) -> tuple[TrustConfig, list[str]]:
60
+ """Trust anchors from configuration; with ``online`` fetch CRLs and require them."""
61
+ warnings: list[str] = []
62
+ anchors: list[x509.Certificate] = []
63
+ if settings.freetsa:
64
+ anchors.append(freetsa_ca_certificate())
65
+ for path in settings.anchors:
66
+ try:
67
+ anchors.extend(x509.load_pem_x509_certificates(Path(path).expanduser().read_bytes()))
68
+ except (OSError, ValueError) as exc:
69
+ raise CliError(
70
+ "trust_anchor_unreadable",
71
+ f"timestamp trust anchor {path} could not be loaded: {exc}",
72
+ Exit.USAGE,
73
+ file="config.json",
74
+ field="/timestamp_trust/anchors",
75
+ ) from None
76
+ crls = []
77
+ for path in settings.crls:
78
+ try:
79
+ crls.append(load_crl(Path(path).expanduser().read_bytes()))
80
+ except (OSError, ValueError) as exc:
81
+ warnings.append(f"CRL {path} could not be loaded: {exc}")
82
+ policy = RevocationPolicy.CHECK_SUPPLIED_CRLS
83
+ if online:
84
+ assert http is not None
85
+ assert deadline is not None
86
+ policy = RevocationPolicy.REQUIRE_CURRENT_CRL
87
+ urls = list(settings.crl_urls)
88
+ if settings.freetsa:
89
+ urls.append(FREETSA_CRL_URL)
90
+ for url in urls:
91
+ try:
92
+ response = http.get(url, deadline=deadline, max_bytes=8 << 20)
93
+ if response.status != 200:
94
+ raise status_error(response, f"CRL {url}")
95
+ crls.append(load_crl(response.body))
96
+ except (CliError, ValueError) as exc:
97
+ warnings.append(f"CRL {url} unavailable: {exc}")
98
+ return TrustConfig(anchors=anchors, crls=crls, revocation_policy=policy), warnings
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class TsaResult:
103
+ response: bytes
104
+ verification: ProofVerification
105
+ url: str
106
+
107
+
108
+ def request_proof(
109
+ http: HttpClient,
110
+ url: str,
111
+ signature: bytes,
112
+ trust: TrustConfig,
113
+ deadline: Deadline,
114
+ ) -> TsaResult:
115
+ """Request a timestamp for SHA-256(signature) and check the reply.
116
+
117
+ The response structure, status, imprint, and nonce are required to match
118
+ the request; trust is reported separately in ``verification``.
119
+
120
+ Raises:
121
+ CliError: (exit 5) the TSA was unavailable or returned an unusable reply.
122
+ """
123
+ request = build_timestamp_request(signature)
124
+ response = http.request(
125
+ "POST",
126
+ url,
127
+ deadline=deadline,
128
+ headers={"Content-Type": TIMESTAMP_CONTENT_TYPE},
129
+ body=request.der,
130
+ max_bytes=MAX_RESPONSE,
131
+ paced=True,
132
+ )
133
+ if response.status != 200:
134
+ error = status_error(response, f"timestamp authority {url}")
135
+ error.exit_code = Exit.INCOMPLETE
136
+ raise error
137
+ der = response.body
138
+ framing = frame_values(der, max_value_length=MAX_RESPONSE)
139
+ if len(framing.values) != 1 or not framing.complete:
140
+ raise CliError(
141
+ "timestamp_response_malformed",
142
+ "timestamp authority reply is not exactly one DER TimeStampResp",
143
+ Exit.INCOMPLETE,
144
+ retryable=True,
145
+ url=url,
146
+ )
147
+ issues = check_response_matches_request(der, request)
148
+ if issues:
149
+ error = CliError(
150
+ "timestamp_response_rejected",
151
+ f"timestamp authority reply does not answer the request: {issues[0].requirement}",
152
+ Exit.INCOMPLETE,
153
+ retryable=True,
154
+ url=url,
155
+ )
156
+ error.records.extend(issue_record(i) for i in issues)
157
+ raise error
158
+ verification = verify_timestamp_response(der, signature, trust)
159
+ return TsaResult(der, verification, url)
160
+
161
+
162
+ def read_sidecar(path: Path) -> bytes | None:
163
+ try:
164
+ size = path.stat().st_size
165
+ except FileNotFoundError:
166
+ return None
167
+ if size > MAX_SIDECAR:
168
+ raise CliError(
169
+ "sidecar_too_large",
170
+ f"{path.name} exceeds the local limit of {MAX_SIDECAR} bytes",
171
+ Exit.INCOMPLETE,
172
+ file=str(path),
173
+ )
174
+ return path.read_bytes()
175
+
176
+
177
+ def append_sidecar(path: Path, response: bytes) -> bool:
178
+ """Append a DER response to the sidecar atomically; False if it was already present.
179
+
180
+ Raises:
181
+ CliError: (exit 3) the existing sidecar is malformed; it is preserved.
182
+ """
183
+ existing = read_sidecar(path) or b""
184
+ try:
185
+ combined = append_proof(existing, response)
186
+ except ValueError as exc:
187
+ raise CliError(
188
+ "sidecar_malformed",
189
+ f"existing timestamp sidecar {path.name} is malformed and was preserved: {exc}",
190
+ Exit.INVALID,
191
+ file=str(path),
192
+ ) from None
193
+ if combined == existing:
194
+ return False
195
+ atomic_write_bytes(path, combined)
196
+ return True
197
+
198
+
199
+ def merge_proofs(existing: bytes, incoming: bytes) -> tuple[bytes, list[ErrorRecord]]:
200
+ """Union of complete DER values from ``incoming`` appended to ``existing``."""
201
+ problems: list[ErrorRecord] = []
202
+ framing = frame_values(incoming, max_value_length=MAX_RESPONSE)
203
+ combined = existing
204
+ for offset, length in framing.values:
205
+ try:
206
+ combined = append_proof(combined, incoming[offset : offset + length])
207
+ except ValueError as exc:
208
+ problems.append(ErrorRecord("sidecar_malformed", str(exc)))
209
+ return existing, problems
210
+ if not framing.complete and incoming:
211
+ problems.append(
212
+ ErrorRecord(
213
+ "proof_framing",
214
+ "retrieved timestamp file has trailing bytes that are not a complete DER value",
215
+ )
216
+ )
217
+ return combined, problems
218
+
219
+
220
+ def verify_sidecar_bytes(
221
+ data: bytes, signature: bytes, trust: TrustConfig
222
+ ) -> TimestampFileVerification:
223
+ return verify_timestamp_file(data, signature, trust)
@@ -0,0 +1,44 @@
1
+ """Command registry, in help order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..spec import Command
6
+ from . import (
7
+ check,
8
+ claim,
9
+ folders,
10
+ index,
11
+ keygen,
12
+ license,
13
+ protocol,
14
+ publish,
15
+ pull,
16
+ read,
17
+ revoke,
18
+ search,
19
+ sign,
20
+ submit,
21
+ timestamp,
22
+ verify,
23
+ )
24
+
25
+ COMMANDS: tuple[Command, ...] = (
26
+ search.COMMAND,
27
+ read.COMMAND,
28
+ pull.COMMAND,
29
+ check.COMMAND,
30
+ license.COMMAND,
31
+ sign.COMMAND,
32
+ verify.COMMAND,
33
+ publish.COMMAND,
34
+ submit.COMMAND,
35
+ keygen.COMMAND,
36
+ claim.COMMAND,
37
+ timestamp.COMMAND,
38
+ revoke.COMMAND,
39
+ index.COMMAND,
40
+ folders.COMMAND,
41
+ protocol.COMMAND,
42
+ )
43
+
44
+ __all__ = ["COMMANDS"]