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