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/draft.py ADDED
@@ -0,0 +1,374 @@
1
+ """Unsigned drafts: checks before signing (§2-§4, §2.1) and signing (§3, §5.1).
2
+
3
+ A draft is a set of files at their bundle paths (``sci.json``, ``README.md``,
4
+ optional ``LICENSE``, ``files/...``) without ``manifest.json`` and
5
+ ``manifest.json.sig``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import os
12
+ import stat
13
+ from collections.abc import Collection, Mapping
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Final
17
+
18
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
19
+
20
+ from . import ed25519
21
+ from .baseline import BaselineReport, evaluate_baseline
22
+ from .bundle import VerificationResult, verify_loose
23
+ from .errors import Incomplete, Issue, IssueCollector, Outcome, SciError
24
+ from .keys import PublicKey, looks_like_private_key, public_key_of
25
+ from .manifest import (
26
+ LICENSE_PATH,
27
+ MANIFEST_PATH,
28
+ METADATA_PATH,
29
+ README_PATH,
30
+ SIGNATURE_PATH,
31
+ TIMESTAMP_SIDECAR_PATH,
32
+ FileEntry,
33
+ build_manifest,
34
+ serialize_manifest,
35
+ )
36
+ from .metadata import Metadata, parse_metadata
37
+ from .paths import check_paths
38
+ from .png import PngStructure, display_problem, is_display_candidate, png_decode_problem
39
+ from .source import ByteSource, PathSource, as_source, iter_chunks
40
+ from .text import TextFileValidator
41
+ from .zipformat import canonical_zip_size
42
+
43
+ __all__ = [
44
+ "GENERATED_PATHS",
45
+ "BaselineRefused",
46
+ "DraftReport",
47
+ "SignedPublication",
48
+ "check_draft",
49
+ "load_draft",
50
+ "sign_draft",
51
+ ]
52
+
53
+ GENERATED_PATHS: Final = frozenset({MANIFEST_PATH, SIGNATURE_PATH, TIMESTAMP_SIDECAR_PATH})
54
+ """Files produced by signing/timestamping; never part of the draft content."""
55
+
56
+ _PLACEHOLDER_KEY: Final = PublicKey(bytes(32))
57
+ _PLACEHOLDER_HASH: Final = "0" * 64
58
+ _METADATA_READ_LIMIT: Final = 16 * 1024**2
59
+ _KEY_SCAN_OVERLAP: Final = 64
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class DraftReport:
64
+ """Result of :func:`check_draft`.
65
+
66
+ ``valid`` means a signature over these files would yield a protocol-valid
67
+ publication. Baseline eligibility is reported separately in ``baseline``.
68
+ ``signed`` is True if generated files (manifest, signature) were present
69
+ and ignored.
70
+ """
71
+
72
+ issues: tuple[Issue, ...]
73
+ files: Mapping[str, int]
74
+ metadata: Metadata | None
75
+ manifest_bytes: int | None
76
+ zip_bytes: int | None
77
+ baseline: BaselineReport | None
78
+ signed: bool = False
79
+ display_warnings: tuple[Issue, ...] = ()
80
+ """Display images that pass the baseline checks but do not decode (§6.2)."""
81
+
82
+ @property
83
+ def valid(self) -> bool:
84
+ return not self.issues
85
+
86
+ @property
87
+ def outcome(self) -> Outcome:
88
+ return IssueCollector(list(self.issues)).outcome
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class SignedPublication:
93
+ """A signed bundle: exact manifest bytes, raw signature, and its verification."""
94
+
95
+ manifest_bytes: bytes
96
+ signature: bytes
97
+ publication_id: str
98
+ verification: VerificationResult
99
+
100
+
101
+ class BaselineRefused(Exception):
102
+ """Signing stopped because the draft does not meet the baseline (§2.1)."""
103
+
104
+ def __init__(self, report: BaselineReport) -> None:
105
+ self.report = report
106
+ super().__init__("; ".join(str(reason) for reason in report.reasons))
107
+
108
+
109
+ def load_draft(
110
+ directory: str | os.PathLike[str], ignore: Collection[str] = ()
111
+ ) -> tuple[dict[str, ByteSource], list[Issue], bool]:
112
+ """Collect draft files from ``directory``.
113
+
114
+ Returns ``(sources, issues, signed)``: sources keyed by relative POSIX
115
+ path; issues for symbolic links and special files; whether generated
116
+ files (manifest, signature, sidecar) were present (they are excluded).
117
+ Paths in ``ignore`` (relative POSIX paths) are skipped.
118
+ """
119
+ root = Path(directory)
120
+ sources: dict[str, ByteSource] = {}
121
+ issues: list[Issue] = []
122
+ signed = False
123
+ stack = [root]
124
+ while stack:
125
+ current = stack.pop()
126
+ with os.scandir(current) as iterator:
127
+ for item in sorted(iterator, key=lambda e: e.name):
128
+ relative = Path(item.path).relative_to(root).as_posix()
129
+ if relative in ignore:
130
+ continue
131
+ info = item.stat(follow_symlinks=False)
132
+ if stat.S_ISLNK(info.st_mode) or not (
133
+ stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)
134
+ ):
135
+ issues.append(
136
+ Issue(
137
+ "not_regular_file",
138
+ "drafts may contain only regular "
139
+ "files and directories (no symbolic links or special "
140
+ "files)",
141
+ section="2",
142
+ file=relative,
143
+ )
144
+ )
145
+ elif stat.S_ISDIR(info.st_mode):
146
+ stack.append(Path(item.path))
147
+ elif relative in GENERATED_PATHS:
148
+ signed = True
149
+ else:
150
+ sources[relative] = PathSource(item.path, info.st_size)
151
+ return sources, issues, signed
152
+
153
+
154
+ def _contains_private_key(source: ByteSource) -> bool:
155
+ tail = b""
156
+ for chunk in iter_chunks(source):
157
+ if looks_like_private_key(tail + chunk):
158
+ return True
159
+ tail = chunk[-_KEY_SCAN_OVERLAP:]
160
+ return False
161
+
162
+
163
+ def check_draft(
164
+ draft: str | os.PathLike[str] | Mapping[str, ByteSource | bytes],
165
+ *,
166
+ ignore: Collection[str] = (),
167
+ ) -> DraftReport:
168
+ """Check an unsigned draft without signing it (CLI ``sci check``).
169
+
170
+ Reports all independent problems together: paths and conflicts (§3.1),
171
+ required files (§3), ``sci.json`` (§4), README/LICENSE text (§2, §4.2),
172
+ private-key material (§2), and the planned manifest and ZIP sizes with
173
+ baseline eligibility (§2.1). Reads no private key and writes nothing.
174
+ """
175
+ if isinstance(draft, Mapping):
176
+ sources = {path: as_source(value) for path, value in draft.items()}
177
+ signed = any(path in GENERATED_PATHS for path in sources)
178
+ sources = {p: s for p, s in sources.items() if p not in GENERATED_PATHS}
179
+ load_issues: list[Issue] = []
180
+ else:
181
+ sources, load_issues, signed = load_draft(draft, ignore)
182
+ collector = IssueCollector(list(load_issues))
183
+ try:
184
+ return _check(sources, collector, signed)
185
+ except OSError as exc:
186
+ collector.add(
187
+ "input_unavailable",
188
+ "draft files could not be read",
189
+ section="6",
190
+ observed=str(exc),
191
+ outcome=Outcome.INCOMPLETE,
192
+ )
193
+ return DraftReport(tuple(collector.issues), {}, None, None, None, None, signed)
194
+
195
+
196
+ def _check(sources: dict[str, ByteSource], collector: IssueCollector, signed: bool) -> DraftReport:
197
+ paths = list(sources)
198
+ for required in (METADATA_PATH, README_PATH):
199
+ if required not in sources:
200
+ collector.add(
201
+ "draft_required_file",
202
+ f"a draft must contain {required}",
203
+ section="2",
204
+ file=required,
205
+ )
206
+ for path in paths:
207
+ if path not in (METADATA_PATH, README_PATH, LICENSE_PATH) and not path.startswith("files/"):
208
+ collector.add(
209
+ "manifest_path_location",
210
+ "files other than sci.json, README.md, and LICENSE must be under files/",
211
+ section="3",
212
+ file=path,
213
+ )
214
+ check_paths(paths, collector=collector)
215
+ for path, source in sources.items():
216
+ if _contains_private_key(source):
217
+ collector.add(
218
+ "private_key_material",
219
+ "publishing tools must not include private key material in a bundle",
220
+ section="2",
221
+ file=path,
222
+ )
223
+ metadata = None
224
+ if METADATA_PATH in sources:
225
+ source = sources[METADATA_PATH]
226
+ if source.size > _METADATA_READ_LIMIT:
227
+ collector.add(
228
+ "local_size_limit",
229
+ "sci.json exceeds the local size limit",
230
+ section="6",
231
+ file=METADATA_PATH,
232
+ observed=source.size,
233
+ outcome=Outcome.INCOMPLETE,
234
+ )
235
+ else:
236
+ try:
237
+ metadata = parse_metadata(source.read_at(0, source.size), paths)
238
+ except SciError as exc:
239
+ collector.extend(exc.issues)
240
+ for path, nonblank in ((README_PATH, True), (LICENSE_PATH, False)):
241
+ if path in sources:
242
+ validator = TextFileValidator(file=path, nonblank=nonblank)
243
+ for chunk in iter_chunks(sources[path]):
244
+ if not validator.feed(chunk):
245
+ break
246
+ collector.extend(validator.finish())
247
+ sizes = {path: source.size for path, source in sources.items()}
248
+ display_problems, display_warnings = _check_display(metadata, sources)
249
+ manifest_size = zip_size = None
250
+ baseline = None
251
+ planned = serialize_manifest(
252
+ _PLACEHOLDER_KEY, {p: FileEntry(_PLACEHOLDER_HASH, n) for p, n in sizes.items()}
253
+ )
254
+ if planned:
255
+ manifest_size = len(planned)
256
+ zip_size = canonical_zip_size(
257
+ [(MANIFEST_PATH, manifest_size), (SIGNATURE_PATH, 64), *sizes.items()]
258
+ )
259
+ license_hash = None
260
+ if LICENSE_PATH in sources:
261
+ digest = hashlib.sha256()
262
+ for chunk in iter_chunks(sources[LICENSE_PATH]):
263
+ digest.update(chunk)
264
+ license_hash = digest.hexdigest()
265
+ baseline = evaluate_baseline(
266
+ zip_bytes=zip_size,
267
+ file_count=len(sizes),
268
+ display_items=len(metadata.display) if metadata else 0,
269
+ manifest_bytes=manifest_size,
270
+ metadata_bytes=sizes.get(METADATA_PATH, 0),
271
+ readme_bytes=sizes.get(README_PATH, 0),
272
+ license_sha256=license_hash,
273
+ display_problems=display_problems,
274
+ )
275
+ return DraftReport(
276
+ tuple(collector.issues),
277
+ sizes,
278
+ metadata,
279
+ manifest_size,
280
+ zip_size,
281
+ baseline,
282
+ signed,
283
+ display_warnings,
284
+ )
285
+
286
+
287
+ def _check_display(
288
+ metadata: Metadata | None, sources: Mapping[str, ByteSource]
289
+ ) -> tuple[dict[str, str], tuple[Issue, ...]]:
290
+ """Baseline problems (§2.1) and decode warnings (§6.2) for the display files."""
291
+ problems: dict[str, str] = {}
292
+ warnings: list[Issue] = []
293
+ for item in metadata.display if metadata else ():
294
+ source = sources[item.path]
295
+ structure = None
296
+ if is_display_candidate(item.path, source.size):
297
+ structure = PngStructure()
298
+ for chunk in iter_chunks(source):
299
+ structure.feed(chunk)
300
+ problem = display_problem(item.path, source.size, structure)
301
+ if problem is not None:
302
+ problems[item.path] = problem
303
+ continue
304
+ reason = png_decode_problem(source.read_at(0, source.size))
305
+ if reason is not None:
306
+ warnings.append(
307
+ Issue(
308
+ "display_undecodable",
309
+ "publishing tools should check that display images decode",
310
+ section="6.2",
311
+ file=item.path,
312
+ observed=reason,
313
+ )
314
+ )
315
+ return problems, tuple(warnings)
316
+
317
+
318
+ def sign_draft(
319
+ draft: str | os.PathLike[str] | Mapping[str, ByteSource | bytes],
320
+ private_key: Ed25519PrivateKey,
321
+ *,
322
+ allow_nonbaseline: bool = False,
323
+ ignore: Collection[str] = (),
324
+ ) -> SignedPublication:
325
+ """Check, build the manifest, sign (§5.1), and verify the result (§6.4).
326
+
327
+ Nothing is written; the caller stores ``manifest_bytes`` and
328
+ ``signature`` (and optionally a ZIP via :func:`pysci.bundle.write_bundle_zip`).
329
+
330
+ Raises:
331
+ Invalid: the draft is not protocol-valid (all issues attached).
332
+ BaselineRefused: baseline unmet and ``allow_nonbaseline`` is False.
333
+ Incomplete: files could not be read or changed during signing.
334
+ """
335
+ if isinstance(draft, Mapping):
336
+ sources = {p: as_source(v) for p, v in draft.items() if p not in GENERATED_PATHS}
337
+ else:
338
+ sources, _, _ = load_draft(draft, ignore)
339
+ report = (
340
+ check_draft(sources) if isinstance(draft, Mapping) else check_draft(draft, ignore=ignore)
341
+ )
342
+ if report.issues:
343
+ IssueCollector(list(report.issues)).raise_if_any()
344
+ assert report.baseline is not None
345
+ if not report.baseline.eligible and not allow_nonbaseline:
346
+ raise BaselineRefused(report.baseline)
347
+ entries: dict[str, FileEntry] = {}
348
+ for path, source in sources.items():
349
+ digest = hashlib.sha256()
350
+ for chunk in iter_chunks(source):
351
+ digest.update(chunk)
352
+ entries[path] = FileEntry(digest.hexdigest(), source.size)
353
+ manifest_bytes = build_manifest(public_key_of(private_key), entries)
354
+ signature = ed25519.sign(private_key, manifest_bytes)
355
+ files: dict[str, ByteSource | bytes] = {
356
+ MANIFEST_PATH: manifest_bytes,
357
+ SIGNATURE_PATH: signature,
358
+ **sources,
359
+ }
360
+ result = verify_loose(files)
361
+ if not result.ok:
362
+ if any(i.code in ("file_hash_mismatch", "file_size_mismatch") for i in result.issues):
363
+ raise Incomplete(
364
+ Issue(
365
+ "draft_changed",
366
+ "draft files changed during signing",
367
+ section="6",
368
+ outcome=Outcome.INCOMPLETE,
369
+ )
370
+ )
371
+ IssueCollector(list(result.issues)).raise_if_any()
372
+ return SignedPublication(
373
+ manifest_bytes, signature, "sha256:" + hashlib.sha256(manifest_bytes).hexdigest(), result
374
+ )
pysci/ed25519.py ADDED
@@ -0,0 +1,153 @@
1
+ """Ed25519 signatures with the protocol's additional point checks (§5.1).
2
+
3
+ Verification follows RFC 8032 §5.1.7 and additionally requires that the public
4
+ key ``A`` and the signature point ``R`` are canonical encodings of nonidentity
5
+ points in the prime-order subgroup. With both points in that subgroup the
6
+ cofactored and cofactorless equations agree, so the final equation is checked
7
+ by ``cryptography`` (OpenSSL), which also rejects ``S >= L``; the scalar range
8
+ is checked here as well so that the failure is reported precisely.
9
+
10
+ Signing uses ``cryptography``, which implements the deterministic procedure
11
+ of RFC 8032 §5.1.6.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from functools import lru_cache
17
+ from typing import Final
18
+
19
+ from cryptography.exceptions import InvalidSignature
20
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
21
+
22
+ from .errors import Invalid, Issue
23
+
24
+ __all__ = [
25
+ "GROUP_ORDER",
26
+ "SIGNATURE_LENGTH",
27
+ "check_public_key_point",
28
+ "sign",
29
+ "verify",
30
+ ]
31
+
32
+ _P: Final = 2**255 - 19
33
+ GROUP_ORDER: Final = 2**252 + 27742317777372353535851937790883648493
34
+ """The prime order L of the Ed25519 base-point subgroup."""
35
+ _D: Final = (-121665 * pow(121666, _P - 2, _P)) % _P
36
+ _SQRT_M1: Final = pow(2, (_P - 1) // 4, _P)
37
+ SIGNATURE_LENGTH: Final = 64
38
+
39
+ _Point = tuple[int, int, int, int] # extended twisted Edwards coordinates
40
+ _IDENTITY: Final[_Point] = (0, 1, 1, 0)
41
+
42
+
43
+ def _decode_point(encoded: bytes) -> _Point | None:
44
+ """RFC 8032 §5.1.3 decoding; None for a noncanonical or off-curve encoding."""
45
+ if len(encoded) != 32:
46
+ return None
47
+ value = int.from_bytes(encoded, "little")
48
+ sign = value >> 255
49
+ y = value & ((1 << 255) - 1)
50
+ if y >= _P:
51
+ return None
52
+ u = (y * y - 1) % _P
53
+ v = (_D * y * y + 1) % _P
54
+ x2 = u * pow(v, _P - 2, _P) % _P
55
+ x = pow(x2, (_P + 3) // 8, _P)
56
+ if (x * x - x2) % _P != 0:
57
+ x = x * _SQRT_M1 % _P
58
+ if (x * x - x2) % _P != 0:
59
+ return None
60
+ if x == 0 and sign == 1:
61
+ return None
62
+ if (x & 1) != sign:
63
+ x = _P - x
64
+ return (x, y, 1, x * y % _P)
65
+
66
+
67
+ def _add(a: _Point, b: _Point) -> _Point:
68
+ x1, y1, z1, t1 = a
69
+ x2, y2, z2, t2 = b
70
+ k_a = (y1 - x1) * (y2 - x2) % _P
71
+ k_b = (y1 + x1) * (y2 + x2) % _P
72
+ k_c = 2 * t1 * t2 * _D % _P
73
+ k_d = 2 * z1 * z2 % _P
74
+ e, f, g, h = k_b - k_a, k_d - k_c, k_d + k_c, k_b + k_a
75
+ return (e * f % _P, g * h % _P, f * g % _P, e * h % _P)
76
+
77
+
78
+ def _multiply(scalar: int, point: _Point) -> _Point:
79
+ result = _IDENTITY
80
+ addend = point
81
+ while scalar:
82
+ if scalar & 1:
83
+ result = _add(result, addend)
84
+ addend = _add(addend, addend)
85
+ scalar >>= 1
86
+ return result
87
+
88
+
89
+ def _is_identity(point: _Point) -> bool:
90
+ x, y, z, _ = point
91
+ return x % _P == 0 and (y - z) % _P == 0
92
+
93
+
94
+ @lru_cache(maxsize=4096)
95
+ def _point_problem(encoded: bytes) -> str | None:
96
+ """Return why ``encoded`` is not an admissible point, or None."""
97
+ point = _decode_point(encoded)
98
+ if point is None:
99
+ return "noncanonical or invalid point encoding"
100
+ if _is_identity(point):
101
+ return "identity point"
102
+ if not _is_identity(_multiply(GROUP_ORDER, point)):
103
+ return "point outside the prime-order subgroup"
104
+ return None
105
+
106
+
107
+ def check_public_key_point(public_key: bytes, *, file: str | None = None) -> None:
108
+ """Require ``A`` to be a canonical nonidentity prime-order point (§5.1)."""
109
+ problem = _point_problem(bytes(public_key))
110
+ if problem is not None:
111
+ raise Invalid(
112
+ Issue(
113
+ "public_key_point",
114
+ "public key A must be a nonidentity member of the prime-order subgroup",
115
+ section="5.1",
116
+ file=file,
117
+ field="/public_key/x",
118
+ observed=problem,
119
+ )
120
+ )
121
+
122
+
123
+ def verify(public_key: bytes, signature: bytes, message: bytes, *, file: str | None = None) -> None:
124
+ """Verify a pure Ed25519 signature under §5.1. Raises :class:`Invalid`."""
125
+
126
+ def fail(code: str, requirement: str, observed: str | None = None) -> Invalid:
127
+ return Invalid(Issue(code, requirement, section="5.1", file=file, observed=observed))
128
+
129
+ if len(signature) != SIGNATURE_LENGTH:
130
+ raise fail(
131
+ "signature_length",
132
+ "signature must be exactly the raw 64 Ed25519 signature bytes",
133
+ f"{len(signature)} bytes",
134
+ )
135
+ check_public_key_point(public_key, file=file)
136
+ problem = _point_problem(bytes(signature[:32]))
137
+ if problem is not None:
138
+ raise fail(
139
+ "signature_r_point",
140
+ "signature point R must be a nonidentity member of the prime-order subgroup",
141
+ problem,
142
+ )
143
+ if int.from_bytes(signature[32:], "little") >= GROUP_ORDER:
144
+ raise fail("signature_scalar", "signature scalar S must be less than L (RFC 8032 §5.1.7)")
145
+ try:
146
+ Ed25519PublicKey.from_public_bytes(bytes(public_key)).verify(bytes(signature), message)
147
+ except (InvalidSignature, ValueError):
148
+ raise fail("signature_mismatch", "Ed25519 signature does not verify") from None
149
+
150
+
151
+ def sign(private_key: Ed25519PrivateKey, message: bytes) -> bytes:
152
+ """Deterministic RFC 8032 §5.1.6 signature (64 raw bytes)."""
153
+ return private_key.sign(message)