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/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
+ ]