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,364 @@
|
|
|
1
|
+
"""``sci read``: read verified text or retrieve one file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pysci.bundle import Scope, read_zip_entry, verify_directory, verify_file, verify_zip
|
|
12
|
+
from pysci.errors import SciError
|
|
13
|
+
from pysci.manifest import LICENSE_PATH, METADATA_PATH, README_PATH, Manifest
|
|
14
|
+
from pysci.metadata import parse_metadata
|
|
15
|
+
from pysci.source import FileSource
|
|
16
|
+
from pysci.text import decode_text_file
|
|
17
|
+
|
|
18
|
+
from ..checks import checks, not_performed
|
|
19
|
+
from ..clients.archive import ArchiveSource, SourceFailure, failure_exit
|
|
20
|
+
from ..context import Context
|
|
21
|
+
from ..errors import CliError, Exit, exit_for_outcome, issue_record
|
|
22
|
+
from ..output import Result
|
|
23
|
+
from ..retrieval import resolve_sources
|
|
24
|
+
from ..spec import Command, arg
|
|
25
|
+
from ..storage.files import StoreOutcome, store_bytes
|
|
26
|
+
from .common import looks_like_id, parse_id
|
|
27
|
+
|
|
28
|
+
DEFAULT_LINES = 200
|
|
29
|
+
Getter = Callable[[str], bytes]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _local(path: Path) -> tuple[Manifest, Getter, dict[str, Any]]:
|
|
33
|
+
if path.is_dir():
|
|
34
|
+
verification = verify_directory(path, scope=Scope.MANIFEST)
|
|
35
|
+
else:
|
|
36
|
+
verification = verify_zip(path, scope=Scope.MANIFEST)
|
|
37
|
+
if not verification.ok or verification.manifest is None:
|
|
38
|
+
error = CliError(
|
|
39
|
+
verification.issues[0].code if verification.issues else "invalid",
|
|
40
|
+
"manifest checks failed (§6.1)",
|
|
41
|
+
exit_for_outcome(verification.outcome),
|
|
42
|
+
)
|
|
43
|
+
error.records.extend(issue_record(i) for i in verification.issues)
|
|
44
|
+
raise error
|
|
45
|
+
manifest = verification.manifest
|
|
46
|
+
|
|
47
|
+
def get(member: str) -> bytes:
|
|
48
|
+
try:
|
|
49
|
+
if path.is_dir():
|
|
50
|
+
data = (path / member).read_bytes()
|
|
51
|
+
verify_file(manifest, member, data)
|
|
52
|
+
return data
|
|
53
|
+
with FileSource(path) as source:
|
|
54
|
+
return read_zip_entry(source, member, expected_id=manifest.publication_id)[1]
|
|
55
|
+
except SciError as exc:
|
|
56
|
+
raise CliError.from_sci(exc, f"{member} failed file checks (§6.2)") from None
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise CliError("input_unavailable", str(exc), Exit.INCOMPLETE, retryable=True) from None
|
|
59
|
+
|
|
60
|
+
return manifest, get, {"kind": "directory" if path.is_dir() else "zip", "path": str(path)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _remote(
|
|
64
|
+
args: argparse.Namespace, ctx: Context, digest: str, workdir: Path
|
|
65
|
+
) -> tuple[Manifest, Getter, dict[str, Any]]:
|
|
66
|
+
deadline = ctx.deadline(args.timeout)
|
|
67
|
+
sources = resolve_sources(
|
|
68
|
+
ctx, digest, archives=args.archive, index_url=args.in_, deadline=deadline
|
|
69
|
+
)
|
|
70
|
+
failures: list[SourceFailure] = []
|
|
71
|
+
archives = [ArchiveSource(ctx.http, url, digest, workdir) for url in sources.descriptors]
|
|
72
|
+
usable: list[ArchiveSource] = []
|
|
73
|
+
manifest: Manifest | None = None
|
|
74
|
+
for archive in archives:
|
|
75
|
+
try:
|
|
76
|
+
manifest = archive.manifest(deadline)
|
|
77
|
+
usable.append(archive)
|
|
78
|
+
except SourceFailure as exc:
|
|
79
|
+
exc.record.extra["descriptor"] = archive.descriptor_url
|
|
80
|
+
failures.append(exc)
|
|
81
|
+
if manifest is None:
|
|
82
|
+
error = CliError(
|
|
83
|
+
"retrieval_failed",
|
|
84
|
+
f"no source provided a verified manifest for sha256:{digest}",
|
|
85
|
+
failure_exit(failures),
|
|
86
|
+
retryable=failure_exit(failures) == Exit.INCOMPLETE,
|
|
87
|
+
)
|
|
88
|
+
error.records.extend(f.record for f in failures)
|
|
89
|
+
raise error
|
|
90
|
+
|
|
91
|
+
def get(member: str) -> bytes:
|
|
92
|
+
errors: list[SourceFailure] = []
|
|
93
|
+
for archive in usable:
|
|
94
|
+
try:
|
|
95
|
+
return archive.file(member, deadline)
|
|
96
|
+
except SourceFailure as exc:
|
|
97
|
+
exc.record.extra["descriptor"] = archive.descriptor_url
|
|
98
|
+
errors.append(exc)
|
|
99
|
+
error = CliError(
|
|
100
|
+
"retrieval_failed",
|
|
101
|
+
f"no source provided verified bytes for {member}",
|
|
102
|
+
failure_exit(errors),
|
|
103
|
+
retryable=failure_exit(errors) == Exit.INCOMPLETE,
|
|
104
|
+
)
|
|
105
|
+
error.records.extend(e.record for e in errors)
|
|
106
|
+
raise error
|
|
107
|
+
|
|
108
|
+
info: dict[str, Any] = {
|
|
109
|
+
"kind": "remote",
|
|
110
|
+
"descriptors": sources.descriptors,
|
|
111
|
+
"index": sources.index.origin if sources.index else None,
|
|
112
|
+
"used": [a.descriptor_url for a in usable],
|
|
113
|
+
"source_failures": [f.record.to_json() for f in failures],
|
|
114
|
+
}
|
|
115
|
+
info["_archives"] = usable
|
|
116
|
+
return manifest, get, info
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _text_range(text: str, path: str, start: int, count: int) -> tuple[dict[str, Any], list[str]]:
|
|
120
|
+
lines = text.splitlines()
|
|
121
|
+
begin = max(start, 1) - 1
|
|
122
|
+
selected = lines[begin : begin + count]
|
|
123
|
+
end = begin + len(selected)
|
|
124
|
+
truncated = end < len(lines)
|
|
125
|
+
info = {
|
|
126
|
+
"path": path,
|
|
127
|
+
"start_line": begin + 1,
|
|
128
|
+
"end_line": end,
|
|
129
|
+
"total_lines": len(lines),
|
|
130
|
+
"truncated": truncated,
|
|
131
|
+
"next_line": end + 1 if truncated else None,
|
|
132
|
+
"lines": selected,
|
|
133
|
+
}
|
|
134
|
+
return info, selected
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
138
|
+
if args.list and args.file:
|
|
139
|
+
raise CliError("conflicting_options", "--list and --file cannot be combined")
|
|
140
|
+
if args.output and not args.file:
|
|
141
|
+
raise CliError("invalid_arguments", "--output requires --file")
|
|
142
|
+
if args.start_line < 1 or args.lines < 1:
|
|
143
|
+
raise CliError("invalid_arguments", "--start-line and --lines must be positive")
|
|
144
|
+
target = args.id_or_path
|
|
145
|
+
remote = looks_like_id(target)
|
|
146
|
+
if not remote and (args.archive or args.in_):
|
|
147
|
+
raise CliError("invalid_arguments", "--archive and --in apply only to publication IDs")
|
|
148
|
+
with tempfile.TemporaryDirectory(prefix="sci-read-") as workdir:
|
|
149
|
+
if remote:
|
|
150
|
+
manifest, get, source = _remote(args, ctx, parse_id(target), Path(workdir))
|
|
151
|
+
else:
|
|
152
|
+
path = ctx.resolve(target)
|
|
153
|
+
if not path.exists():
|
|
154
|
+
raise CliError(
|
|
155
|
+
"input_missing",
|
|
156
|
+
f"{path} does not exist (publication IDs start with sha256:)",
|
|
157
|
+
Exit.USAGE,
|
|
158
|
+
)
|
|
159
|
+
manifest, get, source = _local(path)
|
|
160
|
+
try:
|
|
161
|
+
return _render(args, ctx, manifest, get, source)
|
|
162
|
+
finally:
|
|
163
|
+
for archive in source.pop("_archives", []):
|
|
164
|
+
archive.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _render(
|
|
168
|
+
args: argparse.Namespace,
|
|
169
|
+
ctx: Context,
|
|
170
|
+
manifest: Manifest,
|
|
171
|
+
get: Getter,
|
|
172
|
+
source: dict[str, Any],
|
|
173
|
+
) -> Result:
|
|
174
|
+
result = Result("read")
|
|
175
|
+
verified: list[str] = []
|
|
176
|
+
data: dict[str, Any] = {
|
|
177
|
+
"publication_id": manifest.publication_id,
|
|
178
|
+
"signer": manifest.public_key.to_jwk(),
|
|
179
|
+
"source": source,
|
|
180
|
+
}
|
|
181
|
+
result.data = data
|
|
182
|
+
result.say(f"publication: {manifest.publication_id}")
|
|
183
|
+
if args.list:
|
|
184
|
+
files: list[dict[str, Any]] = [
|
|
185
|
+
{"path": p, "bytes": manifest.files[p].bytes, "sha256": manifest.files[p].sha256}
|
|
186
|
+
for p in manifest.sorted_paths()
|
|
187
|
+
]
|
|
188
|
+
data["files"] = files
|
|
189
|
+
data["verified"] = "manifest checks (§6.1); listed files were not retrieved"
|
|
190
|
+
for entry in files:
|
|
191
|
+
result.say(f"{entry['bytes']:>12} {entry['sha256'][:16]} {entry['path']}")
|
|
192
|
+
elif args.file:
|
|
193
|
+
member = args.file
|
|
194
|
+
if member not in manifest.files:
|
|
195
|
+
raise CliError(
|
|
196
|
+
"file_not_listed",
|
|
197
|
+
f"{member} is not listed in the manifest",
|
|
198
|
+
Exit.INVALID,
|
|
199
|
+
file=member,
|
|
200
|
+
section="6.2",
|
|
201
|
+
)
|
|
202
|
+
content = get(member)
|
|
203
|
+
verified.append(member)
|
|
204
|
+
if args.output:
|
|
205
|
+
target = ctx.resolve(args.output)
|
|
206
|
+
stored = store_bytes(target, content)
|
|
207
|
+
if stored.outcome is StoreOutcome.CONFLICT:
|
|
208
|
+
raise CliError(
|
|
209
|
+
"destination_conflict",
|
|
210
|
+
f"{target} exists with different bytes; it was left unchanged",
|
|
211
|
+
Exit.INVALID,
|
|
212
|
+
file=str(target),
|
|
213
|
+
)
|
|
214
|
+
data["output"] = {
|
|
215
|
+
"path": str(target),
|
|
216
|
+
"status": str(stored.outcome),
|
|
217
|
+
"bytes": len(content),
|
|
218
|
+
}
|
|
219
|
+
result.say(f"saved {member} ({len(content)} bytes, verified) to {target}")
|
|
220
|
+
else:
|
|
221
|
+
try:
|
|
222
|
+
text = content.decode("utf-8")
|
|
223
|
+
if "\x00" in text:
|
|
224
|
+
raise UnicodeDecodeError("utf-8", content, 0, 1, "NUL byte")
|
|
225
|
+
except UnicodeDecodeError:
|
|
226
|
+
raise CliError(
|
|
227
|
+
"binary_file",
|
|
228
|
+
f"{member} is not UTF-8 text; save it with --output FILE",
|
|
229
|
+
Exit.USAGE,
|
|
230
|
+
file=member,
|
|
231
|
+
) from None
|
|
232
|
+
info, lines = _text_range(text, member, args.start_line, args.lines)
|
|
233
|
+
data["text"] = info
|
|
234
|
+
result.lines.extend(lines)
|
|
235
|
+
_truncation(result, info)
|
|
236
|
+
else:
|
|
237
|
+
metadata_bytes = get(METADATA_PATH)
|
|
238
|
+
readme = get(README_PATH)
|
|
239
|
+
verified += [METADATA_PATH, README_PATH]
|
|
240
|
+
try:
|
|
241
|
+
metadata = parse_metadata(metadata_bytes, list(manifest.files))
|
|
242
|
+
text = decode_text_file(readme, file=README_PATH, nonblank=True)
|
|
243
|
+
if LICENSE_PATH in manifest.files:
|
|
244
|
+
decode_text_file(get(LICENSE_PATH), file=LICENSE_PATH)
|
|
245
|
+
verified.append(LICENSE_PATH)
|
|
246
|
+
except SciError as exc:
|
|
247
|
+
raise CliError.from_sci(exc, "metadata checks failed (§6.3)") from None
|
|
248
|
+
raw = metadata.raw
|
|
249
|
+
data["metadata"] = {
|
|
250
|
+
"title": metadata.title,
|
|
251
|
+
"abstract": metadata.abstract,
|
|
252
|
+
"categories": raw.get("categories", []),
|
|
253
|
+
"keywords": list(metadata.keywords),
|
|
254
|
+
"display": raw.get("display", []),
|
|
255
|
+
"citations": raw.get("citations", []),
|
|
256
|
+
"license": LICENSE_PATH in manifest.files,
|
|
257
|
+
}
|
|
258
|
+
info, lines = _text_range(text, README_PATH, args.start_line, args.lines)
|
|
259
|
+
data["text"] = info
|
|
260
|
+
result.say(f"title: {metadata.title}")
|
|
261
|
+
result.say(f"signer x: {manifest.public_key.x}")
|
|
262
|
+
if metadata.keywords:
|
|
263
|
+
result.say(f"keywords: {', '.join(metadata.keywords)}")
|
|
264
|
+
result.say("abstract:")
|
|
265
|
+
result.lines.extend(" " + line for line in metadata.abstract.splitlines())
|
|
266
|
+
if metadata.citations:
|
|
267
|
+
result.say(f"citations: {len(metadata.citations)}")
|
|
268
|
+
citations = raw.get("citations")
|
|
269
|
+
for citation in citations if isinstance(citations, list) else []:
|
|
270
|
+
if isinstance(citation, dict):
|
|
271
|
+
listed = citation.get("relations")
|
|
272
|
+
names = [str(r) for r in listed] if isinstance(listed, list) else []
|
|
273
|
+
relations = ",".join(names)
|
|
274
|
+
result.say(
|
|
275
|
+
f" {citation.get('target')}" + (f" [{relations}]" if relations else "")
|
|
276
|
+
)
|
|
277
|
+
result.say("")
|
|
278
|
+
result.say(f"--- {README_PATH} ---")
|
|
279
|
+
result.lines.extend(lines)
|
|
280
|
+
_truncation(result, info)
|
|
281
|
+
data["verified_files"] = verified
|
|
282
|
+
data["checks"] = checks(
|
|
283
|
+
bundle=not_performed(
|
|
284
|
+
"complete bundle checks (§6.4) were not performed; only the listed files passed "
|
|
285
|
+
"file checks (§6.2)"
|
|
286
|
+
),
|
|
287
|
+
baseline=not_performed("baseline is evaluated only after complete bundle verification"),
|
|
288
|
+
timestamps=not_performed("timestamps are checked by `sci verify` and `sci pull`"),
|
|
289
|
+
)
|
|
290
|
+
if verified:
|
|
291
|
+
result.say(
|
|
292
|
+
f"verified: {', '.join(verified)} (manifest and file checks; not a complete "
|
|
293
|
+
"bundle check)"
|
|
294
|
+
)
|
|
295
|
+
del ctx
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _truncation(result: Result, info: dict[str, Any]) -> None:
|
|
300
|
+
if info["truncated"]:
|
|
301
|
+
result.say(
|
|
302
|
+
f"[lines {info['start_line']}-{info['end_line']} of {info['total_lines']}; "
|
|
303
|
+
f"continue with --start-line {info['next_line']}]"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
COMMAND = Command(
|
|
308
|
+
name="read",
|
|
309
|
+
usage_hint=(
|
|
310
|
+
"read ID_OR_PATH [--list | --file PATH [--output FILE]] [--start-line N] [--lines N] "
|
|
311
|
+
"[--archive ARCHIVE ... | --in URL] [--json]"
|
|
312
|
+
),
|
|
313
|
+
summary="Read verified text or retrieve one file.",
|
|
314
|
+
description="""
|
|
315
|
+
Accepts a signed directory, a ZIP, or a full publication ID (sha256:<digest>
|
|
316
|
+
or sci:sha256:<digest>). Local input is read offline. IDs are resolved like
|
|
317
|
+
`pull`: the configured index or --in URL, or explicit --archive descriptor
|
|
318
|
+
URLs or GitHub/Hugging Face repository addresses.
|
|
319
|
+
|
|
320
|
+
By default prints metadata, citations, and README text after the metadata
|
|
321
|
+
checks of SCI protocol §6.3. --list prints paths, sizes, and hashes after the
|
|
322
|
+
manifest checks (§6.1). --file PATH retrieves one manifest entry after the
|
|
323
|
+
file checks (§6.2). Remote files come from individual-file routes first
|
|
324
|
+
(entry_url, blob_url), then ZIP range requests, then a complete ZIP; the
|
|
325
|
+
whole file is verified before any part is shown. Only the requested files
|
|
326
|
+
are verified; this is not a complete bundle check.
|
|
327
|
+
""",
|
|
328
|
+
args=(
|
|
329
|
+
arg("id_or_path", metavar="ID_OR_PATH", help="signed directory, ZIP, or publication ID"),
|
|
330
|
+
arg("--list", action="store_true", help="list manifest paths, sizes, and SHA-256 hashes"),
|
|
331
|
+
arg("--file", metavar="PATH", help="bundle path to retrieve, e.g. files/data.csv"),
|
|
332
|
+
arg(
|
|
333
|
+
"--output",
|
|
334
|
+
metavar="FILE",
|
|
335
|
+
help="with --file, save the complete original bytes (required for binary files)",
|
|
336
|
+
),
|
|
337
|
+
arg("--start-line", type=int, default=1, metavar="N", help="first text line to show"),
|
|
338
|
+
arg("--lines", type=int, default=DEFAULT_LINES, metavar="N", help="number of text lines"),
|
|
339
|
+
arg(
|
|
340
|
+
"--archive",
|
|
341
|
+
metavar="ARCHIVE",
|
|
342
|
+
action="append",
|
|
343
|
+
help="descriptor URL or GitHub/Hugging Face repository address (repeatable)",
|
|
344
|
+
),
|
|
345
|
+
arg("--in", dest="in_", metavar="URL", help="index origin (default: configured index)"),
|
|
346
|
+
),
|
|
347
|
+
outputs="""Metadata and a README line range (reporting truncation and the next line),
|
|
348
|
+
a file listing, or one file's text or saved path; plus which files passed
|
|
349
|
+
verification.""",
|
|
350
|
+
files="Only --output FILE (an identical file is kept; conflicting bytes are rejected).",
|
|
351
|
+
network="None for local input. For IDs: index lookup and archive GET/range requests.",
|
|
352
|
+
exit_codes={
|
|
353
|
+
3: "a manifest, file, or metadata check failed (or every source served bad content)",
|
|
354
|
+
4: "an unsupported schema",
|
|
355
|
+
5: "no source could be reached, no known sources, or a local limit was exceeded",
|
|
356
|
+
},
|
|
357
|
+
examples=(
|
|
358
|
+
"sci read ./campaign/<digest>.zip --file files/results.csv --lines 20",
|
|
359
|
+
"sci read sha256:<digest> --archive https://raw.githubusercontent.com/lab/research/main/"
|
|
360
|
+
"sci/archive.json",
|
|
361
|
+
),
|
|
362
|
+
run=run,
|
|
363
|
+
timeout=True,
|
|
364
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""``sci revoke``: create or submit a key revocation (§5.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pysci.manifest import MANIFEST_PATH, METADATA_PATH
|
|
10
|
+
from pysci.records import create_revocation, verify_revocation
|
|
11
|
+
|
|
12
|
+
from ..checks import proof_json
|
|
13
|
+
from ..clients.tsa import read_sidecar, verify_sidecar_bytes
|
|
14
|
+
from ..context import Context
|
|
15
|
+
from ..errors import CliError, ErrorRecord, Exit, exit_for_outcome, issue_record
|
|
16
|
+
from ..output import Result
|
|
17
|
+
from ..spec import Command, arg
|
|
18
|
+
from ..storage.files import StoreOutcome, store_bytes
|
|
19
|
+
from ..storage.keys import require_outside
|
|
20
|
+
from .common import KEY_ARG_HELP, index_client, signing_key, trust
|
|
21
|
+
|
|
22
|
+
RECORD = "revocation.json"
|
|
23
|
+
SIGNATURE = "revocation.json.sig"
|
|
24
|
+
SIDECAR = "revocation.json.sig.tsr"
|
|
25
|
+
|
|
26
|
+
WARNING = """Revoking a key is permanent. After an index verifies the revocation it rejects
|
|
27
|
+
new catalog admissions for this key, and verified account associations end.
|
|
28
|
+
Anyone holding the record and signature can submit them: keep them private
|
|
29
|
+
until revocation is intended."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _inside_publication(directory: Path) -> Path | None:
|
|
33
|
+
for candidate in [directory, *directory.parents]:
|
|
34
|
+
if (candidate / MANIFEST_PATH).exists():
|
|
35
|
+
return candidate
|
|
36
|
+
if (directory / METADATA_PATH).exists():
|
|
37
|
+
return directory
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create(args: argparse.Namespace, ctx: Context) -> Result:
|
|
42
|
+
output = ctx.resolve(args.output)
|
|
43
|
+
publication = _inside_publication(output.absolute())
|
|
44
|
+
if publication is not None:
|
|
45
|
+
raise CliError(
|
|
46
|
+
"inside_publication",
|
|
47
|
+
f"{output} is inside the publication directory {publication}; write revocations "
|
|
48
|
+
"elsewhere",
|
|
49
|
+
Exit.USAGE,
|
|
50
|
+
)
|
|
51
|
+
key_path, key, warnings = signing_key(ctx, args)
|
|
52
|
+
require_outside(key_path, output)
|
|
53
|
+
result = Result("revoke")
|
|
54
|
+
result.warnings.extend(warnings)
|
|
55
|
+
if ctx.interactive() and not args.yes:
|
|
56
|
+
ctx.stderr.write(WARNING + "\nType 'revoke' to create the record: ")
|
|
57
|
+
ctx.stderr.flush()
|
|
58
|
+
answer = ctx.stdin.readline().strip()
|
|
59
|
+
if answer != "revoke":
|
|
60
|
+
raise CliError("cancelled", "revocation was not created", Exit.USAGE)
|
|
61
|
+
record = create_revocation(key)
|
|
62
|
+
check = verify_revocation(record.record, record.signature)
|
|
63
|
+
assert check.ok
|
|
64
|
+
assert check.public_key is not None
|
|
65
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
statuses: dict[str, str] = {}
|
|
67
|
+
for name, data in ((RECORD, record.record), (SIGNATURE, record.signature)):
|
|
68
|
+
stored = store_bytes(output / name, data)
|
|
69
|
+
if stored.outcome is StoreOutcome.CONFLICT:
|
|
70
|
+
raise CliError(
|
|
71
|
+
"destination_conflict",
|
|
72
|
+
f"{output / name} exists with different bytes; it was left unchanged",
|
|
73
|
+
Exit.INVALID,
|
|
74
|
+
file=str(output / name),
|
|
75
|
+
)
|
|
76
|
+
statuses[name] = str(stored.outcome)
|
|
77
|
+
result.data = {
|
|
78
|
+
"x": check.public_key.x,
|
|
79
|
+
"public_key": check.public_key.to_jwk(),
|
|
80
|
+
"files": {RECORD: str(output / RECORD), SIGNATURE: str(output / SIGNATURE)},
|
|
81
|
+
"status": statuses,
|
|
82
|
+
"submitted": False,
|
|
83
|
+
}
|
|
84
|
+
result.say(f"revocation record for x={check.public_key.x}:")
|
|
85
|
+
result.say(f" {output / RECORD}")
|
|
86
|
+
result.say(f" {output / SIGNATURE}")
|
|
87
|
+
result.say("Not submitted. Keep these files private until you intend to revoke the key.")
|
|
88
|
+
result.say(f"Optionally timestamp them (sci timestamp {output}), then submit with:")
|
|
89
|
+
result.say(f" sci revoke --submit {output}")
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def submit_revocation(ctx: Context, directory: Path, to: str | None, timeout: float) -> Result:
|
|
94
|
+
result = Result("revoke")
|
|
95
|
+
try:
|
|
96
|
+
record = (directory / RECORD).read_bytes()
|
|
97
|
+
signature = (directory / SIGNATURE).read_bytes()
|
|
98
|
+
except OSError as exc:
|
|
99
|
+
raise CliError(
|
|
100
|
+
"revocation_missing",
|
|
101
|
+
f"{directory} must contain {RECORD} and {SIGNATURE}: {exc}",
|
|
102
|
+
Exit.USAGE,
|
|
103
|
+
) from None
|
|
104
|
+
check = verify_revocation(record, signature)
|
|
105
|
+
if not check.ok or check.public_key is None:
|
|
106
|
+
result.exit_code = exit_for_outcome(check.outcome)
|
|
107
|
+
result.errors.extend(issue_record(i) for i in check.issues)
|
|
108
|
+
result.data = {"path": str(directory), "verification": str(check.outcome)}
|
|
109
|
+
result.say(f"revocation in {directory} failed verification (§6.7)")
|
|
110
|
+
return result
|
|
111
|
+
parts: list[tuple[str, str | None, bytes, str]] = [
|
|
112
|
+
(RECORD, RECORD, record, "application/json"),
|
|
113
|
+
(SIGNATURE, SIGNATURE, signature, "application/octet-stream"),
|
|
114
|
+
]
|
|
115
|
+
sidecar = read_sidecar(directory / SIDECAR)
|
|
116
|
+
local_proofs: dict[str, Any] | None = None
|
|
117
|
+
if sidecar is not None:
|
|
118
|
+
parts.append((SIDECAR, SIDECAR, sidecar, "application/octet-stream"))
|
|
119
|
+
verification = verify_sidecar_bytes(sidecar, signature, trust(ctx))
|
|
120
|
+
local_proofs = {
|
|
121
|
+
"status": str(verification.outcome),
|
|
122
|
+
"proofs": [proof_json(p) for p in verification.proofs],
|
|
123
|
+
}
|
|
124
|
+
client = index_client(ctx, to)
|
|
125
|
+
deadline = ctx.deadline(timeout)
|
|
126
|
+
response, outcome = client.submit_multipart(
|
|
127
|
+
"revocation_url", "key revocation submission", parts, deadline
|
|
128
|
+
)
|
|
129
|
+
x = check.public_key.x
|
|
130
|
+
if outcome["exit_code"] == Exit.OK:
|
|
131
|
+
body = outcome["response"]
|
|
132
|
+
if body.get("x") != x or body.get("status") != "revoked":
|
|
133
|
+
outcome = {
|
|
134
|
+
"status": "failed",
|
|
135
|
+
"exit_code": Exit.INVALID,
|
|
136
|
+
"error": ErrorRecord(
|
|
137
|
+
"invalid_response",
|
|
138
|
+
"revocation response must contain the submitted x and status 'revoked'",
|
|
139
|
+
observed=repr({k: body.get(k) for k in ("x", "status")}),
|
|
140
|
+
).to_json(),
|
|
141
|
+
"response": body,
|
|
142
|
+
}
|
|
143
|
+
result.data = {
|
|
144
|
+
"x": x,
|
|
145
|
+
"index": client.origin,
|
|
146
|
+
"timestamp_sidecar": local_proofs or {"status": "absent"},
|
|
147
|
+
**{k: v for k, v in outcome.items() if k != "exit_code"},
|
|
148
|
+
}
|
|
149
|
+
result.exit_code = outcome["exit_code"]
|
|
150
|
+
if "error" in outcome:
|
|
151
|
+
error = outcome["error"]
|
|
152
|
+
result.errors.append(ErrorRecord(error["code"], error["message"], error["retryable"]))
|
|
153
|
+
result.say(f"revocation for x={x} not confirmed by {client.origin}: {error['message']}")
|
|
154
|
+
else:
|
|
155
|
+
result.say(f"revocation for x={x} recorded by {client.origin} (status: revoked)")
|
|
156
|
+
extra = {k: v for k, v in outcome.get("response", {}).items() if k not in ("x", "status")}
|
|
157
|
+
if extra:
|
|
158
|
+
result.say(f"service results: {extra}")
|
|
159
|
+
del response
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
164
|
+
if (args.output is None) == (args.submit is None):
|
|
165
|
+
raise CliError("invalid_arguments", "use exactly one of --output DIR or --submit DIR")
|
|
166
|
+
if args.output is not None:
|
|
167
|
+
if args.to:
|
|
168
|
+
raise CliError("invalid_arguments", "--to applies only to --submit")
|
|
169
|
+
return create(args, ctx)
|
|
170
|
+
return submit_revocation(ctx, ctx.resolve(args.submit), args.to, args.timeout)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
COMMAND = Command(
|
|
174
|
+
name="revoke",
|
|
175
|
+
usage_hint="revoke (--output DIR | --submit DIR) [--to URL] [--key PATH] [--yes] [--json]",
|
|
176
|
+
summary="Create or submit a key revocation.",
|
|
177
|
+
description="""
|
|
178
|
+
--output DIR creates revocation.json and revocation.json.sig (SCI protocol
|
|
179
|
+
§5.3) for the selected key, outside any publication directory. Revocation is
|
|
180
|
+
permanent. Interactive use explains the effect and asks for confirmation;
|
|
181
|
+
noninteractive invocation is an explicit request to create the record. The
|
|
182
|
+
record is never submitted by --output.
|
|
183
|
+
|
|
184
|
+
--submit DIR verifies the record (§6.7) and sends it, with any
|
|
185
|
+
revocation.json.sig.tsr, to the index's revocation submission endpoint
|
|
186
|
+
(Sciscape `revocation_url`).
|
|
187
|
+
""",
|
|
188
|
+
args=(
|
|
189
|
+
arg("--output", metavar="DIR", help="create the revocation record in DIR"),
|
|
190
|
+
arg("--submit", metavar="DIR", help="submit the revocation record found in DIR"),
|
|
191
|
+
arg("--to", metavar="URL", help="index origin for --submit (default: configured index)"),
|
|
192
|
+
arg("--key", metavar="PATH", help=KEY_ARG_HELP),
|
|
193
|
+
arg("--yes", action="store_true", help="skip the interactive confirmation"),
|
|
194
|
+
),
|
|
195
|
+
outputs="The revoked key's x and file paths, or the index's submission result.",
|
|
196
|
+
files="--output writes DIR/revocation.json and DIR/revocation.json.sig; --submit writes none.",
|
|
197
|
+
network="--output: none. --submit: one paced POST to the index (after descriptor discovery).",
|
|
198
|
+
exit_codes={
|
|
199
|
+
3: "the revocation fails verification, or DIR holds a different record",
|
|
200
|
+
4: "the index does not support revocation submission",
|
|
201
|
+
5: "the submission did not complete (temporary failure or outcome_unknown)",
|
|
202
|
+
6: "the index refused the submission by policy",
|
|
203
|
+
},
|
|
204
|
+
examples=("sci revoke --output ~/sci-revocation", "sci revoke --submit ~/sci-revocation"),
|
|
205
|
+
run=run,
|
|
206
|
+
timeout=True,
|
|
207
|
+
)
|