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/crypto.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Small, auditable cryptographic primitives used by pyencode.
|
|
2
|
+
|
|
3
|
+
No custom cipher is implemented here. Module keys are domain-separated with
|
|
4
|
+
HKDF-SHA256 and payloads are authenticated with AES-256-GCM.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import NamedTuple, TypeAlias
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidSignature, InvalidTag
|
|
14
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
16
|
+
Ed25519PrivateKey,
|
|
17
|
+
Ed25519PublicKey,
|
|
18
|
+
)
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
20
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
21
|
+
|
|
22
|
+
from .errors import CryptoError, IntegrityError, InvalidKeyError, SignatureError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
NONCE_SIZE = 12
|
|
26
|
+
KEY_SIZE = 32
|
|
27
|
+
TAG_SIZE = 16
|
|
28
|
+
BOUND_COMMITMENT_SIZE = 32
|
|
29
|
+
_BUILD_ID_RE = re.compile(r"[0-9a-fA-F]+\Z")
|
|
30
|
+
|
|
31
|
+
BytesLike: TypeAlias = bytes | bytearray | memoryview
|
|
32
|
+
PrivateKeyLike: TypeAlias = Ed25519PrivateKey | BytesLike
|
|
33
|
+
PublicKeyLike: TypeAlias = Ed25519PublicKey | BytesLike
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EncryptedPayload(NamedTuple):
|
|
37
|
+
"""The nonce and AES-GCM ciphertext (with its tag appended)."""
|
|
38
|
+
|
|
39
|
+
nonce: bytes
|
|
40
|
+
ciphertext: bytes
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Ed25519KeyPair(NamedTuple):
|
|
44
|
+
"""Raw 32-byte Ed25519 private and public keys."""
|
|
45
|
+
|
|
46
|
+
private_key: bytes
|
|
47
|
+
public_key: bytes
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_bytes(value: BytesLike, *, name: str) -> bytes:
|
|
51
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
52
|
+
raise CryptoError(f"{name} must be bytes-like")
|
|
53
|
+
return bytes(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_id_bytes(build_id: str) -> bytes:
|
|
57
|
+
if not isinstance(build_id, str):
|
|
58
|
+
raise CryptoError("build_id must be a string")
|
|
59
|
+
if (
|
|
60
|
+
not build_id
|
|
61
|
+
or len(build_id) % 2
|
|
62
|
+
or _BUILD_ID_RE.fullmatch(build_id) is None
|
|
63
|
+
):
|
|
64
|
+
raise CryptoError("build_id must be a non-empty, even-length hexadecimal string")
|
|
65
|
+
# Keep this conversion explicit: the container format defines the HKDF salt
|
|
66
|
+
# as bytes.fromhex(build_id), not as the UTF-8 spelling of the identifier.
|
|
67
|
+
return bytes.fromhex(build_id)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def derive_module_key(master_key: BytesLike, build_id: str, module: str) -> bytes:
|
|
71
|
+
"""Derive a 32-byte key for one module using HKDF-SHA256.
|
|
72
|
+
|
|
73
|
+
The derivation is exactly::
|
|
74
|
+
|
|
75
|
+
salt = bytes.fromhex(build_id)
|
|
76
|
+
info = b"pyencode-module\\0" + module.encode("utf-8")
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
key_material = _as_bytes(master_key, name="master_key")
|
|
80
|
+
if not key_material:
|
|
81
|
+
raise InvalidKeyError("master_key must not be empty")
|
|
82
|
+
if not isinstance(module, str):
|
|
83
|
+
raise CryptoError("module must be a string")
|
|
84
|
+
if not module:
|
|
85
|
+
raise CryptoError("module must not be empty")
|
|
86
|
+
try:
|
|
87
|
+
module_bytes = module.encode("utf-8")
|
|
88
|
+
except UnicodeEncodeError as exc:
|
|
89
|
+
raise CryptoError("module is not valid UTF-8 text") from exc
|
|
90
|
+
|
|
91
|
+
return HKDF(
|
|
92
|
+
algorithm=hashes.SHA256(),
|
|
93
|
+
length=KEY_SIZE,
|
|
94
|
+
salt=_build_id_bytes(build_id),
|
|
95
|
+
info=b"pyencode-module\0" + module_bytes,
|
|
96
|
+
).derive(key_material)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def derive_bound_master_key(
|
|
100
|
+
master_key: BytesLike,
|
|
101
|
+
build_id: str,
|
|
102
|
+
commitment: BytesLike,
|
|
103
|
+
) -> bytes:
|
|
104
|
+
"""Bind a build's root key to the exact staged runtime/support files.
|
|
105
|
+
|
|
106
|
+
``commitment`` is the 32-byte digest produced by
|
|
107
|
+
:func:`pyencode.integrity.bound_files_commitment`. The returned key, not
|
|
108
|
+
the recoverable root key stored by the generated runtime, is used as the
|
|
109
|
+
master input for per-module key derivation.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
key_material = _as_bytes(master_key, name="master_key")
|
|
113
|
+
binding = _as_bytes(commitment, name="commitment")
|
|
114
|
+
if not key_material:
|
|
115
|
+
raise InvalidKeyError("master_key must not be empty")
|
|
116
|
+
if len(binding) != BOUND_COMMITMENT_SIZE:
|
|
117
|
+
raise CryptoError("bound-files commitment must be exactly 32 bytes")
|
|
118
|
+
return HKDF(
|
|
119
|
+
algorithm=hashes.SHA256(),
|
|
120
|
+
length=KEY_SIZE,
|
|
121
|
+
salt=_build_id_bytes(build_id),
|
|
122
|
+
info=b"pyencode-root-key-v2\0" + binding,
|
|
123
|
+
).derive(key_material)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def encrypt_payload(
|
|
127
|
+
payload: BytesLike,
|
|
128
|
+
key: BytesLike,
|
|
129
|
+
*,
|
|
130
|
+
aad: BytesLike = b"",
|
|
131
|
+
nonce: BytesLike | None = None,
|
|
132
|
+
) -> EncryptedPayload:
|
|
133
|
+
"""Encrypt and authenticate a payload with AES-256-GCM.
|
|
134
|
+
|
|
135
|
+
``ciphertext`` includes the 16-byte GCM authentication tag. Supplying a
|
|
136
|
+
nonce is useful for deterministic test vectors; production callers should
|
|
137
|
+
leave it unset so a fresh random 96-bit nonce is generated.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
plaintext = _as_bytes(payload, name="payload")
|
|
141
|
+
aes_key = _as_bytes(key, name="key")
|
|
142
|
+
associated_data = _as_bytes(aad, name="aad")
|
|
143
|
+
if len(aes_key) != KEY_SIZE:
|
|
144
|
+
raise InvalidKeyError("AES-256-GCM key must be exactly 32 bytes")
|
|
145
|
+
|
|
146
|
+
if nonce is None:
|
|
147
|
+
nonce_bytes = os.urandom(NONCE_SIZE)
|
|
148
|
+
else:
|
|
149
|
+
nonce_bytes = _as_bytes(nonce, name="nonce")
|
|
150
|
+
if len(nonce_bytes) != NONCE_SIZE:
|
|
151
|
+
raise CryptoError("AES-GCM nonce must be exactly 12 bytes")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
ciphertext = AESGCM(aes_key).encrypt(
|
|
155
|
+
nonce_bytes, plaintext, associated_data
|
|
156
|
+
)
|
|
157
|
+
except (TypeError, ValueError) as exc:
|
|
158
|
+
raise CryptoError("could not encrypt payload") from exc
|
|
159
|
+
return EncryptedPayload(nonce_bytes, ciphertext)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def decrypt_payload(
|
|
163
|
+
ciphertext: BytesLike,
|
|
164
|
+
key: BytesLike,
|
|
165
|
+
nonce: BytesLike,
|
|
166
|
+
*,
|
|
167
|
+
aad: BytesLike = b"",
|
|
168
|
+
) -> bytes:
|
|
169
|
+
"""Authenticate and decrypt an AES-256-GCM payload.
|
|
170
|
+
|
|
171
|
+
``IntegrityError`` intentionally does not distinguish a wrong key from
|
|
172
|
+
modified data.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
encrypted = _as_bytes(ciphertext, name="ciphertext")
|
|
176
|
+
aes_key = _as_bytes(key, name="key")
|
|
177
|
+
nonce_bytes = _as_bytes(nonce, name="nonce")
|
|
178
|
+
associated_data = _as_bytes(aad, name="aad")
|
|
179
|
+
if len(aes_key) != KEY_SIZE:
|
|
180
|
+
raise InvalidKeyError("AES-256-GCM key must be exactly 32 bytes")
|
|
181
|
+
if len(nonce_bytes) != NONCE_SIZE:
|
|
182
|
+
raise CryptoError("AES-GCM nonce must be exactly 12 bytes")
|
|
183
|
+
if len(encrypted) < TAG_SIZE:
|
|
184
|
+
raise IntegrityError("encrypted payload is shorter than the GCM tag")
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
return AESGCM(aes_key).decrypt(
|
|
188
|
+
nonce_bytes, encrypted, associated_data
|
|
189
|
+
)
|
|
190
|
+
except InvalidTag as exc:
|
|
191
|
+
raise IntegrityError("payload authentication failed") from exc
|
|
192
|
+
except (TypeError, ValueError) as exc:
|
|
193
|
+
raise CryptoError("could not decrypt payload") from exc
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_private_key(key: PrivateKeyLike) -> Ed25519PrivateKey:
|
|
197
|
+
"""Load an Ed25519 private key object from an object or 32 raw bytes."""
|
|
198
|
+
|
|
199
|
+
if isinstance(key, Ed25519PrivateKey):
|
|
200
|
+
return key
|
|
201
|
+
try:
|
|
202
|
+
raw = _as_bytes(key, name="private_key")
|
|
203
|
+
if len(raw) != KEY_SIZE:
|
|
204
|
+
raise InvalidKeyError("Ed25519 private key must be exactly 32 raw bytes")
|
|
205
|
+
return Ed25519PrivateKey.from_private_bytes(raw)
|
|
206
|
+
except InvalidKeyError:
|
|
207
|
+
raise
|
|
208
|
+
except (TypeError, ValueError) as exc:
|
|
209
|
+
raise InvalidKeyError("invalid Ed25519 private key") from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_public_key(key: PublicKeyLike) -> Ed25519PublicKey:
|
|
213
|
+
"""Load an Ed25519 public key object from an object or 32 raw bytes."""
|
|
214
|
+
|
|
215
|
+
if isinstance(key, Ed25519PublicKey):
|
|
216
|
+
return key
|
|
217
|
+
try:
|
|
218
|
+
raw = _as_bytes(key, name="public_key")
|
|
219
|
+
if len(raw) != KEY_SIZE:
|
|
220
|
+
raise InvalidKeyError("Ed25519 public key must be exactly 32 raw bytes")
|
|
221
|
+
return Ed25519PublicKey.from_public_bytes(raw)
|
|
222
|
+
except InvalidKeyError:
|
|
223
|
+
raise
|
|
224
|
+
except (TypeError, ValueError) as exc:
|
|
225
|
+
raise InvalidKeyError("invalid Ed25519 public key") from exc
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def private_key_bytes(key: PrivateKeyLike) -> bytes:
|
|
229
|
+
"""Return a private key in its portable 32-byte raw encoding."""
|
|
230
|
+
|
|
231
|
+
return load_private_key(key).private_bytes(
|
|
232
|
+
encoding=serialization.Encoding.Raw,
|
|
233
|
+
format=serialization.PrivateFormat.Raw,
|
|
234
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def public_key_bytes(key: PublicKeyLike | PrivateKeyLike) -> bytes:
|
|
239
|
+
"""Return a public key in its portable 32-byte raw encoding."""
|
|
240
|
+
|
|
241
|
+
if isinstance(key, Ed25519PrivateKey):
|
|
242
|
+
public_key = key.public_key()
|
|
243
|
+
else:
|
|
244
|
+
try:
|
|
245
|
+
public_key = load_public_key(key) # type: ignore[arg-type]
|
|
246
|
+
except InvalidKeyError:
|
|
247
|
+
# Raw private and public keys are both 32 bytes, so bytes cannot be
|
|
248
|
+
# disambiguated. Bytes are intentionally interpreted as public.
|
|
249
|
+
raise
|
|
250
|
+
return public_key.public_bytes(
|
|
251
|
+
encoding=serialization.Encoding.Raw,
|
|
252
|
+
format=serialization.PublicFormat.Raw,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def generate_ed25519_keypair() -> Ed25519KeyPair:
|
|
257
|
+
"""Generate a new Ed25519 keypair in raw, cross-platform encodings."""
|
|
258
|
+
|
|
259
|
+
private = Ed25519PrivateKey.generate()
|
|
260
|
+
return Ed25519KeyPair(
|
|
261
|
+
private_key_bytes(private),
|
|
262
|
+
private.public_key().public_bytes(
|
|
263
|
+
encoding=serialization.Encoding.Raw,
|
|
264
|
+
format=serialization.PublicFormat.Raw,
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def sign_bytes(data: BytesLike, private_key: PrivateKeyLike) -> bytes:
|
|
270
|
+
"""Return an Ed25519 signature for arbitrary bytes."""
|
|
271
|
+
|
|
272
|
+
message = _as_bytes(data, name="data")
|
|
273
|
+
try:
|
|
274
|
+
return load_private_key(private_key).sign(message)
|
|
275
|
+
except InvalidKeyError:
|
|
276
|
+
raise
|
|
277
|
+
except (TypeError, ValueError) as exc:
|
|
278
|
+
raise CryptoError("could not sign data") from exc
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def verify_bytes(
|
|
282
|
+
data: BytesLike,
|
|
283
|
+
signature: BytesLike,
|
|
284
|
+
public_key: PublicKeyLike,
|
|
285
|
+
) -> bool:
|
|
286
|
+
"""Verify an Ed25519 signature, returning ``True`` or raising on failure."""
|
|
287
|
+
|
|
288
|
+
message = _as_bytes(data, name="data")
|
|
289
|
+
signature_bytes = _as_bytes(signature, name="signature")
|
|
290
|
+
try:
|
|
291
|
+
load_public_key(public_key).verify(signature_bytes, message)
|
|
292
|
+
except InvalidSignature as exc:
|
|
293
|
+
raise SignatureError("signature verification failed") from exc
|
|
294
|
+
except InvalidKeyError:
|
|
295
|
+
raise
|
|
296
|
+
except (TypeError, ValueError) as exc:
|
|
297
|
+
raise SignatureError("signature is malformed") from exc
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
__all__ = [
|
|
302
|
+
"BOUND_COMMITMENT_SIZE",
|
|
303
|
+
"Ed25519KeyPair",
|
|
304
|
+
"EncryptedPayload",
|
|
305
|
+
"KEY_SIZE",
|
|
306
|
+
"NONCE_SIZE",
|
|
307
|
+
"TAG_SIZE",
|
|
308
|
+
"decrypt_payload",
|
|
309
|
+
"derive_bound_master_key",
|
|
310
|
+
"derive_module_key",
|
|
311
|
+
"encrypt_payload",
|
|
312
|
+
"generate_ed25519_keypair",
|
|
313
|
+
"load_private_key",
|
|
314
|
+
"load_public_key",
|
|
315
|
+
"private_key_bytes",
|
|
316
|
+
"public_key_bytes",
|
|
317
|
+
"sign_bytes",
|
|
318
|
+
"verify_bytes",
|
|
319
|
+
]
|
pyencode/discovery.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Discover Python modules and resource files in an input tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from fnmatch import fnmatch
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, Sequence
|
|
9
|
+
|
|
10
|
+
from .errors import PyEncodeError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEFAULT_EXCLUDED_DIRECTORIES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
".git",
|
|
16
|
+
".hg",
|
|
17
|
+
".svn",
|
|
18
|
+
".venv",
|
|
19
|
+
"venv",
|
|
20
|
+
"env",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
".mypy_cache",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".ruff_cache",
|
|
25
|
+
"build",
|
|
26
|
+
"dist",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class SourceModule:
|
|
33
|
+
name: str
|
|
34
|
+
source_path: Path
|
|
35
|
+
output_path: Path
|
|
36
|
+
is_package: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class ResourceFile:
|
|
41
|
+
source_path: Path
|
|
42
|
+
output_path: Path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class ProjectLayout:
|
|
47
|
+
source: Path
|
|
48
|
+
base_directory: Path
|
|
49
|
+
modules: tuple[SourceModule, ...]
|
|
50
|
+
resources: tuple[ResourceFile, ...]
|
|
51
|
+
inferred_entry: str | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _relative_module_name(relative_path: Path) -> tuple[str, bool]:
|
|
55
|
+
is_package = relative_path.name == "__init__.py"
|
|
56
|
+
module_path = relative_path.parent if is_package else relative_path.with_suffix("")
|
|
57
|
+
parts = module_path.parts
|
|
58
|
+
if not parts:
|
|
59
|
+
raise PyEncodeError("A package __init__.py must have a package directory name")
|
|
60
|
+
invalid = [part for part in parts if not part.isidentifier()]
|
|
61
|
+
if invalid:
|
|
62
|
+
joined = ", ".join(repr(part) for part in invalid)
|
|
63
|
+
raise PyEncodeError(f"Invalid Python module path component(s): {joined}")
|
|
64
|
+
return ".".join(parts), is_package
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _protected_output_path(relative_path: Path) -> Path:
|
|
68
|
+
if relative_path.name == "__init__.py":
|
|
69
|
+
return relative_path.with_name("__init__.pye")
|
|
70
|
+
return relative_path.with_suffix(".pye")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _matches_user_exclude(relative_path: Path, patterns: Sequence[str]) -> bool:
|
|
74
|
+
value = relative_path.as_posix()
|
|
75
|
+
return any(fnmatch(value, pattern) or fnmatch(relative_path.name, pattern) for pattern in patterns)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_default_excluded(relative_path: Path) -> bool:
|
|
79
|
+
if any(part in DEFAULT_EXCLUDED_DIRECTORIES for part in relative_path.parts):
|
|
80
|
+
return True
|
|
81
|
+
return relative_path.suffix.lower() in {".pyc", ".pyo", ".pye"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _iter_files(scope: Path) -> Iterable[Path]:
|
|
85
|
+
if scope.is_file():
|
|
86
|
+
yield scope
|
|
87
|
+
return
|
|
88
|
+
for path in sorted(scope.rglob("*")):
|
|
89
|
+
if path.is_file():
|
|
90
|
+
yield path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def discover_project(
|
|
94
|
+
source: Path,
|
|
95
|
+
*,
|
|
96
|
+
excludes: Sequence[str] = (),
|
|
97
|
+
include_resources: bool = True,
|
|
98
|
+
) -> ProjectLayout:
|
|
99
|
+
"""Return protected modules and copied resources for *source*.
|
|
100
|
+
|
|
101
|
+
A directory containing ``__init__.py`` is treated as one package and keeps
|
|
102
|
+
its top-level package name. Other directories are treated as project roots.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
source = source.expanduser().resolve()
|
|
106
|
+
if not source.exists():
|
|
107
|
+
raise PyEncodeError(f"Source does not exist: {source}")
|
|
108
|
+
if source.is_file() and source.suffix.lower() != ".py":
|
|
109
|
+
raise PyEncodeError("A source file must have the .py extension")
|
|
110
|
+
|
|
111
|
+
if source.is_file():
|
|
112
|
+
base_directory = source.parent
|
|
113
|
+
scope = source
|
|
114
|
+
package_input = False
|
|
115
|
+
else:
|
|
116
|
+
package_input = (source / "__init__.py").is_file()
|
|
117
|
+
base_directory = source.parent if package_input else source
|
|
118
|
+
scope = source
|
|
119
|
+
|
|
120
|
+
modules: list[SourceModule] = []
|
|
121
|
+
resources: list[ResourceFile] = []
|
|
122
|
+
seen_modules: set[str] = set()
|
|
123
|
+
|
|
124
|
+
for path in _iter_files(scope):
|
|
125
|
+
relative = path.relative_to(base_directory)
|
|
126
|
+
if _is_default_excluded(relative) or _matches_user_exclude(relative, excludes):
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if path.suffix.lower() == ".py":
|
|
130
|
+
module_name, is_package = _relative_module_name(relative)
|
|
131
|
+
if module_name in seen_modules:
|
|
132
|
+
raise PyEncodeError(f"Duplicate module discovered: {module_name}")
|
|
133
|
+
seen_modules.add(module_name)
|
|
134
|
+
modules.append(
|
|
135
|
+
SourceModule(
|
|
136
|
+
name=module_name,
|
|
137
|
+
source_path=path,
|
|
138
|
+
output_path=_protected_output_path(relative),
|
|
139
|
+
is_package=is_package,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
elif include_resources:
|
|
143
|
+
resources.append(ResourceFile(source_path=path, output_path=relative))
|
|
144
|
+
|
|
145
|
+
if not modules:
|
|
146
|
+
raise PyEncodeError(f"No Python modules found in {source}")
|
|
147
|
+
|
|
148
|
+
inferred_entry: str | None = None
|
|
149
|
+
names = {module.name for module in modules}
|
|
150
|
+
if source.is_file():
|
|
151
|
+
inferred_entry = modules[0].name
|
|
152
|
+
elif package_input:
|
|
153
|
+
candidate = f"{source.name}.__main__"
|
|
154
|
+
if candidate in names:
|
|
155
|
+
inferred_entry = candidate
|
|
156
|
+
elif "main" in names:
|
|
157
|
+
inferred_entry = "main"
|
|
158
|
+
elif len(modules) == 1 and not modules[0].is_package:
|
|
159
|
+
inferred_entry = modules[0].name
|
|
160
|
+
|
|
161
|
+
return ProjectLayout(
|
|
162
|
+
source=source,
|
|
163
|
+
base_directory=base_directory,
|
|
164
|
+
modules=tuple(modules),
|
|
165
|
+
resources=tuple(resources),
|
|
166
|
+
inferred_entry=inferred_entry,
|
|
167
|
+
)
|
|
168
|
+
|
pyencode/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Project-specific exceptions raised by :mod:`pyencode`.
|
|
2
|
+
|
|
3
|
+
The public exception hierarchy deliberately separates malformed input from
|
|
4
|
+
cryptographic integrity failures. Applications can catch ``PyEncodeError``
|
|
5
|
+
for a user-facing error while still treating ``IntegrityError`` as a likely
|
|
6
|
+
tamper/wrong-key condition.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PyEncodeError(Exception):
|
|
13
|
+
"""Base class for all expected pyencode failures."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ContainerError(PyEncodeError):
|
|
17
|
+
"""A protected module container is malformed or unsupported."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedFormatError(ContainerError):
|
|
21
|
+
"""The container uses a format version this runtime cannot read."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CryptoError(PyEncodeError):
|
|
25
|
+
"""A cryptographic operation could not be completed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidKeyError(CryptoError):
|
|
29
|
+
"""A key has an invalid type, encoding, or size."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IntegrityError(CryptoError):
|
|
33
|
+
"""Authenticated data is corrupt, was modified, or used the wrong key."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ManifestError(PyEncodeError):
|
|
37
|
+
"""A manifest is malformed or cannot be serialized canonically."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SignatureError(ManifestError, IntegrityError):
|
|
41
|
+
"""A manifest signature is missing, malformed, or invalid."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# A compatibility spelling for callers that do not capitalize the product
|
|
45
|
+
# name as two words. New code should use ``PyEncodeError``.
|
|
46
|
+
PyencodeError = PyEncodeError
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"ContainerError",
|
|
51
|
+
"CryptoError",
|
|
52
|
+
"IntegrityError",
|
|
53
|
+
"InvalidKeyError",
|
|
54
|
+
"ManifestError",
|
|
55
|
+
"PyEncodeError",
|
|
56
|
+
"PyencodeError",
|
|
57
|
+
"SignatureError",
|
|
58
|
+
"UnsupportedFormatError",
|
|
59
|
+
]
|
pyencode/integrity.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Deterministic commitments for plaintext runtime and launcher files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Mapping, Sequence
|
|
8
|
+
|
|
9
|
+
from .container import canonical_json_bytes
|
|
10
|
+
from .errors import PyEncodeError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BOUND_FILES_FORMAT = 1
|
|
14
|
+
BOUND_FILES_ALGORITHM = "sha256"
|
|
15
|
+
BOUND_FILES_DOMAIN = b"pyencode-bound-files-v1\0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate_bound_path(value: str) -> str:
|
|
19
|
+
"""Validate a platform-neutral relative path and return its canonical form."""
|
|
20
|
+
|
|
21
|
+
if not isinstance(value, str) or not value or "\\" in value:
|
|
22
|
+
raise PyEncodeError("bound file paths must be non-empty POSIX relative paths")
|
|
23
|
+
path = PurePosixPath(value)
|
|
24
|
+
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
|
|
25
|
+
raise PyEncodeError(f"unsafe bound file path: {value!r}")
|
|
26
|
+
canonical = path.as_posix()
|
|
27
|
+
if canonical != value:
|
|
28
|
+
raise PyEncodeError(f"bound file path is not canonical: {value!r}")
|
|
29
|
+
return canonical
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_file_digests(file_digests: Mapping[str, str]) -> dict[str, str]:
|
|
33
|
+
"""Validate a path-to-SHA256 mapping and return it sorted by path."""
|
|
34
|
+
|
|
35
|
+
if not isinstance(file_digests, Mapping) or not file_digests:
|
|
36
|
+
raise PyEncodeError("bound file digest mapping must not be empty")
|
|
37
|
+
normalized: dict[str, str] = {}
|
|
38
|
+
for raw_path, raw_digest in file_digests.items():
|
|
39
|
+
path = validate_bound_path(raw_path)
|
|
40
|
+
if not isinstance(raw_digest, str) or len(raw_digest) != 64:
|
|
41
|
+
raise PyEncodeError(f"invalid SHA-256 digest for bound file {path!r}")
|
|
42
|
+
try:
|
|
43
|
+
decoded = bytes.fromhex(raw_digest)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise PyEncodeError(f"invalid SHA-256 digest for bound file {path!r}") from exc
|
|
46
|
+
if len(decoded) != 32 or raw_digest != raw_digest.lower():
|
|
47
|
+
raise PyEncodeError(f"invalid SHA-256 digest for bound file {path!r}")
|
|
48
|
+
normalized[path] = raw_digest
|
|
49
|
+
return dict(sorted(normalized.items()))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def bound_files_commitment(file_digests: Mapping[str, str]) -> bytes:
|
|
53
|
+
"""Commit to exact file paths and raw-byte SHA-256 digests."""
|
|
54
|
+
|
|
55
|
+
descriptor = {
|
|
56
|
+
"format": BOUND_FILES_FORMAT,
|
|
57
|
+
"files": normalize_file_digests(file_digests),
|
|
58
|
+
}
|
|
59
|
+
return hashlib.sha256(BOUND_FILES_DOMAIN + canonical_json_bytes(descriptor)).digest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def hash_bound_files(root: Path, relative_paths: Sequence[str]) -> dict[str, str]:
|
|
63
|
+
"""Hash exact bytes below *root*, rejecting missing or escaping paths."""
|
|
64
|
+
|
|
65
|
+
root = root.resolve()
|
|
66
|
+
result: dict[str, str] = {}
|
|
67
|
+
for raw_path in relative_paths:
|
|
68
|
+
relative = validate_bound_path(raw_path)
|
|
69
|
+
candidate = (root / Path(*PurePosixPath(relative).parts)).resolve()
|
|
70
|
+
try:
|
|
71
|
+
candidate.relative_to(root)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise PyEncodeError(f"bound file escapes output root: {relative!r}") from exc
|
|
74
|
+
if not candidate.is_file():
|
|
75
|
+
raise PyEncodeError(f"bound file is missing: {relative}")
|
|
76
|
+
result[relative] = hashlib.sha256(candidate.read_bytes()).hexdigest()
|
|
77
|
+
return normalize_file_digests(result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"BOUND_FILES_ALGORITHM",
|
|
82
|
+
"BOUND_FILES_DOMAIN",
|
|
83
|
+
"BOUND_FILES_FORMAT",
|
|
84
|
+
"bound_files_commitment",
|
|
85
|
+
"hash_bound_files",
|
|
86
|
+
"normalize_file_digests",
|
|
87
|
+
"validate_bound_path",
|
|
88
|
+
]
|