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