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
pysci/keys.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Ed25519 public JWKs (RFC 8037) and key file helpers (§5.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives import serialization
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
12
|
+
|
|
13
|
+
from .ed25519 import check_public_key_point
|
|
14
|
+
from .errors import Invalid, Issue, IssueCollector
|
|
15
|
+
from .jsonstrict import json_type_name
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PublicKey",
|
|
19
|
+
"generate_private_key",
|
|
20
|
+
"load_private_key_pem",
|
|
21
|
+
"load_public_key_pem",
|
|
22
|
+
"looks_like_private_key",
|
|
23
|
+
"parse_public_jwk",
|
|
24
|
+
"private_key_to_pem",
|
|
25
|
+
"public_key_of",
|
|
26
|
+
"x_to_bytes",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_X_PATTERN: Final = re.compile(r"[A-Za-z0-9_-]{43}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def x_to_bytes(x: str) -> bytes | None:
|
|
33
|
+
"""Decode a canonical unpadded base64url ``x``; None if noncanonical."""
|
|
34
|
+
if not _X_PATTERN.fullmatch(x):
|
|
35
|
+
return None
|
|
36
|
+
raw = base64.urlsafe_b64decode(x + "=")
|
|
37
|
+
if base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") != x:
|
|
38
|
+
return None
|
|
39
|
+
return raw
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class PublicKey:
|
|
44
|
+
"""An Ed25519 public key; ``raw`` holds the 32 encoded bytes."""
|
|
45
|
+
|
|
46
|
+
raw: bytes
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if len(self.raw) != 32:
|
|
50
|
+
raise ValueError("Ed25519 public keys are 32 bytes")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def x(self) -> str:
|
|
54
|
+
"""Canonical unpadded base64url JWK ``x`` value."""
|
|
55
|
+
return base64.urlsafe_b64encode(self.raw).rstrip(b"=").decode("ascii")
|
|
56
|
+
|
|
57
|
+
def to_jwk(self) -> dict[str, str]:
|
|
58
|
+
"""The public JWK object with exactly ``kty``, ``crv``, and ``x``."""
|
|
59
|
+
return {"kty": "OKP", "crv": "Ed25519", "x": self.x}
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_x(cls, x: str) -> PublicKey:
|
|
63
|
+
"""Parse a canonical ``x`` value. Raises :class:`Invalid`."""
|
|
64
|
+
raw = x_to_bytes(x)
|
|
65
|
+
if raw is None:
|
|
66
|
+
raise Invalid(
|
|
67
|
+
Issue(
|
|
68
|
+
"jwk_x_encoding",
|
|
69
|
+
"x must be canonical unpadded base64url of 32 bytes",
|
|
70
|
+
section="5.1",
|
|
71
|
+
observed=x,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return cls(raw)
|
|
75
|
+
|
|
76
|
+
def to_pem(self) -> bytes:
|
|
77
|
+
"""SubjectPublicKeyInfo PEM encoding."""
|
|
78
|
+
return Ed25519PublicKey.from_public_bytes(self.raw).public_bytes(
|
|
79
|
+
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def to_cryptography(self) -> Ed25519PublicKey:
|
|
83
|
+
return Ed25519PublicKey.from_public_bytes(self.raw)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_public_jwk(
|
|
87
|
+
value: object,
|
|
88
|
+
*,
|
|
89
|
+
file: str | None = None,
|
|
90
|
+
field: str = "/public_key",
|
|
91
|
+
collector: IssueCollector | None = None,
|
|
92
|
+
check_point: bool = True,
|
|
93
|
+
) -> PublicKey | None:
|
|
94
|
+
"""Validate a public JWK under §5.1.
|
|
95
|
+
|
|
96
|
+
With a ``collector`` issues are appended and None is returned on failure;
|
|
97
|
+
without one the first failure raises :class:`Invalid`.
|
|
98
|
+
"""
|
|
99
|
+
local = IssueCollector()
|
|
100
|
+
result = _parse_jwk(value, file, field, local, check_point)
|
|
101
|
+
if collector is not None:
|
|
102
|
+
collector.extend(local.issues)
|
|
103
|
+
return result
|
|
104
|
+
local.raise_if_any()
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_jwk(
|
|
109
|
+
value: object, file: str | None, field: str, issues: IssueCollector, check_point: bool
|
|
110
|
+
) -> PublicKey | None:
|
|
111
|
+
if not isinstance(value, dict):
|
|
112
|
+
issues.add(
|
|
113
|
+
"jwk_type",
|
|
114
|
+
"public key must be a JWK object",
|
|
115
|
+
section="5.1",
|
|
116
|
+
file=file,
|
|
117
|
+
field=field,
|
|
118
|
+
observed=json_type_name(value),
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
121
|
+
extra = sorted(set(value) - {"kty", "crv", "x"})
|
|
122
|
+
missing = sorted({"kty", "crv", "x"} - set(value))
|
|
123
|
+
if extra:
|
|
124
|
+
issues.add(
|
|
125
|
+
"jwk_fields",
|
|
126
|
+
"only kty, crv, and x are permitted in a public key",
|
|
127
|
+
section="5.1",
|
|
128
|
+
file=file,
|
|
129
|
+
field=field,
|
|
130
|
+
observed=extra,
|
|
131
|
+
)
|
|
132
|
+
if missing:
|
|
133
|
+
issues.add(
|
|
134
|
+
"jwk_fields",
|
|
135
|
+
"public key requires kty, crv, and x",
|
|
136
|
+
section="5.1",
|
|
137
|
+
file=file,
|
|
138
|
+
field=field,
|
|
139
|
+
observed=missing,
|
|
140
|
+
)
|
|
141
|
+
if "kty" in value and value["kty"] != "OKP":
|
|
142
|
+
issues.add(
|
|
143
|
+
"jwk_kty",
|
|
144
|
+
'kty must be "OKP"',
|
|
145
|
+
section="5.1",
|
|
146
|
+
file=file,
|
|
147
|
+
field=field + "/kty",
|
|
148
|
+
observed=value["kty"],
|
|
149
|
+
)
|
|
150
|
+
if "crv" in value and value["crv"] != "Ed25519":
|
|
151
|
+
issues.add(
|
|
152
|
+
"jwk_crv",
|
|
153
|
+
'crv must be "Ed25519"',
|
|
154
|
+
section="5.1",
|
|
155
|
+
file=file,
|
|
156
|
+
field=field + "/crv",
|
|
157
|
+
observed=value["crv"],
|
|
158
|
+
)
|
|
159
|
+
x = value.get("x")
|
|
160
|
+
raw: bytes | None = None
|
|
161
|
+
if "x" in value:
|
|
162
|
+
raw = x_to_bytes(x) if isinstance(x, str) else None
|
|
163
|
+
if raw is None:
|
|
164
|
+
issues.add(
|
|
165
|
+
"jwk_x_encoding",
|
|
166
|
+
"x must be canonical unpadded base64url of 32 bytes",
|
|
167
|
+
section="5.1",
|
|
168
|
+
file=file,
|
|
169
|
+
field=field + "/x",
|
|
170
|
+
observed=x,
|
|
171
|
+
)
|
|
172
|
+
if issues or raw is None:
|
|
173
|
+
return None
|
|
174
|
+
if check_point:
|
|
175
|
+
try:
|
|
176
|
+
check_public_key_point(raw, file=file)
|
|
177
|
+
except Invalid as exc:
|
|
178
|
+
issues.extend(exc.issues)
|
|
179
|
+
return None
|
|
180
|
+
return PublicKey(raw)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def generate_private_key() -> Ed25519PrivateKey:
|
|
184
|
+
"""Create a new Ed25519 private key."""
|
|
185
|
+
return Ed25519PrivateKey.generate()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def public_key_of(private_key: Ed25519PrivateKey) -> PublicKey:
|
|
189
|
+
"""The :class:`PublicKey` matching ``private_key``."""
|
|
190
|
+
return PublicKey(
|
|
191
|
+
private_key.public_key().public_bytes(
|
|
192
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def private_key_to_pem(private_key: Ed25519PrivateKey, password: bytes | None = None) -> bytes:
|
|
198
|
+
"""PKCS#8 PEM, optionally encrypted with ``password``."""
|
|
199
|
+
encryption: serialization.KeySerializationEncryption = (
|
|
200
|
+
serialization.BestAvailableEncryption(password)
|
|
201
|
+
if password
|
|
202
|
+
else serialization.NoEncryption()
|
|
203
|
+
)
|
|
204
|
+
return private_key.private_bytes(
|
|
205
|
+
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, encryption
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_private_key_pem(data: bytes, password: bytes | None = None) -> Ed25519PrivateKey:
|
|
210
|
+
"""Load a PKCS#8 PEM Ed25519 private key. Raises ValueError for other key types."""
|
|
211
|
+
key = serialization.load_pem_private_key(data, password=password)
|
|
212
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
213
|
+
raise ValueError("not an Ed25519 private key")
|
|
214
|
+
return key
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_public_key_pem(data: bytes) -> PublicKey:
|
|
218
|
+
"""Load a SubjectPublicKeyInfo PEM Ed25519 public key."""
|
|
219
|
+
key = serialization.load_pem_public_key(data)
|
|
220
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
221
|
+
raise ValueError("not an Ed25519 public key")
|
|
222
|
+
return PublicKey(key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
_PRIVATE_MARKERS: Final = (
|
|
226
|
+
b"-----BEGIN PRIVATE KEY-----",
|
|
227
|
+
b"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
|
228
|
+
b"-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
229
|
+
b"-----BEGIN RSA PRIVATE KEY-----",
|
|
230
|
+
b"-----BEGIN EC PRIVATE KEY-----",
|
|
231
|
+
b"-----BEGIN DSA PRIVATE KEY-----",
|
|
232
|
+
b"PuTTY-User-Key-File-",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def looks_like_private_key(data: bytes) -> bool:
|
|
237
|
+
"""Heuristic: does ``data`` contain recognizable private-key material?
|
|
238
|
+
|
|
239
|
+
Detects PEM/OpenSSH/PuTTY private keys and private JWKs (a ``"d"`` member
|
|
240
|
+
next to ``"kty"``). Publishing tools use this to keep signing keys out of
|
|
241
|
+
bundles (§2).
|
|
242
|
+
"""
|
|
243
|
+
if any(marker in data for marker in _PRIVATE_MARKERS):
|
|
244
|
+
return True
|
|
245
|
+
return bool(
|
|
246
|
+
re.search(rb'"kty"\s*:', data) and re.search(rb'"d"\s*:\s*"[A-Za-z0-9_-]{20,}', data)
|
|
247
|
+
)
|
pysci/manifest.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""The signed manifest (protocol §3) and manifest checks (§6.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
13
|
+
|
|
14
|
+
from . import ed25519
|
|
15
|
+
from .errors import Invalid, Issue, IssueCollector
|
|
16
|
+
from .ids import parse_publication_id
|
|
17
|
+
from .jsonstrict import MAX_SAFE_INTEGER, JsonInt, JsonObject, json_pointer, json_type_name
|
|
18
|
+
from .jsonstrict import parse_json_object as _parse_json_object
|
|
19
|
+
from .keys import PublicKey, parse_public_jwk, public_key_of
|
|
20
|
+
from .paths import check_paths, path_sort_key
|
|
21
|
+
from .schemas import PUBLICATION_SCHEMA, check_schema
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MANIFEST_PATH",
|
|
25
|
+
"REQUIRED_FILES",
|
|
26
|
+
"SIGNATURE_PATH",
|
|
27
|
+
"TIMESTAMP_SIDECAR_PATH",
|
|
28
|
+
"FileEntry",
|
|
29
|
+
"Manifest",
|
|
30
|
+
"build_manifest",
|
|
31
|
+
"parse_manifest",
|
|
32
|
+
"serialize_manifest",
|
|
33
|
+
"sign_manifest",
|
|
34
|
+
"verify_manifest",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
MANIFEST_PATH: Final = "manifest.json"
|
|
38
|
+
SIGNATURE_PATH: Final = "manifest.json.sig"
|
|
39
|
+
TIMESTAMP_SIDECAR_PATH: Final = "manifest.json.sig.tsr"
|
|
40
|
+
METADATA_PATH: Final = "sci.json"
|
|
41
|
+
README_PATH: Final = "README.md"
|
|
42
|
+
LICENSE_PATH: Final = "LICENSE"
|
|
43
|
+
REQUIRED_FILES: Final = (METADATA_PATH, README_PATH)
|
|
44
|
+
_ROOT_FILES: Final = frozenset({METADATA_PATH, README_PATH, LICENSE_PATH})
|
|
45
|
+
_SHA256_HEX: Final = re.compile(r"[0-9a-f]{64}")
|
|
46
|
+
_BYTES_LEXEME: Final = re.compile(r"0|[1-9][0-9]*")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class FileEntry:
|
|
51
|
+
"""One manifest ``files`` member."""
|
|
52
|
+
|
|
53
|
+
sha256: str
|
|
54
|
+
bytes: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class Manifest:
|
|
59
|
+
"""A parsed manifest. ``raw`` is the exact signed byte string."""
|
|
60
|
+
|
|
61
|
+
raw: bytes
|
|
62
|
+
public_key: PublicKey
|
|
63
|
+
files: Mapping[str, FileEntry]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def digest(self) -> str:
|
|
67
|
+
"""Bare SHA-256 hex digest of the manifest bytes."""
|
|
68
|
+
return hashlib.sha256(self.raw).hexdigest()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def publication_id(self) -> str:
|
|
72
|
+
"""``sha256:<digest>`` identifier (§2)."""
|
|
73
|
+
return "sha256:" + self.digest
|
|
74
|
+
|
|
75
|
+
def sorted_paths(self) -> list[str]:
|
|
76
|
+
"""Listed paths in ZIP order (ascending ASCII bytes)."""
|
|
77
|
+
return sorted(self.files, key=path_sort_key)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_manifest(data: bytes, *, file: str = MANIFEST_PATH) -> Manifest:
|
|
81
|
+
"""Check JSON encoding, fields, types, values, and paths (§3, §3.1, §7).
|
|
82
|
+
|
|
83
|
+
The signature is not checked; use :func:`verify_manifest`.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
Invalid: one or more violations (all independent field issues are reported).
|
|
87
|
+
Unsupported: the schema string is unknown.
|
|
88
|
+
"""
|
|
89
|
+
record = _parse_json_object(data, file=file)
|
|
90
|
+
check_schema(record, PUBLICATION_SCHEMA, file=file)
|
|
91
|
+
issues = IssueCollector()
|
|
92
|
+
_check_members(record, ("schema", "public_key", "files"), file, issues)
|
|
93
|
+
public_key = None
|
|
94
|
+
if "public_key" in record:
|
|
95
|
+
public_key = parse_public_jwk(record["public_key"], file=file, collector=issues)
|
|
96
|
+
files = _parse_files(record.get("files"), "files" in record, file, issues)
|
|
97
|
+
issues.raise_if_any()
|
|
98
|
+
assert public_key is not None
|
|
99
|
+
assert files is not None
|
|
100
|
+
return Manifest(raw=bytes(data), public_key=public_key, files=files)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _check_members(
|
|
104
|
+
record: JsonObject, required: tuple[str, ...], file: str, issues: IssueCollector
|
|
105
|
+
) -> None:
|
|
106
|
+
missing = [name for name in required if name not in record]
|
|
107
|
+
extra = sorted(set(record) - set(required))
|
|
108
|
+
if missing:
|
|
109
|
+
issues.add(
|
|
110
|
+
"manifest_fields",
|
|
111
|
+
"manifest requires schema, public_key, and files",
|
|
112
|
+
section="3",
|
|
113
|
+
file=file,
|
|
114
|
+
observed=missing,
|
|
115
|
+
)
|
|
116
|
+
if extra:
|
|
117
|
+
issues.add(
|
|
118
|
+
"manifest_fields",
|
|
119
|
+
"manifest permits only schema, public_key, and files",
|
|
120
|
+
section="3",
|
|
121
|
+
file=file,
|
|
122
|
+
observed=extra,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse_files(
|
|
127
|
+
value: object, present: bool, file: str, issues: IssueCollector
|
|
128
|
+
) -> dict[str, FileEntry] | None:
|
|
129
|
+
if not present:
|
|
130
|
+
return None
|
|
131
|
+
if not isinstance(value, dict):
|
|
132
|
+
issues.add(
|
|
133
|
+
"manifest_files_type",
|
|
134
|
+
"files must be a JSON object",
|
|
135
|
+
section="3",
|
|
136
|
+
file=file,
|
|
137
|
+
field="/files",
|
|
138
|
+
observed=json_type_name(value),
|
|
139
|
+
)
|
|
140
|
+
return None
|
|
141
|
+
entries: dict[str, FileEntry] = {}
|
|
142
|
+
start = len(issues)
|
|
143
|
+
for path, entry in value.items():
|
|
144
|
+
where = json_pointer("files", path)
|
|
145
|
+
if not isinstance(entry, dict):
|
|
146
|
+
issues.add(
|
|
147
|
+
"manifest_entry_type",
|
|
148
|
+
"each files member must be an object",
|
|
149
|
+
section="3",
|
|
150
|
+
file=file,
|
|
151
|
+
field=where,
|
|
152
|
+
observed=json_type_name(entry),
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
if set(entry) != {"sha256", "bytes"}:
|
|
156
|
+
issues.add(
|
|
157
|
+
"manifest_entry_fields",
|
|
158
|
+
"each entry has exactly sha256 and bytes",
|
|
159
|
+
section="3",
|
|
160
|
+
file=file,
|
|
161
|
+
field=where,
|
|
162
|
+
observed=sorted(entry),
|
|
163
|
+
)
|
|
164
|
+
digest = entry.get("sha256")
|
|
165
|
+
if not (isinstance(digest, str) and _SHA256_HEX.fullmatch(digest)):
|
|
166
|
+
issues.add(
|
|
167
|
+
"manifest_sha256",
|
|
168
|
+
"sha256 must be 64 lowercase hexadecimal characters",
|
|
169
|
+
section="3",
|
|
170
|
+
file=file,
|
|
171
|
+
field=where + "/sha256",
|
|
172
|
+
observed=digest,
|
|
173
|
+
)
|
|
174
|
+
size = entry.get("bytes")
|
|
175
|
+
if not (
|
|
176
|
+
type(size) is JsonInt
|
|
177
|
+
and _BYTES_LEXEME.fullmatch(size.lexeme)
|
|
178
|
+
and 0 <= size <= MAX_SAFE_INTEGER
|
|
179
|
+
):
|
|
180
|
+
observed = size.lexeme if isinstance(size, JsonInt) else size
|
|
181
|
+
issues.add(
|
|
182
|
+
"manifest_bytes",
|
|
183
|
+
"bytes must be a digits-only integer from 0 through 9007199254740991",
|
|
184
|
+
section="3",
|
|
185
|
+
file=file,
|
|
186
|
+
field=where + "/bytes",
|
|
187
|
+
observed=getattr(size, "lexeme", observed),
|
|
188
|
+
)
|
|
189
|
+
if len(issues) == start:
|
|
190
|
+
assert isinstance(digest, str)
|
|
191
|
+
assert isinstance(size, int)
|
|
192
|
+
entries[path] = FileEntry(sha256=digest, bytes=int(size))
|
|
193
|
+
for required in REQUIRED_FILES:
|
|
194
|
+
if required not in value:
|
|
195
|
+
issues.add(
|
|
196
|
+
"manifest_required_file",
|
|
197
|
+
f"files must list {required}",
|
|
198
|
+
section="3",
|
|
199
|
+
file=file,
|
|
200
|
+
field="/files",
|
|
201
|
+
observed=required,
|
|
202
|
+
)
|
|
203
|
+
for path in value:
|
|
204
|
+
if path not in _ROOT_FILES and not path.startswith("files/"):
|
|
205
|
+
issues.add(
|
|
206
|
+
"manifest_path_location",
|
|
207
|
+
"entries other than sci.json, README.md, and LICENSE must be under files/",
|
|
208
|
+
section="3",
|
|
209
|
+
file=file,
|
|
210
|
+
field=json_pointer("files", path),
|
|
211
|
+
observed=path,
|
|
212
|
+
)
|
|
213
|
+
check_paths(value.keys(), collector=issues, file=file, field_prefix="/files")
|
|
214
|
+
return entries if len(issues) == start else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def verify_manifest(
|
|
218
|
+
manifest_bytes: bytes,
|
|
219
|
+
signature: bytes,
|
|
220
|
+
*,
|
|
221
|
+
expected_id: str | None = None,
|
|
222
|
+
file: str = MANIFEST_PATH,
|
|
223
|
+
) -> Manifest:
|
|
224
|
+
"""Complete the §6.1 manifest checks.
|
|
225
|
+
|
|
226
|
+
Checks the JSON encoding, fields, and paths; computes the publication
|
|
227
|
+
identifier and compares it with ``expected_id`` (any accepted ID spelling);
|
|
228
|
+
then checks the public key and signature over the original bytes.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
Invalid, Unsupported: see :func:`parse_manifest`; also a mismatched
|
|
232
|
+
identifier or signature failure.
|
|
233
|
+
"""
|
|
234
|
+
manifest = parse_manifest(manifest_bytes, file=file)
|
|
235
|
+
if expected_id is not None:
|
|
236
|
+
expected = parse_publication_id(expected_id, allow_bare=True)
|
|
237
|
+
if manifest.digest != expected:
|
|
238
|
+
raise Invalid(
|
|
239
|
+
Issue(
|
|
240
|
+
"publication_id_mismatch",
|
|
241
|
+
"manifest hash must equal the requested publication identifier",
|
|
242
|
+
section="6.1",
|
|
243
|
+
file=file,
|
|
244
|
+
observed=manifest.publication_id,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
ed25519.verify(manifest.public_key.raw, signature, manifest.raw, file=SIGNATURE_PATH)
|
|
248
|
+
return manifest
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_manifest(public_key: PublicKey, files: Mapping[str, FileEntry]) -> bytes:
|
|
252
|
+
"""Serialize a manifest with the stable SCI serializer.
|
|
253
|
+
|
|
254
|
+
Members appear as ``schema``, ``public_key``, ``files``; file paths are in
|
|
255
|
+
ascending ASCII byte order; output is compact UTF-8 JSON with no
|
|
256
|
+
insignificant whitespace. Paths are validated before serialization.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
Invalid: a path, hash, or size would make the manifest invalid.
|
|
260
|
+
"""
|
|
261
|
+
issues = IssueCollector()
|
|
262
|
+
check_paths(files.keys(), collector=issues, field_prefix="/files")
|
|
263
|
+
for required in REQUIRED_FILES:
|
|
264
|
+
if required not in files:
|
|
265
|
+
issues.add("manifest_required_file", f"files must list {required}", section="3")
|
|
266
|
+
for path, entry in files.items():
|
|
267
|
+
if path not in _ROOT_FILES and not path.startswith("files/"):
|
|
268
|
+
issues.add(
|
|
269
|
+
"manifest_path_location",
|
|
270
|
+
"entries other than sci.json, README.md, and LICENSE must be under files/",
|
|
271
|
+
section="3",
|
|
272
|
+
observed=path,
|
|
273
|
+
)
|
|
274
|
+
if not _SHA256_HEX.fullmatch(entry.sha256) or not 0 <= entry.bytes <= MAX_SAFE_INTEGER:
|
|
275
|
+
issues.add("manifest_entry", "invalid file hash or size", section="3", observed=path)
|
|
276
|
+
issues.raise_if_any()
|
|
277
|
+
return serialize_manifest(public_key, files)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def serialize_manifest(public_key: PublicKey, files: Mapping[str, FileEntry]) -> bytes:
|
|
281
|
+
"""The stable serializer used by :func:`build_manifest`, without validation.
|
|
282
|
+
|
|
283
|
+
Useful for planning sizes of drafts that still have problems.
|
|
284
|
+
Non-ASCII characters are written as JSON escapes.
|
|
285
|
+
"""
|
|
286
|
+
document = {
|
|
287
|
+
"schema": PUBLICATION_SCHEMA,
|
|
288
|
+
"public_key": public_key.to_jwk(),
|
|
289
|
+
"files": {
|
|
290
|
+
path: {"sha256": files[path].sha256, "bytes": files[path].bytes}
|
|
291
|
+
for path in sorted(files, key=path_sort_key)
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
return json.dumps(document, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def sign_manifest(manifest_bytes: bytes, private_key: Ed25519PrivateKey) -> bytes:
|
|
298
|
+
"""Sign exact manifest bytes after checking the embedded key matches."""
|
|
299
|
+
manifest = parse_manifest(manifest_bytes)
|
|
300
|
+
if manifest.public_key != public_key_of(private_key):
|
|
301
|
+
raise ValueError("private key does not match the manifest public_key")
|
|
302
|
+
return ed25519.sign(private_key, manifest_bytes)
|