pyencode-protector 0.3.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.
- pyencode/__init__.py +5 -0
- pyencode/__main__.py +8 -0
- pyencode/builder.py +628 -0
- pyencode/cli.py +136 -0
- pyencode/code_hardening.py +185 -0
- pyencode/container.py +319 -0
- pyencode/crypto.py +319 -0
- pyencode/discovery.py +168 -0
- pyencode/errors.py +59 -0
- pyencode/integrity.py +88 -0
- pyencode/inventory.py +356 -0
- pyencode/manifest.py +196 -0
- pyencode/opaque.py +574 -0
- pyencode/runtime_template/__init__.py +99 -0
- pyencode/runtime_template/_build.py +15 -0
- pyencode/runtime_template/_mp_main.py +7 -0
- pyencode/runtime_template/_runtime.py +1488 -0
- pyencode/source.py +50 -0
- pyencode_protector-0.3.0.dist-info/METADATA +276 -0
- pyencode_protector-0.3.0.dist-info/RECORD +23 -0
- pyencode_protector-0.3.0.dist-info/WHEEL +5 -0
- pyencode_protector-0.3.0.dist-info/entry_points.txt +2 -0
- pyencode_protector-0.3.0.dist-info/top_level.txt +1 -0
pyencode/inventory.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Safe, deterministic inventories for files shipped with protected output.
|
|
2
|
+
|
|
3
|
+
The builder can sign the mappings produced here. Generated runtimes mirror
|
|
4
|
+
the same deliberately small set of rules rather than importing this module.
|
|
5
|
+
Inventory paths are always canonical POSIX relative paths, independent of the
|
|
6
|
+
host operating system.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
15
|
+
from typing import Iterable, Iterator
|
|
16
|
+
|
|
17
|
+
from .errors import PyEncodeError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CODE_BEARING_SUFFIXES = frozenset(
|
|
21
|
+
{".py", ".pyc", ".pyo", ".pyd", ".so", ".dll", ".dylib"}
|
|
22
|
+
)
|
|
23
|
+
_HASH_CHUNK_SIZE = 1024 * 1024
|
|
24
|
+
_WINDOWS_FORBIDDEN_CHARACTERS = frozenset('<>:"|?*')
|
|
25
|
+
_WINDOWS_RESERVED_BASENAMES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"aux",
|
|
28
|
+
"con",
|
|
29
|
+
"conin$",
|
|
30
|
+
"conout$",
|
|
31
|
+
"nul",
|
|
32
|
+
"prn",
|
|
33
|
+
*(f"com{index}" for index in range(1, 10)),
|
|
34
|
+
*(f"lpt{index}" for index in range(1, 10)),
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_portable_component(component: str) -> bool:
|
|
40
|
+
if component.endswith((".", " ")):
|
|
41
|
+
return False
|
|
42
|
+
if any(
|
|
43
|
+
ord(character) < 32 or character in _WINDOWS_FORBIDDEN_CHARACTERS
|
|
44
|
+
for character in component
|
|
45
|
+
):
|
|
46
|
+
return False
|
|
47
|
+
basename = component.partition(".")[0].casefold()
|
|
48
|
+
return basename not in _WINDOWS_RESERVED_BASENAMES
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_inventory_path(value: str) -> str:
|
|
52
|
+
"""Return *value* if it is a canonical, portable relative POSIX path.
|
|
53
|
+
|
|
54
|
+
Backslashes and Windows drive/alternate-stream syntax are rejected so a
|
|
55
|
+
manifest has one interpretation on both Windows and POSIX systems.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
if not isinstance(value, str) or not value or "\x00" in value or "\\" in value:
|
|
59
|
+
raise PyEncodeError(
|
|
60
|
+
"inventory paths must be non-empty POSIX relative paths"
|
|
61
|
+
)
|
|
62
|
+
path = PurePosixPath(value)
|
|
63
|
+
windows_path = PureWindowsPath(value)
|
|
64
|
+
if (
|
|
65
|
+
path.is_absolute()
|
|
66
|
+
or windows_path.is_absolute()
|
|
67
|
+
or bool(windows_path.drive)
|
|
68
|
+
or any(
|
|
69
|
+
part in ("", ".", "..") or not _is_portable_component(part)
|
|
70
|
+
for part in path.parts
|
|
71
|
+
)
|
|
72
|
+
):
|
|
73
|
+
raise PyEncodeError(f"unsafe inventory path: {value!r}")
|
|
74
|
+
canonical = path.as_posix()
|
|
75
|
+
if canonical != value:
|
|
76
|
+
raise PyEncodeError(f"inventory path is not canonical: {value!r}")
|
|
77
|
+
return canonical
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _path_prefixes(path: str) -> Iterator[str]:
|
|
81
|
+
parts = PurePosixPath(path).parts
|
|
82
|
+
for end in range(1, len(parts) + 1):
|
|
83
|
+
yield "/".join(parts[:end])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_inventory_paths(paths: Iterable[str]) -> tuple[str, ...]:
|
|
87
|
+
"""Validate, de-duplicate and sort inventory paths.
|
|
88
|
+
|
|
89
|
+
Case-fold collisions are rejected for every path prefix. Checking
|
|
90
|
+
prefixes catches trees such as ``Pkg/a.py`` and ``pkg/b.py`` which cannot
|
|
91
|
+
be represented consistently after copying to a case-insensitive system.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
normalized: list[str] = []
|
|
95
|
+
exact_paths: set[str] = set()
|
|
96
|
+
folded_prefixes: dict[str, str] = {}
|
|
97
|
+
for raw_path in paths:
|
|
98
|
+
path = validate_inventory_path(raw_path)
|
|
99
|
+
if path in exact_paths:
|
|
100
|
+
raise PyEncodeError(f"duplicate inventory path: {path!r}")
|
|
101
|
+
exact_paths.add(path)
|
|
102
|
+
for prefix in _path_prefixes(path):
|
|
103
|
+
folded = prefix.casefold()
|
|
104
|
+
previous = folded_prefixes.get(folded)
|
|
105
|
+
if previous is not None and previous != prefix:
|
|
106
|
+
raise PyEncodeError(
|
|
107
|
+
"case-folding inventory path collision: "
|
|
108
|
+
f"{previous!r} and {prefix!r}"
|
|
109
|
+
)
|
|
110
|
+
folded_prefixes[folded] = prefix
|
|
111
|
+
normalized.append(path)
|
|
112
|
+
return tuple(sorted(normalized))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_code_bearing_path(path: str) -> bool:
|
|
116
|
+
"""Return whether *path* names Python or native executable code."""
|
|
117
|
+
|
|
118
|
+
canonical = validate_inventory_path(path)
|
|
119
|
+
name = PurePosixPath(canonical).name.casefold()
|
|
120
|
+
if any(name.endswith(suffix) for suffix in CODE_BEARING_SUFFIXES):
|
|
121
|
+
return True
|
|
122
|
+
# Versioned ELF shared objects conventionally end in e.g. ``.so.1``.
|
|
123
|
+
return ".so." in name
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _is_link_or_reparse(metadata: os.stat_result) -> bool:
|
|
127
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
128
|
+
return True
|
|
129
|
+
reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
130
|
+
attributes = getattr(metadata, "st_file_attributes", 0)
|
|
131
|
+
return bool(reparse_flag and attributes & reparse_flag)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _inventory_root(root: Path) -> Path:
|
|
135
|
+
source = Path(root).expanduser()
|
|
136
|
+
try:
|
|
137
|
+
resolved = source.resolve(strict=True)
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise PyEncodeError(f"inventory root is missing or unreadable: {source}") from exc
|
|
140
|
+
if not resolved.is_dir():
|
|
141
|
+
raise PyEncodeError(f"inventory root is not a directory: {resolved}")
|
|
142
|
+
return resolved
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _exact_child(directory: Path, name: str, relative: str) -> os.DirEntry[str]:
|
|
146
|
+
try:
|
|
147
|
+
with os.scandir(directory) as entries:
|
|
148
|
+
exact: os.DirEntry[str] | None = None
|
|
149
|
+
folded_match: str | None = None
|
|
150
|
+
for entry in entries:
|
|
151
|
+
if entry.name == name:
|
|
152
|
+
exact = entry
|
|
153
|
+
break
|
|
154
|
+
if entry.name.casefold() == name.casefold():
|
|
155
|
+
folded_match = entry.name
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
raise PyEncodeError(
|
|
158
|
+
f"cannot inspect parent directory for inventory file {relative!r}"
|
|
159
|
+
) from exc
|
|
160
|
+
if exact is not None:
|
|
161
|
+
return exact
|
|
162
|
+
if folded_match is not None:
|
|
163
|
+
raise PyEncodeError(
|
|
164
|
+
"inventory path casing does not match the filesystem: "
|
|
165
|
+
f"{relative!r} refers to {folded_match!r}"
|
|
166
|
+
)
|
|
167
|
+
raise PyEncodeError(f"inventory file is missing: {relative}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _exact_regular_file(root: Path, relative: str) -> tuple[Path, os.stat_result]:
|
|
171
|
+
current = root
|
|
172
|
+
parts = PurePosixPath(relative).parts
|
|
173
|
+
for index, part in enumerate(parts):
|
|
174
|
+
entry = _exact_child(current, part, relative)
|
|
175
|
+
try:
|
|
176
|
+
metadata = entry.stat(follow_symlinks=False)
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
raise PyEncodeError(f"cannot stat inventory file: {relative}") from exc
|
|
179
|
+
if _is_link_or_reparse(metadata):
|
|
180
|
+
raise PyEncodeError(
|
|
181
|
+
f"inventory path contains a symlink or reparse point: {relative!r}"
|
|
182
|
+
)
|
|
183
|
+
is_last = index == len(parts) - 1
|
|
184
|
+
if is_last:
|
|
185
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
186
|
+
raise PyEncodeError(
|
|
187
|
+
f"inventory path is not a regular file: {relative!r}"
|
|
188
|
+
)
|
|
189
|
+
elif not stat.S_ISDIR(metadata.st_mode):
|
|
190
|
+
raise PyEncodeError(
|
|
191
|
+
f"inventory path parent is not a directory: {relative!r}"
|
|
192
|
+
)
|
|
193
|
+
current = Path(entry.path)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
resolved = current.resolve(strict=True)
|
|
197
|
+
resolved.relative_to(root)
|
|
198
|
+
except (OSError, ValueError) as exc:
|
|
199
|
+
raise PyEncodeError(f"inventory file escapes its root: {relative!r}") from exc
|
|
200
|
+
try:
|
|
201
|
+
final_metadata = os.stat(resolved, follow_symlinks=False)
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise PyEncodeError(f"cannot stat inventory file: {relative}") from exc
|
|
204
|
+
if _is_link_or_reparse(final_metadata) or not stat.S_ISREG(final_metadata.st_mode):
|
|
205
|
+
raise PyEncodeError(f"inventory path is not a regular file: {relative!r}")
|
|
206
|
+
return resolved, final_metadata
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _hash_regular_file(path: Path, expected: os.stat_result) -> str:
|
|
210
|
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
|
|
211
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
212
|
+
try:
|
|
213
|
+
descriptor = os.open(path, flags)
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
raise PyEncodeError(f"cannot open inventory file: {path}") from exc
|
|
216
|
+
digest = hashlib.sha256()
|
|
217
|
+
try:
|
|
218
|
+
opened = os.fstat(descriptor)
|
|
219
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
220
|
+
raise PyEncodeError(f"inventory path is not a regular file: {path}")
|
|
221
|
+
expected_identity = (expected.st_dev, expected.st_ino)
|
|
222
|
+
opened_identity = (opened.st_dev, opened.st_ino)
|
|
223
|
+
if expected_identity != opened_identity:
|
|
224
|
+
raise PyEncodeError(f"inventory file changed while opening: {path}")
|
|
225
|
+
while True:
|
|
226
|
+
chunk = os.read(descriptor, _HASH_CHUNK_SIZE)
|
|
227
|
+
if not chunk:
|
|
228
|
+
break
|
|
229
|
+
digest.update(chunk)
|
|
230
|
+
finally:
|
|
231
|
+
os.close(descriptor)
|
|
232
|
+
return digest.hexdigest()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def hash_inventory_files(root: Path, relative_paths: Iterable[str]) -> dict[str, str]:
|
|
236
|
+
"""Hash exact regular non-symlink files below *root* with SHA-256."""
|
|
237
|
+
|
|
238
|
+
resolved_root = _inventory_root(root)
|
|
239
|
+
paths = normalize_inventory_paths(relative_paths)
|
|
240
|
+
result: dict[str, str] = {}
|
|
241
|
+
for relative in paths:
|
|
242
|
+
candidate, metadata = _exact_regular_file(resolved_root, relative)
|
|
243
|
+
result[relative] = _hash_regular_file(candidate, metadata)
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _walk_inventory(directory: Path, root: Path, prefixes: tuple[str, ...]) -> Iterator[str]:
|
|
248
|
+
try:
|
|
249
|
+
with os.scandir(directory) as iterator:
|
|
250
|
+
entries = sorted(iterator, key=lambda item: item.name)
|
|
251
|
+
except OSError as exc:
|
|
252
|
+
relative = "/".join(prefixes) or "."
|
|
253
|
+
raise PyEncodeError(f"cannot scan inventory directory: {relative}") from exc
|
|
254
|
+
|
|
255
|
+
folded_names: dict[str, str] = {}
|
|
256
|
+
for entry in entries:
|
|
257
|
+
folded = entry.name.casefold()
|
|
258
|
+
previous = folded_names.get(folded)
|
|
259
|
+
if previous is not None and previous != entry.name:
|
|
260
|
+
relative = "/".join(prefixes)
|
|
261
|
+
raise PyEncodeError(
|
|
262
|
+
"case-folding filesystem collision in inventory directory "
|
|
263
|
+
f"{relative or '.'!r}: {previous!r} and {entry.name!r}"
|
|
264
|
+
)
|
|
265
|
+
folded_names[folded] = entry.name
|
|
266
|
+
|
|
267
|
+
for entry in entries:
|
|
268
|
+
relative = "/".join((*prefixes, entry.name))
|
|
269
|
+
validate_inventory_path(relative)
|
|
270
|
+
try:
|
|
271
|
+
metadata = entry.stat(follow_symlinks=False)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
raise PyEncodeError(f"cannot stat inventory entry: {relative!r}") from exc
|
|
274
|
+
if _is_link_or_reparse(metadata):
|
|
275
|
+
raise PyEncodeError(
|
|
276
|
+
f"inventory contains a symlink or reparse point: {relative!r}"
|
|
277
|
+
)
|
|
278
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
279
|
+
yield from _walk_inventory(Path(entry.path), root, (*prefixes, entry.name))
|
|
280
|
+
elif stat.S_ISREG(metadata.st_mode):
|
|
281
|
+
try:
|
|
282
|
+
Path(entry.path).resolve(strict=True).relative_to(root)
|
|
283
|
+
except (OSError, ValueError) as exc:
|
|
284
|
+
raise PyEncodeError(f"inventory file escapes its root: {relative!r}") from exc
|
|
285
|
+
yield relative
|
|
286
|
+
else:
|
|
287
|
+
raise PyEncodeError(
|
|
288
|
+
f"inventory contains a non-regular filesystem entry: {relative!r}"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def discover_inventory_files(root: Path) -> tuple[str, ...]:
|
|
293
|
+
"""Return every regular file below *root*, rejecting ambiguous trees."""
|
|
294
|
+
|
|
295
|
+
resolved_root = _inventory_root(root)
|
|
296
|
+
return normalize_inventory_paths(_walk_inventory(resolved_root, resolved_root, ()))
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def find_unexpected_code_files(
|
|
300
|
+
root: Path, allowed_paths: Iterable[str]
|
|
301
|
+
) -> tuple[str, ...]:
|
|
302
|
+
"""Return code-bearing files present below *root* but absent from allowlist."""
|
|
303
|
+
|
|
304
|
+
allowed = normalize_inventory_paths(allowed_paths)
|
|
305
|
+
invalid = [path for path in allowed if not is_code_bearing_path(path)]
|
|
306
|
+
if invalid:
|
|
307
|
+
raise PyEncodeError(
|
|
308
|
+
"code-bearing allowlist contains non-code paths: " + ", ".join(invalid)
|
|
309
|
+
)
|
|
310
|
+
actual = {
|
|
311
|
+
path for path in discover_inventory_files(root) if is_code_bearing_path(path)
|
|
312
|
+
}
|
|
313
|
+
return tuple(sorted(actual.difference(allowed)))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def validate_code_inventory(root: Path, allowed_paths: Iterable[str]) -> tuple[str, ...]:
|
|
317
|
+
"""Validate that code-bearing files below *root* exactly match allowlist.
|
|
318
|
+
|
|
319
|
+
The normalized allowlist is returned so callers can reuse it in signed
|
|
320
|
+
metadata after validation.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
allowed = normalize_inventory_paths(allowed_paths)
|
|
324
|
+
invalid = [path for path in allowed if not is_code_bearing_path(path)]
|
|
325
|
+
if invalid:
|
|
326
|
+
raise PyEncodeError(
|
|
327
|
+
"code-bearing allowlist contains non-code paths: " + ", ".join(invalid)
|
|
328
|
+
)
|
|
329
|
+
actual = {
|
|
330
|
+
path for path in discover_inventory_files(root) if is_code_bearing_path(path)
|
|
331
|
+
}
|
|
332
|
+
expected = set(allowed)
|
|
333
|
+
missing = sorted(expected.difference(actual))
|
|
334
|
+
unexpected = sorted(actual.difference(expected))
|
|
335
|
+
if missing or unexpected:
|
|
336
|
+
details: list[str] = []
|
|
337
|
+
if missing:
|
|
338
|
+
details.append("missing " + ", ".join(missing))
|
|
339
|
+
if unexpected:
|
|
340
|
+
details.append("unexpected " + ", ".join(unexpected))
|
|
341
|
+
raise PyEncodeError(
|
|
342
|
+
"code-bearing distribution inventory mismatch (" + "; ".join(details) + ")"
|
|
343
|
+
)
|
|
344
|
+
return allowed
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
__all__ = [
|
|
348
|
+
"CODE_BEARING_SUFFIXES",
|
|
349
|
+
"discover_inventory_files",
|
|
350
|
+
"find_unexpected_code_files",
|
|
351
|
+
"hash_inventory_files",
|
|
352
|
+
"is_code_bearing_path",
|
|
353
|
+
"normalize_inventory_paths",
|
|
354
|
+
"validate_code_inventory",
|
|
355
|
+
"validate_inventory_path",
|
|
356
|
+
]
|
pyencode/manifest.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Canonical JSON and Ed25519 signatures for build manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Mapping, TypeAlias
|
|
9
|
+
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
11
|
+
|
|
12
|
+
from .crypto import (
|
|
13
|
+
PrivateKeyLike,
|
|
14
|
+
PublicKeyLike,
|
|
15
|
+
load_private_key,
|
|
16
|
+
public_key_bytes,
|
|
17
|
+
sign_bytes,
|
|
18
|
+
verify_bytes,
|
|
19
|
+
)
|
|
20
|
+
from .errors import ManifestError, SignatureError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
JsonObject: TypeAlias = Mapping[str, Any]
|
|
24
|
+
SIGNATURE_FIELD = "signature"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
28
|
+
"""Serialize a JSON value with pyencode's canonical JSON settings."""
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
return json.dumps(
|
|
32
|
+
value,
|
|
33
|
+
sort_keys=True,
|
|
34
|
+
separators=(",", ":"),
|
|
35
|
+
ensure_ascii=False,
|
|
36
|
+
allow_nan=False,
|
|
37
|
+
).encode("utf-8")
|
|
38
|
+
except (TypeError, ValueError, UnicodeEncodeError) as exc:
|
|
39
|
+
raise ManifestError("manifest cannot be encoded as canonical JSON") from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def canonical_manifest_bytes(manifest: JsonObject) -> bytes:
|
|
43
|
+
"""Validate and canonically serialize a manifest object."""
|
|
44
|
+
|
|
45
|
+
if not isinstance(manifest, Mapping):
|
|
46
|
+
raise ManifestError("manifest must be a JSON object")
|
|
47
|
+
# Materializing a dict avoids surprising behavior from mutable/custom
|
|
48
|
+
# Mapping implementations while serialization is in progress.
|
|
49
|
+
return canonical_json_bytes(dict(manifest))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sign_manifest(manifest: JsonObject, private_key: PrivateKeyLike) -> bytes:
|
|
53
|
+
"""Sign the exact canonical UTF-8 JSON representation of ``manifest``."""
|
|
54
|
+
|
|
55
|
+
return sign_bytes(canonical_manifest_bytes(manifest), private_key)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def verify_manifest(
|
|
59
|
+
manifest: JsonObject,
|
|
60
|
+
signature: bytes | bytearray | memoryview,
|
|
61
|
+
public_key: PublicKeyLike,
|
|
62
|
+
) -> bool:
|
|
63
|
+
"""Verify a canonical manifest, returning ``True`` or raising on failure."""
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
return verify_bytes(
|
|
67
|
+
canonical_manifest_bytes(manifest), signature, public_key
|
|
68
|
+
)
|
|
69
|
+
except SignatureError:
|
|
70
|
+
raise
|
|
71
|
+
except (TypeError, ValueError) as exc:
|
|
72
|
+
raise SignatureError("manifest signature is malformed") from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def encode_signature(signature: bytes | bytearray | memoryview) -> str:
|
|
76
|
+
"""Encode a signature as unambiguous standard Base64 text."""
|
|
77
|
+
|
|
78
|
+
if not isinstance(signature, (bytes, bytearray, memoryview)):
|
|
79
|
+
raise TypeError("signature must be bytes-like")
|
|
80
|
+
return base64.b64encode(bytes(signature)).decode("ascii")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def decode_signature(encoded: str) -> bytes:
|
|
84
|
+
"""Decode a strict standard-Base64 signature."""
|
|
85
|
+
|
|
86
|
+
if not isinstance(encoded, str):
|
|
87
|
+
raise SignatureError("manifest signature must be a Base64 string")
|
|
88
|
+
try:
|
|
89
|
+
signature = base64.b64decode(encoded.encode("ascii"), validate=True)
|
|
90
|
+
except (UnicodeEncodeError, binascii.Error, ValueError) as exc:
|
|
91
|
+
raise SignatureError("manifest signature is not valid Base64") from exc
|
|
92
|
+
if len(signature) != 64:
|
|
93
|
+
raise SignatureError("Ed25519 signature must be exactly 64 bytes")
|
|
94
|
+
return signature
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sign_manifest_base64(manifest: JsonObject, private_key: PrivateKeyLike) -> str:
|
|
98
|
+
"""Sign a manifest and return a JSON-friendly Base64 signature."""
|
|
99
|
+
|
|
100
|
+
return encode_signature(sign_manifest(manifest, private_key))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def verify_manifest_base64(
|
|
104
|
+
manifest: JsonObject,
|
|
105
|
+
signature: str,
|
|
106
|
+
public_key: PublicKeyLike,
|
|
107
|
+
) -> bool:
|
|
108
|
+
"""Verify a manifest with a strict Base64-encoded signature."""
|
|
109
|
+
|
|
110
|
+
return verify_manifest(manifest, decode_signature(signature), public_key)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def attach_signature(
|
|
114
|
+
manifest: JsonObject,
|
|
115
|
+
private_key: PrivateKeyLike,
|
|
116
|
+
*,
|
|
117
|
+
field: str = SIGNATURE_FIELD,
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
"""Return a copy containing a Base64 signature over all other fields."""
|
|
120
|
+
|
|
121
|
+
if not isinstance(manifest, Mapping):
|
|
122
|
+
raise ManifestError("manifest must be a JSON object")
|
|
123
|
+
if not isinstance(field, str) or not field:
|
|
124
|
+
raise ManifestError("signature field name must be a non-empty string")
|
|
125
|
+
unsigned = dict(manifest)
|
|
126
|
+
unsigned.pop(field, None)
|
|
127
|
+
signed = dict(unsigned)
|
|
128
|
+
signed[field] = sign_manifest_base64(unsigned, private_key)
|
|
129
|
+
return signed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify_signed_manifest(
|
|
133
|
+
signed_manifest: JsonObject,
|
|
134
|
+
public_key: PublicKeyLike,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Verify ``{'manifest': payload, 'signature': base64}`` and return payload."""
|
|
137
|
+
|
|
138
|
+
if not isinstance(signed_manifest, Mapping):
|
|
139
|
+
raise ManifestError("signed manifest wrapper must be a JSON object")
|
|
140
|
+
if set(signed_manifest) != {"manifest", SIGNATURE_FIELD}:
|
|
141
|
+
raise ManifestError(
|
|
142
|
+
"signed manifest wrapper must contain only 'manifest' and 'signature'"
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
payload = signed_manifest["manifest"]
|
|
146
|
+
encoded = signed_manifest[SIGNATURE_FIELD]
|
|
147
|
+
except KeyError as exc:
|
|
148
|
+
raise SignatureError("signed manifest wrapper is incomplete") from exc
|
|
149
|
+
if not isinstance(payload, Mapping):
|
|
150
|
+
raise ManifestError("wrapped manifest payload must be a JSON object")
|
|
151
|
+
if not isinstance(encoded, str):
|
|
152
|
+
raise SignatureError("embedded manifest signature must be a Base64 string")
|
|
153
|
+
verify_manifest_base64(payload, encoded, public_key)
|
|
154
|
+
return dict(payload)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_signed_manifest(
|
|
158
|
+
payload: JsonObject,
|
|
159
|
+
private_key: PrivateKeyLike | None = None,
|
|
160
|
+
) -> tuple[dict[str, Any], bytes, Ed25519PrivateKey]:
|
|
161
|
+
"""Create the portable signed-manifest wrapper used by the builder.
|
|
162
|
+
|
|
163
|
+
When ``private_key`` is omitted a fresh key is generated. The returned
|
|
164
|
+
tuple contains ``(wrapper, raw_public_key, private_key_object)`` so a caller
|
|
165
|
+
can persist the public runtime key and reuse or export the private key.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
if not isinstance(payload, Mapping):
|
|
169
|
+
raise ManifestError("manifest payload must be a JSON object")
|
|
170
|
+
signing_key = (
|
|
171
|
+
Ed25519PrivateKey.generate()
|
|
172
|
+
if private_key is None
|
|
173
|
+
else load_private_key(private_key)
|
|
174
|
+
)
|
|
175
|
+
normalized = dict(payload)
|
|
176
|
+
wrapper = {
|
|
177
|
+
"manifest": normalized,
|
|
178
|
+
SIGNATURE_FIELD: sign_manifest_base64(normalized, signing_key),
|
|
179
|
+
}
|
|
180
|
+
return wrapper, public_key_bytes(signing_key.public_key()), signing_key
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
__all__ = [
|
|
184
|
+
"SIGNATURE_FIELD",
|
|
185
|
+
"attach_signature",
|
|
186
|
+
"canonical_json_bytes",
|
|
187
|
+
"canonical_manifest_bytes",
|
|
188
|
+
"create_signed_manifest",
|
|
189
|
+
"decode_signature",
|
|
190
|
+
"encode_signature",
|
|
191
|
+
"sign_manifest",
|
|
192
|
+
"sign_manifest_base64",
|
|
193
|
+
"verify_manifest",
|
|
194
|
+
"verify_manifest_base64",
|
|
195
|
+
"verify_signed_manifest",
|
|
196
|
+
]
|