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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- 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"]
|