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,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
+ )