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,172 @@
|
|
|
1
|
+
"""``sci submit``: register hosted publications with an indexer (§8.1.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pysci.accounts import normalize_account
|
|
9
|
+
from pysci.submission import build_submission_request
|
|
10
|
+
|
|
11
|
+
from ..context import Context
|
|
12
|
+
from ..errors import CliError, ErrorRecord, Exit, combine_exits
|
|
13
|
+
from ..output import Result
|
|
14
|
+
from ..spec import Command, arg
|
|
15
|
+
from ..storage.inputs import collect_signed_inputs, invalid_exit
|
|
16
|
+
from .common import archive_urls, index_client, looks_like_id, parse_id
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
20
|
+
archives = archive_urls(args.archive)
|
|
21
|
+
if not archives:
|
|
22
|
+
raise CliError(
|
|
23
|
+
"missing_archive",
|
|
24
|
+
"submit requires at least one --archive descriptor URL or repository address; "
|
|
25
|
+
"files must already be hosted",
|
|
26
|
+
)
|
|
27
|
+
account = None
|
|
28
|
+
if args.account:
|
|
29
|
+
try:
|
|
30
|
+
account = normalize_account(args.account[0], args.account[1])
|
|
31
|
+
except ValueError as exc:
|
|
32
|
+
raise CliError(
|
|
33
|
+
"invalid_account", f"--account: {exc}", Exit.USAGE, observed=" ".join(args.account)
|
|
34
|
+
) from None
|
|
35
|
+
digests: list[str] = []
|
|
36
|
+
paths = []
|
|
37
|
+
for target in args.targets:
|
|
38
|
+
if looks_like_id(target):
|
|
39
|
+
digest = parse_id(target)
|
|
40
|
+
if digest not in digests:
|
|
41
|
+
digests.append(digest)
|
|
42
|
+
else:
|
|
43
|
+
paths.append(ctx.resolve(target))
|
|
44
|
+
invalid: list[dict[str, Any]] = []
|
|
45
|
+
if paths:
|
|
46
|
+
inputs, invalid, _ = collect_signed_inputs(paths)
|
|
47
|
+
for item in inputs:
|
|
48
|
+
if item.digest not in digests:
|
|
49
|
+
digests.append(item.digest)
|
|
50
|
+
client = index_client(ctx, args.to)
|
|
51
|
+
result = Result("submit")
|
|
52
|
+
result.data = {
|
|
53
|
+
"index": client.origin,
|
|
54
|
+
"descriptors": archives,
|
|
55
|
+
"account": account.to_json() if account else None,
|
|
56
|
+
"invalid_candidates": invalid,
|
|
57
|
+
"submissions": [],
|
|
58
|
+
}
|
|
59
|
+
if not digests:
|
|
60
|
+
result.exit_code = invalid_exit(invalid) if invalid else Exit.USAGE
|
|
61
|
+
result.errors.append(ErrorRecord("no_valid_inputs", "no valid publications to submit"))
|
|
62
|
+
return result
|
|
63
|
+
client.discover(ctx.deadline(args.timeout))
|
|
64
|
+
exits: list[int] = []
|
|
65
|
+
for digest in digests:
|
|
66
|
+
request = build_submission_request(digest, archives, account)
|
|
67
|
+
outcome = client.submit(request, ctx.deadline(args.timeout))
|
|
68
|
+
exits.append(outcome["exit_code"])
|
|
69
|
+
entry = {k: v for k, v in outcome.items() if k != "exit_code"}
|
|
70
|
+
entry["id"] = "sha256:" + digest
|
|
71
|
+
result.data["submissions"].append(entry)
|
|
72
|
+
_describe(result, entry)
|
|
73
|
+
if "error" in entry:
|
|
74
|
+
error = entry["error"]
|
|
75
|
+
result.errors.append(
|
|
76
|
+
ErrorRecord(
|
|
77
|
+
error["code"],
|
|
78
|
+
f"sha256:{digest}: {error['message']}",
|
|
79
|
+
error["retryable"],
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
if invalid:
|
|
83
|
+
exits.append(invalid_exit(invalid))
|
|
84
|
+
for candidate in invalid:
|
|
85
|
+
result.say(f"not sent (invalid): {candidate['path']} ({candidate['status']})")
|
|
86
|
+
result.exit_code = combine_exits(exits)
|
|
87
|
+
result.partial = result.exit_code == Exit.INCOMPLETE and Exit.OK in exits
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _describe(result: Result, entry: dict[str, Any]) -> None:
|
|
92
|
+
status = entry["status"]
|
|
93
|
+
if status == "registered":
|
|
94
|
+
body = entry.get("response", {})
|
|
95
|
+
service = body.get("status")
|
|
96
|
+
line = f"{entry['id']}: registered"
|
|
97
|
+
if isinstance(service, str):
|
|
98
|
+
line += f" (service status: {service})"
|
|
99
|
+
result.say(line)
|
|
100
|
+
if body.get("publication_url"):
|
|
101
|
+
result.say(f" {body['publication_url']}")
|
|
102
|
+
if body.get("message"):
|
|
103
|
+
result.say(f" {body['message']}")
|
|
104
|
+
if service is None:
|
|
105
|
+
result.say(" registration confirms lookup only; catalog admission is not implied")
|
|
106
|
+
else:
|
|
107
|
+
message = entry.get("error", {}).get("message", "")
|
|
108
|
+
result.say(f"{entry['id']}: {status} - {message}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
COMMAND = Command(
|
|
112
|
+
name="submit",
|
|
113
|
+
usage_hint=(
|
|
114
|
+
"submit ID_OR_PATH ... --archive ARCHIVE [--archive ARCHIVE ...] [--to INDEX] "
|
|
115
|
+
"[--account TYPE ID] [--json]"
|
|
116
|
+
),
|
|
117
|
+
summary="Register hosted publications with an indexer.",
|
|
118
|
+
description="""
|
|
119
|
+
Sends one publication submission (SCI protocol §8.1.4) per ID to the index's
|
|
120
|
+
submit_url, naming the archive descriptors that host it. Accepts full IDs
|
|
121
|
+
or local signed inputs (a ZIP, a signed directory, or a folder of *.zip
|
|
122
|
+
bundles; each is verified first and invalid candidates are not sent). Files
|
|
123
|
+
must already be hosted (see `sci publish`); submission uploads nothing.
|
|
124
|
+
|
|
125
|
+
--archive accepts a descriptor URL or the same GitHub/Hugging Face repository
|
|
126
|
+
address used by publish --to. Repository addresses expand to the descriptor
|
|
127
|
+
created by sci publish; no upload history or provider credentials are needed.
|
|
128
|
+
|
|
129
|
+
Protocol success confirms location registration only; service fields such as
|
|
130
|
+
Sciscape's status, publication_url, accepted_at, reason, and message are
|
|
131
|
+
reported as returned. Temporary failures are retried with backoff and
|
|
132
|
+
Retry-After; refusals are not retried. A lost response is reported as
|
|
133
|
+
outcome_unknown. Adding or renewing an account claim permits resubmitting an
|
|
134
|
+
unchanged bundle for catalog admission.
|
|
135
|
+
""",
|
|
136
|
+
args=(
|
|
137
|
+
arg(
|
|
138
|
+
"targets",
|
|
139
|
+
metavar="ID_OR_PATH",
|
|
140
|
+
nargs="+",
|
|
141
|
+
help="publication IDs or local signed inputs",
|
|
142
|
+
),
|
|
143
|
+
arg(
|
|
144
|
+
"--archive",
|
|
145
|
+
metavar="ARCHIVE",
|
|
146
|
+
action="append",
|
|
147
|
+
help="descriptor URL or GitHub/Hugging Face repository address (repeatable)",
|
|
148
|
+
),
|
|
149
|
+
arg("--to", metavar="INDEX", help="index origin (default: configured index)"),
|
|
150
|
+
arg(
|
|
151
|
+
"--account",
|
|
152
|
+
nargs=2,
|
|
153
|
+
metavar=("TYPE", "ID"),
|
|
154
|
+
help="unsigned account hint (domain, github, or huggingface)",
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
outputs="One result per ID (registered, refused, failed, or outcome_unknown) with the "
|
|
158
|
+
"service response.",
|
|
159
|
+
files="None.",
|
|
160
|
+
network="Index descriptor GET, then one paced POST per publication (at most one per second).",
|
|
161
|
+
exit_codes={
|
|
162
|
+
3: "a local input failed verification, or the index rejected the content",
|
|
163
|
+
4: "the index has no submit_url or rejected an unsupported schema",
|
|
164
|
+
5: "a temporary failure, outcome_unknown, or a mix of successes and failures",
|
|
165
|
+
6: "the index refused a submission by policy (e.g. not_baseline)",
|
|
166
|
+
},
|
|
167
|
+
examples=(
|
|
168
|
+
"sci submit ./campaign --to https://sciscape.io --archive https://github.com/lab/research",
|
|
169
|
+
),
|
|
170
|
+
run=run,
|
|
171
|
+
timeout=True,
|
|
172
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""``sci timestamp``: obtain or submit timestamp proofs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pysci.bundle import Scope, verify_directory, verify_zip
|
|
9
|
+
from pysci.manifest import TIMESTAMP_SIDECAR_PATH
|
|
10
|
+
from pysci.records import 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.inputs import find_sidecar, sidecar_target
|
|
19
|
+
from .common import index_client, trust
|
|
20
|
+
from .revoke import RECORD, SIDECAR, SIGNATURE, submit_revocation
|
|
21
|
+
from .sign import timestamp_step, tsa_url
|
|
22
|
+
from .verify import signature_bytes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _target(path: Path) -> tuple[str, bytes, Path, str | None, Result | None]:
|
|
26
|
+
"""(kind, signature, sidecar path, publication digest, failure result)."""
|
|
27
|
+
if path.is_dir() and (path / RECORD).exists():
|
|
28
|
+
record = (path / RECORD).read_bytes()
|
|
29
|
+
signature = (path / SIGNATURE).read_bytes() if (path / SIGNATURE).exists() else b""
|
|
30
|
+
check = verify_revocation(record, signature)
|
|
31
|
+
if not check.ok:
|
|
32
|
+
failed = Result("timestamp", exit_code=exit_for_outcome(check.outcome))
|
|
33
|
+
failed.errors.extend(issue_record(i) for i in check.issues)
|
|
34
|
+
failed.say("the revocation record failed verification (§6.7); no proof requested")
|
|
35
|
+
return "revocation", b"", path / SIDECAR, None, failed
|
|
36
|
+
return "revocation", signature, path / SIDECAR, None, None
|
|
37
|
+
if path.is_dir():
|
|
38
|
+
kind = "directory"
|
|
39
|
+
verification = verify_directory(path, scope=Scope.MANIFEST)
|
|
40
|
+
else:
|
|
41
|
+
kind = "zip"
|
|
42
|
+
verification = verify_zip(path, scope=Scope.MANIFEST)
|
|
43
|
+
if not verification.ok or verification.manifest is None:
|
|
44
|
+
failed = Result("timestamp", exit_code=exit_for_outcome(verification.outcome))
|
|
45
|
+
failed.errors.extend(issue_record(i) for i in verification.issues)
|
|
46
|
+
failed.say("manifest checks failed (§6.1); no proof requested")
|
|
47
|
+
return kind, b"", path, None, failed
|
|
48
|
+
digest = verification.manifest.digest
|
|
49
|
+
return kind, signature_bytes(path, kind), sidecar_target(path, kind, digest), digest, None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def submit(args: argparse.Namespace, ctx: Context, path: Path) -> Result:
|
|
53
|
+
if path.is_dir() and (path / RECORD).exists():
|
|
54
|
+
result = submit_revocation(ctx, path, args.to, args.timeout)
|
|
55
|
+
result.command = "timestamp"
|
|
56
|
+
return result
|
|
57
|
+
kind, signature, _, digest, failed = _target(path)
|
|
58
|
+
if failed is not None:
|
|
59
|
+
return failed
|
|
60
|
+
assert digest is not None
|
|
61
|
+
sidecar = find_sidecar(path, kind, digest)
|
|
62
|
+
data = read_sidecar(sidecar) if sidecar else None
|
|
63
|
+
if not data:
|
|
64
|
+
raise CliError(
|
|
65
|
+
"no_proofs",
|
|
66
|
+
f"no timestamp sidecar found for {path}; obtain one with `sci timestamp {path}`",
|
|
67
|
+
Exit.USAGE,
|
|
68
|
+
)
|
|
69
|
+
local = verify_sidecar_bytes(data, signature, trust(ctx))
|
|
70
|
+
client = index_client(ctx, args.to)
|
|
71
|
+
_, outcome = client.submit_multipart(
|
|
72
|
+
"timestamp_submit_url",
|
|
73
|
+
"timestamp submission",
|
|
74
|
+
[
|
|
75
|
+
("id", None, digest.encode("ascii"), "text/plain"),
|
|
76
|
+
(TIMESTAMP_SIDECAR_PATH, TIMESTAMP_SIDECAR_PATH, data, "application/octet-stream"),
|
|
77
|
+
],
|
|
78
|
+
ctx.deadline(args.timeout),
|
|
79
|
+
)
|
|
80
|
+
if outcome["exit_code"] == Exit.OK:
|
|
81
|
+
body = outcome["response"]
|
|
82
|
+
count = body.get("retained_count")
|
|
83
|
+
if body.get("id") != digest or not isinstance(count, int) or isinstance(count, bool):
|
|
84
|
+
outcome = {
|
|
85
|
+
"status": "failed",
|
|
86
|
+
"exit_code": Exit.INVALID,
|
|
87
|
+
"error": ErrorRecord(
|
|
88
|
+
"invalid_response",
|
|
89
|
+
"timestamp submission response needs the submitted id and an integer "
|
|
90
|
+
"retained_count",
|
|
91
|
+
).to_json(),
|
|
92
|
+
"response": body,
|
|
93
|
+
}
|
|
94
|
+
result = Result("timestamp", exit_code=outcome["exit_code"])
|
|
95
|
+
result.data = {
|
|
96
|
+
"mode": "submit",
|
|
97
|
+
"publication_id": "sha256:" + digest,
|
|
98
|
+
"index": client.origin,
|
|
99
|
+
"sidecar": str(sidecar),
|
|
100
|
+
"local_checks": {
|
|
101
|
+
"status": str(local.outcome),
|
|
102
|
+
"proofs": [proof_json(p) for p in local.proofs],
|
|
103
|
+
},
|
|
104
|
+
**{k: v for k, v in outcome.items() if k != "exit_code"},
|
|
105
|
+
}
|
|
106
|
+
if "error" in outcome:
|
|
107
|
+
error = outcome["error"]
|
|
108
|
+
result.errors.append(ErrorRecord(error["code"], error["message"], error["retryable"]))
|
|
109
|
+
result.say(f"timestamp submission failed: {error['message']}")
|
|
110
|
+
else:
|
|
111
|
+
body = outcome["response"]
|
|
112
|
+
result.say(
|
|
113
|
+
f"submitted {local.proofs.__len__()} proof(s) for sha256:{digest} to {client.origin}; "
|
|
114
|
+
f"retained: {body['retained_count']}"
|
|
115
|
+
)
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
120
|
+
if args.submit and args.tsa:
|
|
121
|
+
raise CliError("conflicting_options", "--submit cannot be combined with --tsa")
|
|
122
|
+
if args.to and not args.submit:
|
|
123
|
+
raise CliError("invalid_arguments", "--to applies only to --submit")
|
|
124
|
+
path = ctx.resolve(args.path)
|
|
125
|
+
if not path.exists():
|
|
126
|
+
raise CliError("input_missing", f"{path} does not exist", Exit.USAGE, file=str(path))
|
|
127
|
+
if args.submit:
|
|
128
|
+
return submit(args, ctx, path)
|
|
129
|
+
kind, signature, sidecar, digest, failed = _target(path)
|
|
130
|
+
if failed is not None:
|
|
131
|
+
return failed
|
|
132
|
+
url = tsa_url(ctx, args.tsa)
|
|
133
|
+
result = Result("timestamp")
|
|
134
|
+
result.data = {
|
|
135
|
+
"mode": "create",
|
|
136
|
+
"target": kind,
|
|
137
|
+
"path": str(path),
|
|
138
|
+
"publication_id": None if digest is None else "sha256:" + digest,
|
|
139
|
+
}
|
|
140
|
+
ok = timestamp_step(ctx, result, sidecar, signature, url, args.timeout)
|
|
141
|
+
ts = result.data["timestamp"]
|
|
142
|
+
if not ok:
|
|
143
|
+
result.exit_code = Exit.INCOMPLETE if ts["error"]["code"] != "sidecar_malformed" else 3
|
|
144
|
+
result.say(f"timestamp failed: {ts['error']['message']}")
|
|
145
|
+
return result
|
|
146
|
+
result.say(f"proof {ts['status']}: {ts['sidecar']}")
|
|
147
|
+
proof = ts["proof"]
|
|
148
|
+
result.say(f"genTime: {proof.get('gen_time')} TSA: {proof.get('tsa') or url}")
|
|
149
|
+
result.say(
|
|
150
|
+
f"trust: {proof['result']}"
|
|
151
|
+
+ (f" ({proof['issues'][0]['message']})" if proof["issues"] else "")
|
|
152
|
+
)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
COMMAND = Command(
|
|
157
|
+
name="timestamp",
|
|
158
|
+
usage_hint="timestamp PATH [--tsa URL | --submit [--to URL]] [--json]",
|
|
159
|
+
summary="Obtain or submit timestamp proofs.",
|
|
160
|
+
description="""
|
|
161
|
+
Proof creation: PATH is a signed publication directory, a ZIP, or a
|
|
162
|
+
revocation directory. The target signature is verified first (§6.1 or §6.7),
|
|
163
|
+
then an RFC 3161 proof over SHA-256 of the signature bytes is requested from
|
|
164
|
+
the configured TSA (default FreeTSA) or --tsa URL. The reply's structure,
|
|
165
|
+
status, imprint, and nonce are checked; trust checks are reported
|
|
166
|
+
separately. The complete DER reply is appended without duplicates to
|
|
167
|
+
manifest.json.sig.tsr, <digest>.tsr beside a ZIP, or revocation.json.sig.tsr,
|
|
168
|
+
written atomically. A malformed existing sidecar is preserved and the
|
|
169
|
+
command stops.
|
|
170
|
+
|
|
171
|
+
--submit sends existing publication proofs to the index's timestamp
|
|
172
|
+
submission endpoint (Sciscape `timestamp_submit_url`); the publication must be
|
|
173
|
+
admitted to its catalog. For a revocation directory, --submit sends the proofs
|
|
174
|
+
with the revocation record (as `sci revoke --submit`).
|
|
175
|
+
""",
|
|
176
|
+
args=(
|
|
177
|
+
arg("path", metavar="PATH", help="signed directory, ZIP, or revocation directory"),
|
|
178
|
+
arg(
|
|
179
|
+
"--tsa", metavar="URL", help="timestamp authority (default: config tsa_url or FreeTSA)"
|
|
180
|
+
),
|
|
181
|
+
arg("--submit", action="store_true", help="submit existing proofs to an index"),
|
|
182
|
+
arg("--to", metavar="URL", help="index origin for --submit (default: configured index)"),
|
|
183
|
+
),
|
|
184
|
+
outputs="The proof's genTime, TSA, and trust result, or the index's retention result.",
|
|
185
|
+
files="Creates or appends the timestamp sidecar next to PATH; --submit writes none.",
|
|
186
|
+
network="One paced request to the TSA, or one paced POST to the index with --submit.",
|
|
187
|
+
exit_codes={
|
|
188
|
+
3: "the target fails verification, or the existing sidecar is malformed",
|
|
189
|
+
4: "the index does not support timestamp submission",
|
|
190
|
+
5: "the TSA or index request did not complete",
|
|
191
|
+
6: "the index refused the proofs by policy",
|
|
192
|
+
},
|
|
193
|
+
examples=("sci timestamp ./draft", "sci timestamp ./campaign/<digest>.zip --submit"),
|
|
194
|
+
run=run,
|
|
195
|
+
timeout=True,
|
|
196
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""``sci verify``: verify a signed directory or ZIP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pysci.bundle import VerificationResult
|
|
10
|
+
from pysci.manifest import SIGNATURE_PATH
|
|
11
|
+
from pysci.source import FileSource
|
|
12
|
+
|
|
13
|
+
from ..checks import (
|
|
14
|
+
baseline_check,
|
|
15
|
+
bundle_check,
|
|
16
|
+
checks,
|
|
17
|
+
describe_checks,
|
|
18
|
+
issue_lines,
|
|
19
|
+
timestamps_check,
|
|
20
|
+
)
|
|
21
|
+
from ..clients.tsa import read_sidecar, verify_sidecar_bytes
|
|
22
|
+
from ..context import Context
|
|
23
|
+
from ..errors import CliError, ErrorRecord, Exit, exit_for_outcome, issue_record
|
|
24
|
+
from ..output import Result
|
|
25
|
+
from ..spec import Command, arg
|
|
26
|
+
from ..storage.inputs import find_sidecar, verify_input, zip_manifest_and_signature
|
|
27
|
+
from ..storage.keys import load_public_key_file
|
|
28
|
+
from .common import trust_with_warnings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def signature_bytes(path: Path, kind: str) -> bytes:
|
|
32
|
+
if kind == "directory":
|
|
33
|
+
return (path / SIGNATURE_PATH).read_bytes()
|
|
34
|
+
with FileSource(path) as source:
|
|
35
|
+
return zip_manifest_and_signature(source)[1]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check_timestamps(
|
|
39
|
+
ctx: Context,
|
|
40
|
+
path: Path,
|
|
41
|
+
kind: str,
|
|
42
|
+
result: VerificationResult,
|
|
43
|
+
*,
|
|
44
|
+
online: bool,
|
|
45
|
+
timeout: float | None,
|
|
46
|
+
warnings: list[str],
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
if result.manifest is None:
|
|
49
|
+
return {
|
|
50
|
+
"status": "not_performed",
|
|
51
|
+
"reason": "timestamp checks require completed manifest checks (§6.8)",
|
|
52
|
+
}
|
|
53
|
+
sidecar = find_sidecar(path, kind, result.manifest.digest)
|
|
54
|
+
if sidecar is None:
|
|
55
|
+
return timestamps_check(None, source=None, online=online)
|
|
56
|
+
data = read_sidecar(sidecar) or b""
|
|
57
|
+
deadline = ctx.deadline(timeout) if online else None
|
|
58
|
+
trust_config, trust_warnings = trust_with_warnings(ctx, online=online, deadline=deadline)
|
|
59
|
+
warnings.extend(trust_warnings)
|
|
60
|
+
verification = verify_sidecar_bytes(data, signature_bytes(path, kind), trust_config)
|
|
61
|
+
return timestamps_check(verification, source=str(sidecar), online=online)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run(args: argparse.Namespace, ctx: Context) -> Result:
|
|
65
|
+
path = ctx.resolve(args.path)
|
|
66
|
+
if not path.exists():
|
|
67
|
+
raise CliError("input_missing", f"{path} does not exist", Exit.USAGE, file=str(path))
|
|
68
|
+
expected = None
|
|
69
|
+
if args.public_key:
|
|
70
|
+
expected = load_public_key_file(ctx.resolve(args.public_key))
|
|
71
|
+
kind, verification = verify_input(path)
|
|
72
|
+
result = Result("verify")
|
|
73
|
+
bundle = bundle_check(verification)
|
|
74
|
+
exit_code = exit_for_outcome(verification.outcome)
|
|
75
|
+
key_check: dict[str, Any] | None = None
|
|
76
|
+
if expected is not None:
|
|
77
|
+
if verification.manifest is None:
|
|
78
|
+
key_check = {"status": "not_performed"}
|
|
79
|
+
else:
|
|
80
|
+
match = verification.manifest.public_key == expected
|
|
81
|
+
key_check = {"status": "match" if match else "mismatch", "expected": expected.x}
|
|
82
|
+
if not match:
|
|
83
|
+
exit_code = Exit.INVALID
|
|
84
|
+
result.errors.append(
|
|
85
|
+
ErrorRecord(
|
|
86
|
+
"public_key_mismatch",
|
|
87
|
+
"signer does not match --public-key",
|
|
88
|
+
file="manifest.json",
|
|
89
|
+
field="/public_key/x",
|
|
90
|
+
requirement=f"x = {expected.x}",
|
|
91
|
+
observed=verification.manifest.public_key.x,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
timestamps = check_timestamps(
|
|
95
|
+
ctx,
|
|
96
|
+
path,
|
|
97
|
+
kind,
|
|
98
|
+
verification,
|
|
99
|
+
online=args.check_timestamp_online,
|
|
100
|
+
timeout=args.timeout,
|
|
101
|
+
warnings=result.warnings,
|
|
102
|
+
)
|
|
103
|
+
report = checks(bundle=bundle, baseline=baseline_check(verification), timestamps=timestamps)
|
|
104
|
+
result.data = {
|
|
105
|
+
"path": str(path),
|
|
106
|
+
"kind": kind,
|
|
107
|
+
"publication_id": verification.publication_id,
|
|
108
|
+
"signer": verification.manifest.public_key.to_jwk() if verification.manifest else None,
|
|
109
|
+
"public_key_check": key_check,
|
|
110
|
+
"checks": report,
|
|
111
|
+
}
|
|
112
|
+
result.errors.extend(issue_record(i) for i in verification.issues)
|
|
113
|
+
result.exit_code = exit_code
|
|
114
|
+
if verification.publication_id:
|
|
115
|
+
result.say(f"publication: {verification.publication_id}")
|
|
116
|
+
if verification.manifest is not None:
|
|
117
|
+
result.say(f"signer x: {verification.manifest.public_key.x}")
|
|
118
|
+
if key_check is not None:
|
|
119
|
+
result.say(f"public key: {key_check['status']}")
|
|
120
|
+
result.say("checks:")
|
|
121
|
+
result.lines.extend(describe_checks(report))
|
|
122
|
+
if bundle["issues"]:
|
|
123
|
+
result.lines.extend(issue_lines(bundle["issues"], " "))
|
|
124
|
+
result.say("Offline verification does not establish current account associations or the")
|
|
125
|
+
result.say("absence of key revocation.")
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
COMMAND = Command(
|
|
130
|
+
name="verify",
|
|
131
|
+
usage_hint="verify PATH [--public-key PATH] [--check-timestamp-online] [--json]",
|
|
132
|
+
summary="Verify a signed directory or ZIP.",
|
|
133
|
+
description="""
|
|
134
|
+
Runs the complete bundle checks of SCI protocol §6.4 (ZIP layout, manifest,
|
|
135
|
+
signature, metadata, every file hash, and inventory). Baseline eligibility
|
|
136
|
+
is reported separately; valid non-baseline bundles pass. Timestamp proofs in
|
|
137
|
+
manifest.json.sig.tsr (directories) or <digest>.tsr (beside a ZIP) are
|
|
138
|
+
checked under §6.8 and reported separately; a failed proof never
|
|
139
|
+
invalidates the publication. Associations and revocation are reported as
|
|
140
|
+
not performed.
|
|
141
|
+
""",
|
|
142
|
+
args=(
|
|
143
|
+
arg("path", metavar="PATH", help="signed directory or ZIP (any file name)"),
|
|
144
|
+
arg("--public-key", metavar="PATH", help="require the signer to match this public PEM key"),
|
|
145
|
+
arg(
|
|
146
|
+
"--check-timestamp-online",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="fetch current CRLs for timestamp trust checks (otherwise offline)",
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
outputs="""Publication ID, signer, and the checks object: bundle, baseline, timestamps,
|
|
152
|
+
associations, revocation (each with status; skipped checks are not_performed).""",
|
|
153
|
+
files="None.",
|
|
154
|
+
network="None, unless --check-timestamp-online fetches CRLs (FreeTSA and configured URLs).",
|
|
155
|
+
exit_codes={
|
|
156
|
+
3: "a protocol, signature, or content check failed, or --public-key does not match",
|
|
157
|
+
4: "an unsupported schema",
|
|
158
|
+
5: "a check could not be completed (unreadable input or local limit)",
|
|
159
|
+
},
|
|
160
|
+
examples=("sci verify ./campaign/<digest>.zip --public-key ~/.sci/key.pub.pem",),
|
|
161
|
+
run=run,
|
|
162
|
+
timeout=True,
|
|
163
|
+
)
|