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/opaque.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""Opaque PYE2 artifacts and encrypted protected-module indexes.
|
|
2
|
+
|
|
3
|
+
This module contains the format-independent core used by a future builder and
|
|
4
|
+
runtime. PYE2 deliberately stores no semantic header on disk: module names,
|
|
5
|
+
package flags, Python compatibility metadata, and artifact tokens are supplied
|
|
6
|
+
as authenticated context after the encrypted module index has been opened.
|
|
7
|
+
|
|
8
|
+
The wire format for one artifact is exactly::
|
|
9
|
+
|
|
10
|
+
b"PYE2" || nonce[12] || aes_gcm_ciphertext_and_tag
|
|
11
|
+
|
|
12
|
+
All key derivations are domain-separated HKDF-SHA256 operations. Callers are
|
|
13
|
+
expected to pass the support-bound root key produced by
|
|
14
|
+
``derive_bound_master_key`` when encrypting an index, then derive a module root
|
|
15
|
+
from that encrypted index before packing artifacts.
|
|
16
|
+
|
|
17
|
+
The protocol derivations are::
|
|
18
|
+
|
|
19
|
+
K_index = HKDF(K_support, salt=build_id,
|
|
20
|
+
info=b"pyencode-module-index-v1\\0")
|
|
21
|
+
C_index = SHA256(b"pyencode-module-index-commitment-v1\\0"
|
|
22
|
+
+ index_aad + nonce + ciphertext)
|
|
23
|
+
K_modules = HKDF(K_support, salt=build_id,
|
|
24
|
+
info=b"pyencode-module-root-v2\\0" + C_index)
|
|
25
|
+
K_artifact = HKDF(K_modules, salt=build_id,
|
|
26
|
+
info=b"pyencode-artifact-v2\\0" + token_bytes
|
|
27
|
+
+ b"\\0" + module_utf8)
|
|
28
|
+
|
|
29
|
+
Here ``HKDF`` means HKDF-SHA256 with a 32-byte output. Index plaintext and all
|
|
30
|
+
associated data use the canonical JSON helpers in this module.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
import zlib
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from typing import Any, Mapping, TypedDict, cast
|
|
41
|
+
|
|
42
|
+
from cryptography.hazmat.primitives import hashes
|
|
43
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
44
|
+
|
|
45
|
+
from .crypto import (
|
|
46
|
+
KEY_SIZE,
|
|
47
|
+
NONCE_SIZE,
|
|
48
|
+
TAG_SIZE,
|
|
49
|
+
BytesLike,
|
|
50
|
+
decrypt_payload,
|
|
51
|
+
encrypt_payload,
|
|
52
|
+
)
|
|
53
|
+
from .errors import (
|
|
54
|
+
ContainerError,
|
|
55
|
+
CryptoError,
|
|
56
|
+
InvalidKeyError,
|
|
57
|
+
ManifestError,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
MAGIC_PYE2 = b"PYE2"
|
|
62
|
+
OPAQUE_ARTIFACT_FORMAT = 2
|
|
63
|
+
MODULE_INDEX_FORMAT = 1
|
|
64
|
+
TOKEN_SIZE = 16
|
|
65
|
+
INDEX_COMMITMENT_SIZE = 32
|
|
66
|
+
|
|
67
|
+
INDEX_KEY_INFO = b"pyencode-module-index-v1\0"
|
|
68
|
+
INDEX_COMMITMENT_DOMAIN = b"pyencode-module-index-commitment-v1\0"
|
|
69
|
+
MODULE_ROOT_KEY_INFO = b"pyencode-module-root-v2\0"
|
|
70
|
+
ARTIFACT_KEY_INFO = b"pyencode-artifact-v2\0"
|
|
71
|
+
|
|
72
|
+
_BUILD_ID_RE = re.compile(r"[0-9a-f]{32}\Z")
|
|
73
|
+
_TOKEN_RE = re.compile(r"[0-9a-f]{32}\Z")
|
|
74
|
+
_PYTHON_TAG_RE = re.compile(r"cp[0-9]{2,}\Z")
|
|
75
|
+
_MAX_MODULE_INDEX_SIZE = 16 * 1024 * 1024
|
|
76
|
+
_MAX_ARTIFACT_PLAINTEXT_SIZE = 256 * 1024 * 1024
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ModuleIndexEntry(TypedDict):
|
|
80
|
+
"""One normalized entry in a decrypted module index."""
|
|
81
|
+
|
|
82
|
+
artifact: str
|
|
83
|
+
package: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModuleIndex(TypedDict):
|
|
87
|
+
"""Canonical plaintext schema encrypted into a module index."""
|
|
88
|
+
|
|
89
|
+
format: int
|
|
90
|
+
entry_module: str
|
|
91
|
+
modules: dict[str, ModuleIndexEntry]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class OpaqueModuleContext:
|
|
96
|
+
"""Authenticated context required to encrypt or open one PYE2 artifact."""
|
|
97
|
+
|
|
98
|
+
build_id: str
|
|
99
|
+
python_tag: str
|
|
100
|
+
module: str
|
|
101
|
+
package: bool
|
|
102
|
+
token: str
|
|
103
|
+
marshal_version: int
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
_validate_build_id(self.build_id, ContainerError)
|
|
107
|
+
_validate_python_tag(self.python_tag, ContainerError)
|
|
108
|
+
_validate_module_name(self.module, ContainerError)
|
|
109
|
+
_validate_token(self.token, ContainerError)
|
|
110
|
+
if type(self.package) is not bool:
|
|
111
|
+
raise ContainerError("opaque module package flag must be a boolean")
|
|
112
|
+
if type(self.marshal_version) is not int or self.marshal_version < 0:
|
|
113
|
+
raise ContainerError(
|
|
114
|
+
"opaque module marshal_version must be a non-negative integer"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class ParsedOpaqueArtifact:
|
|
120
|
+
"""The structural fields of a still-encrypted PYE2 artifact."""
|
|
121
|
+
|
|
122
|
+
nonce: bytes
|
|
123
|
+
ciphertext: bytes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class EncryptedModuleIndex:
|
|
128
|
+
"""AES-GCM fields stored by a signed outer manifest."""
|
|
129
|
+
|
|
130
|
+
nonce: bytes
|
|
131
|
+
ciphertext: bytes
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _as_bytes(value: BytesLike, *, name: str, error: type[Exception]) -> bytes:
|
|
135
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
136
|
+
raise error(f"{name} must be bytes-like")
|
|
137
|
+
return bytes(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _validate_key(value: BytesLike, name: str = "root_key") -> bytes:
|
|
141
|
+
key = _as_bytes(value, name=name, error=InvalidKeyError)
|
|
142
|
+
if len(key) != KEY_SIZE:
|
|
143
|
+
raise InvalidKeyError(f"{name} must be exactly {KEY_SIZE} bytes")
|
|
144
|
+
return key
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _validate_build_id(value: object, error: type[Exception]) -> str:
|
|
148
|
+
if not isinstance(value, str) or _BUILD_ID_RE.fullmatch(value) is None:
|
|
149
|
+
raise error("build_id must be exactly 32 lowercase hexadecimal characters")
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_id_bytes(value: object, error: type[Exception]) -> bytes:
|
|
154
|
+
return bytes.fromhex(_validate_build_id(value, error))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_python_tag(value: object, error: type[Exception]) -> str:
|
|
158
|
+
if not isinstance(value, str) or _PYTHON_TAG_RE.fullmatch(value) is None:
|
|
159
|
+
raise error("python_tag must be a canonical CPython tag such as 'cp314'")
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_module_name(value: object, error: type[Exception]) -> str:
|
|
164
|
+
if not isinstance(value, str) or not value:
|
|
165
|
+
raise error("module name must be a non-empty string")
|
|
166
|
+
if any(not part or not part.isidentifier() for part in value.split(".")):
|
|
167
|
+
raise error(f"invalid protected module name {value!r}")
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _validate_token(value: object, error: type[Exception]) -> str:
|
|
172
|
+
if not isinstance(value, str) or _TOKEN_RE.fullmatch(value) is None:
|
|
173
|
+
raise error("artifact token must be exactly 32 lowercase hexadecimal characters")
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _canonical_json(value: Any, error: type[Exception]) -> bytes:
|
|
178
|
+
try:
|
|
179
|
+
return json.dumps(
|
|
180
|
+
value,
|
|
181
|
+
sort_keys=True,
|
|
182
|
+
separators=(",", ":"),
|
|
183
|
+
ensure_ascii=False,
|
|
184
|
+
allow_nan=False,
|
|
185
|
+
).encode("utf-8")
|
|
186
|
+
except (TypeError, ValueError, UnicodeEncodeError) as exc:
|
|
187
|
+
raise error("metadata cannot be encoded as canonical JSON") from exc
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _hkdf(root_key: BytesLike, build_id: str, info: bytes, *, name: str) -> bytes:
|
|
191
|
+
key = _validate_key(root_key, name)
|
|
192
|
+
salt = _build_id_bytes(build_id, CryptoError)
|
|
193
|
+
return HKDF(
|
|
194
|
+
algorithm=hashes.SHA256(),
|
|
195
|
+
length=KEY_SIZE,
|
|
196
|
+
salt=salt,
|
|
197
|
+
info=info,
|
|
198
|
+
).derive(key)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def module_index_aad(build_id: str, python_tag: str) -> bytes:
|
|
202
|
+
"""Return the canonical public context authenticated with an index."""
|
|
203
|
+
|
|
204
|
+
validated_build_id = _validate_build_id(build_id, ManifestError)
|
|
205
|
+
validated_python_tag = _validate_python_tag(python_tag, ManifestError)
|
|
206
|
+
return _canonical_json(
|
|
207
|
+
{
|
|
208
|
+
"build_id": validated_build_id,
|
|
209
|
+
"cipher": "aes-256-gcm",
|
|
210
|
+
"compression": "zlib",
|
|
211
|
+
"format": MODULE_INDEX_FORMAT,
|
|
212
|
+
"python_tag": validated_python_tag,
|
|
213
|
+
},
|
|
214
|
+
ManifestError,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def derive_module_index_key(support_root_key: BytesLike, build_id: str) -> bytes:
|
|
219
|
+
"""Derive the AES-256 key used only for the encrypted module index."""
|
|
220
|
+
|
|
221
|
+
return _hkdf(
|
|
222
|
+
support_root_key,
|
|
223
|
+
build_id,
|
|
224
|
+
INDEX_KEY_INFO,
|
|
225
|
+
name="support_root_key",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def module_index_commitment(
|
|
230
|
+
encrypted_index: EncryptedModuleIndex,
|
|
231
|
+
*,
|
|
232
|
+
build_id: str,
|
|
233
|
+
python_tag: str,
|
|
234
|
+
) -> bytes:
|
|
235
|
+
"""Commit to the exact encrypted index and its authenticated context."""
|
|
236
|
+
|
|
237
|
+
nonce, ciphertext = _encrypted_index_parts(encrypted_index)
|
|
238
|
+
aad = module_index_aad(build_id, python_tag)
|
|
239
|
+
return hashlib.sha256(
|
|
240
|
+
INDEX_COMMITMENT_DOMAIN + aad + nonce + ciphertext
|
|
241
|
+
).digest()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def derive_module_root_key(
|
|
245
|
+
support_root_key: BytesLike,
|
|
246
|
+
build_id: str,
|
|
247
|
+
index_commitment: BytesLike,
|
|
248
|
+
) -> bytes:
|
|
249
|
+
"""Derive the root used by all PYE2 artifacts in one encrypted index."""
|
|
250
|
+
|
|
251
|
+
commitment = _as_bytes(
|
|
252
|
+
index_commitment,
|
|
253
|
+
name="index_commitment",
|
|
254
|
+
error=CryptoError,
|
|
255
|
+
)
|
|
256
|
+
if len(commitment) != INDEX_COMMITMENT_SIZE:
|
|
257
|
+
raise CryptoError(
|
|
258
|
+
f"index_commitment must be exactly {INDEX_COMMITMENT_SIZE} bytes"
|
|
259
|
+
)
|
|
260
|
+
return _hkdf(
|
|
261
|
+
support_root_key,
|
|
262
|
+
build_id,
|
|
263
|
+
MODULE_ROOT_KEY_INFO + commitment,
|
|
264
|
+
name="support_root_key",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def opaque_artifact_aad(context: OpaqueModuleContext) -> bytes:
|
|
269
|
+
"""Return semantic artifact metadata authenticated but never stored."""
|
|
270
|
+
|
|
271
|
+
if not isinstance(context, OpaqueModuleContext):
|
|
272
|
+
raise ContainerError("context must be an OpaqueModuleContext")
|
|
273
|
+
return _canonical_json(
|
|
274
|
+
{
|
|
275
|
+
"artifact": context.token,
|
|
276
|
+
"build_id": context.build_id,
|
|
277
|
+
"compression": "zlib",
|
|
278
|
+
"format": OPAQUE_ARTIFACT_FORMAT,
|
|
279
|
+
"marshal_version": context.marshal_version,
|
|
280
|
+
"module": context.module,
|
|
281
|
+
"package": context.package,
|
|
282
|
+
"python_tag": context.python_tag,
|
|
283
|
+
},
|
|
284
|
+
ContainerError,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def derive_opaque_artifact_key(
|
|
289
|
+
module_root_key: BytesLike,
|
|
290
|
+
context: OpaqueModuleContext,
|
|
291
|
+
) -> bytes:
|
|
292
|
+
"""Derive one artifact key from its opaque token and protected fullname."""
|
|
293
|
+
|
|
294
|
+
if not isinstance(context, OpaqueModuleContext):
|
|
295
|
+
raise ContainerError("context must be an OpaqueModuleContext")
|
|
296
|
+
info = (
|
|
297
|
+
ARTIFACT_KEY_INFO
|
|
298
|
+
+ bytes.fromhex(context.token)
|
|
299
|
+
+ b"\0"
|
|
300
|
+
+ context.module.encode("utf-8")
|
|
301
|
+
)
|
|
302
|
+
return _hkdf(
|
|
303
|
+
module_root_key,
|
|
304
|
+
context.build_id,
|
|
305
|
+
info,
|
|
306
|
+
name="module_root_key",
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def pack_opaque_artifact(
|
|
311
|
+
payload: BytesLike,
|
|
312
|
+
module_root_key: BytesLike,
|
|
313
|
+
context: OpaqueModuleContext,
|
|
314
|
+
*,
|
|
315
|
+
nonce: BytesLike | None = None,
|
|
316
|
+
compression_level: int = 9,
|
|
317
|
+
) -> bytes:
|
|
318
|
+
"""Compress and encrypt bytes into the headerless PYE2 wire format."""
|
|
319
|
+
|
|
320
|
+
plaintext = _as_bytes(payload, name="payload", error=ContainerError)
|
|
321
|
+
if len(plaintext) > _MAX_ARTIFACT_PLAINTEXT_SIZE:
|
|
322
|
+
raise ContainerError("PYE2 artifact payload exceeds the safety limit")
|
|
323
|
+
if type(compression_level) is not int or not -1 <= compression_level <= 9:
|
|
324
|
+
raise ContainerError("zlib compression_level must be between -1 and 9")
|
|
325
|
+
aad = opaque_artifact_aad(context)
|
|
326
|
+
key = derive_opaque_artifact_key(module_root_key, context)
|
|
327
|
+
encrypted = encrypt_payload(
|
|
328
|
+
zlib.compress(plaintext, level=compression_level),
|
|
329
|
+
key,
|
|
330
|
+
aad=aad,
|
|
331
|
+
nonce=nonce,
|
|
332
|
+
)
|
|
333
|
+
return MAGIC_PYE2 + encrypted.nonce + encrypted.ciphertext
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def parse_opaque_artifact(data: BytesLike) -> ParsedOpaqueArtifact:
|
|
337
|
+
"""Validate PYE2 framing without decrypting its ciphertext."""
|
|
338
|
+
|
|
339
|
+
artifact = _as_bytes(data, name="data", error=ContainerError)
|
|
340
|
+
minimum = len(MAGIC_PYE2) + NONCE_SIZE + TAG_SIZE
|
|
341
|
+
if len(artifact) < minimum:
|
|
342
|
+
raise ContainerError("PYE2 artifact is truncated")
|
|
343
|
+
if artifact[: len(MAGIC_PYE2)] != MAGIC_PYE2:
|
|
344
|
+
raise ContainerError("invalid PYE2 artifact magic")
|
|
345
|
+
nonce_start = len(MAGIC_PYE2)
|
|
346
|
+
ciphertext_start = nonce_start + NONCE_SIZE
|
|
347
|
+
return ParsedOpaqueArtifact(
|
|
348
|
+
nonce=artifact[nonce_start:ciphertext_start],
|
|
349
|
+
ciphertext=artifact[ciphertext_start:],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def unpack_opaque_artifact(
|
|
354
|
+
data: BytesLike,
|
|
355
|
+
module_root_key: BytesLike,
|
|
356
|
+
context: OpaqueModuleContext,
|
|
357
|
+
) -> bytes:
|
|
358
|
+
"""Authenticate, decrypt, and decompress one PYE2 artifact."""
|
|
359
|
+
|
|
360
|
+
parsed = parse_opaque_artifact(data)
|
|
361
|
+
aad = opaque_artifact_aad(context)
|
|
362
|
+
key = derive_opaque_artifact_key(module_root_key, context)
|
|
363
|
+
compressed = decrypt_payload(
|
|
364
|
+
parsed.ciphertext,
|
|
365
|
+
key,
|
|
366
|
+
parsed.nonce,
|
|
367
|
+
aad=aad,
|
|
368
|
+
)
|
|
369
|
+
return _decompress_strict(
|
|
370
|
+
compressed,
|
|
371
|
+
maximum=_MAX_ARTIFACT_PLAINTEXT_SIZE,
|
|
372
|
+
description="PYE2 artifact payload",
|
|
373
|
+
error=ContainerError,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def normalize_module_index(value: Mapping[str, Any]) -> ModuleIndex:
|
|
378
|
+
"""Validate and return the exact canonical plaintext index schema."""
|
|
379
|
+
|
|
380
|
+
if not isinstance(value, Mapping):
|
|
381
|
+
raise ManifestError("module index must be a JSON object")
|
|
382
|
+
required = {"format", "entry_module", "modules"}
|
|
383
|
+
raw_fields = set(value)
|
|
384
|
+
if raw_fields != required:
|
|
385
|
+
missing = sorted(required.difference(raw_fields))
|
|
386
|
+
extra = sorted(raw_fields.difference(required), key=repr)
|
|
387
|
+
details: list[str] = []
|
|
388
|
+
if missing:
|
|
389
|
+
details.append("missing " + ", ".join(missing))
|
|
390
|
+
if extra:
|
|
391
|
+
details.append("unknown " + ", ".join(repr(item) for item in extra))
|
|
392
|
+
raise ManifestError(
|
|
393
|
+
"module index has invalid fields (" + "; ".join(details) + ")"
|
|
394
|
+
)
|
|
395
|
+
if type(value["format"]) is not int or value["format"] != MODULE_INDEX_FORMAT:
|
|
396
|
+
raise ManifestError("unsupported module index format")
|
|
397
|
+
entry_module = _validate_module_name(value["entry_module"], ManifestError)
|
|
398
|
+
raw_modules = value["modules"]
|
|
399
|
+
if not isinstance(raw_modules, Mapping) or not raw_modules:
|
|
400
|
+
raise ManifestError("module index modules must be a non-empty object")
|
|
401
|
+
|
|
402
|
+
modules: dict[str, ModuleIndexEntry] = {}
|
|
403
|
+
tokens: set[str] = set()
|
|
404
|
+
for raw_name, raw_metadata in raw_modules.items():
|
|
405
|
+
name = _validate_module_name(raw_name, ManifestError)
|
|
406
|
+
if name in modules:
|
|
407
|
+
raise ManifestError(f"duplicate protected module {name!r}")
|
|
408
|
+
if not isinstance(raw_metadata, Mapping):
|
|
409
|
+
raise ManifestError(f"metadata for module {name!r} must be an object")
|
|
410
|
+
if set(raw_metadata) != {"artifact", "package"}:
|
|
411
|
+
raise ManifestError(
|
|
412
|
+
f"metadata for module {name!r} must contain only "
|
|
413
|
+
"'artifact' and 'package'"
|
|
414
|
+
)
|
|
415
|
+
token = _validate_token(raw_metadata["artifact"], ManifestError)
|
|
416
|
+
if token in tokens:
|
|
417
|
+
raise ManifestError(f"duplicate artifact token {token!r}")
|
|
418
|
+
package = raw_metadata["package"]
|
|
419
|
+
if type(package) is not bool:
|
|
420
|
+
raise ManifestError(f"package flag for module {name!r} must be boolean")
|
|
421
|
+
tokens.add(token)
|
|
422
|
+
modules[name] = {"artifact": token, "package": package}
|
|
423
|
+
|
|
424
|
+
if entry_module not in modules:
|
|
425
|
+
raise ManifestError("module index does not contain its configured entry module")
|
|
426
|
+
return cast(
|
|
427
|
+
ModuleIndex,
|
|
428
|
+
{
|
|
429
|
+
"format": MODULE_INDEX_FORMAT,
|
|
430
|
+
"entry_module": entry_module,
|
|
431
|
+
"modules": dict(sorted(modules.items())),
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def canonical_module_index_bytes(value: Mapping[str, Any]) -> bytes:
|
|
437
|
+
"""Return canonical UTF-8 JSON for a strictly normalized module index."""
|
|
438
|
+
|
|
439
|
+
return _canonical_json(normalize_module_index(value), ManifestError)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def decode_module_index(data: BytesLike) -> ModuleIndex:
|
|
443
|
+
"""Decode only canonical JSON matching the strict module-index schema."""
|
|
444
|
+
|
|
445
|
+
raw = _as_bytes(data, name="module index", error=ManifestError)
|
|
446
|
+
try:
|
|
447
|
+
decoded = json.loads(raw.decode("utf-8"))
|
|
448
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
449
|
+
raise ManifestError("module index is not valid UTF-8 JSON") from exc
|
|
450
|
+
normalized = normalize_module_index(decoded)
|
|
451
|
+
if _canonical_json(normalized, ManifestError) != raw:
|
|
452
|
+
raise ManifestError("module index JSON is not canonical")
|
|
453
|
+
return normalized
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def encrypt_module_index(
|
|
457
|
+
value: Mapping[str, Any],
|
|
458
|
+
support_root_key: BytesLike,
|
|
459
|
+
*,
|
|
460
|
+
build_id: str,
|
|
461
|
+
python_tag: str,
|
|
462
|
+
nonce: BytesLike | None = None,
|
|
463
|
+
compression_level: int = 9,
|
|
464
|
+
) -> EncryptedModuleIndex:
|
|
465
|
+
"""Canonicalize, compress, and encrypt a protected-module index."""
|
|
466
|
+
|
|
467
|
+
if type(compression_level) is not int or not -1 <= compression_level <= 9:
|
|
468
|
+
raise ManifestError("zlib compression_level must be between -1 and 9")
|
|
469
|
+
plaintext = canonical_module_index_bytes(value)
|
|
470
|
+
if len(plaintext) > _MAX_MODULE_INDEX_SIZE:
|
|
471
|
+
raise ManifestError("module index exceeds the safety limit")
|
|
472
|
+
key = derive_module_index_key(support_root_key, build_id)
|
|
473
|
+
aad = module_index_aad(build_id, python_tag)
|
|
474
|
+
encrypted = encrypt_payload(
|
|
475
|
+
zlib.compress(plaintext, level=compression_level),
|
|
476
|
+
key,
|
|
477
|
+
aad=aad,
|
|
478
|
+
nonce=nonce,
|
|
479
|
+
)
|
|
480
|
+
return EncryptedModuleIndex(encrypted.nonce, encrypted.ciphertext)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def decrypt_module_index(
|
|
484
|
+
encrypted_index: EncryptedModuleIndex,
|
|
485
|
+
support_root_key: BytesLike,
|
|
486
|
+
*,
|
|
487
|
+
build_id: str,
|
|
488
|
+
python_tag: str,
|
|
489
|
+
) -> ModuleIndex:
|
|
490
|
+
"""Authenticate, decrypt, and strictly decode a protected-module index."""
|
|
491
|
+
|
|
492
|
+
nonce, ciphertext = _encrypted_index_parts(encrypted_index)
|
|
493
|
+
key = derive_module_index_key(support_root_key, build_id)
|
|
494
|
+
aad = module_index_aad(build_id, python_tag)
|
|
495
|
+
compressed = decrypt_payload(ciphertext, key, nonce, aad=aad)
|
|
496
|
+
plaintext = _decompress_strict(
|
|
497
|
+
compressed,
|
|
498
|
+
maximum=_MAX_MODULE_INDEX_SIZE,
|
|
499
|
+
description="module index",
|
|
500
|
+
error=ManifestError,
|
|
501
|
+
)
|
|
502
|
+
return decode_module_index(plaintext)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _encrypted_index_parts(
|
|
506
|
+
value: EncryptedModuleIndex,
|
|
507
|
+
) -> tuple[bytes, bytes]:
|
|
508
|
+
if not isinstance(value, EncryptedModuleIndex):
|
|
509
|
+
raise ManifestError("encrypted index must be an EncryptedModuleIndex")
|
|
510
|
+
nonce = _as_bytes(value.nonce, name="module index nonce", error=ManifestError)
|
|
511
|
+
ciphertext = _as_bytes(
|
|
512
|
+
value.ciphertext,
|
|
513
|
+
name="module index ciphertext",
|
|
514
|
+
error=ManifestError,
|
|
515
|
+
)
|
|
516
|
+
if len(nonce) != NONCE_SIZE:
|
|
517
|
+
raise ManifestError(f"module index nonce must be exactly {NONCE_SIZE} bytes")
|
|
518
|
+
if len(ciphertext) < TAG_SIZE:
|
|
519
|
+
raise ManifestError("module index ciphertext is shorter than its GCM tag")
|
|
520
|
+
return nonce, ciphertext
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _decompress_strict(
|
|
524
|
+
compressed: bytes,
|
|
525
|
+
*,
|
|
526
|
+
maximum: int,
|
|
527
|
+
description: str,
|
|
528
|
+
error: type[Exception],
|
|
529
|
+
) -> bytes:
|
|
530
|
+
decompressor = zlib.decompressobj()
|
|
531
|
+
try:
|
|
532
|
+
value = decompressor.decompress(compressed, maximum + 1)
|
|
533
|
+
if len(value) > maximum or decompressor.unconsumed_tail:
|
|
534
|
+
raise error(f"{description} exceeds the safety limit")
|
|
535
|
+
value += decompressor.flush()
|
|
536
|
+
except zlib.error as exc:
|
|
537
|
+
raise error(f"{description} is not valid zlib data") from exc
|
|
538
|
+
if len(value) > maximum:
|
|
539
|
+
raise error(f"{description} exceeds the safety limit")
|
|
540
|
+
if not decompressor.eof or decompressor.unused_data:
|
|
541
|
+
raise error(f"{description} contains incomplete or trailing compressed data")
|
|
542
|
+
return value
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
__all__ = [
|
|
546
|
+
"ARTIFACT_KEY_INFO",
|
|
547
|
+
"EncryptedModuleIndex",
|
|
548
|
+
"INDEX_COMMITMENT_DOMAIN",
|
|
549
|
+
"INDEX_COMMITMENT_SIZE",
|
|
550
|
+
"INDEX_KEY_INFO",
|
|
551
|
+
"MAGIC_PYE2",
|
|
552
|
+
"MODULE_INDEX_FORMAT",
|
|
553
|
+
"MODULE_ROOT_KEY_INFO",
|
|
554
|
+
"ModuleIndex",
|
|
555
|
+
"ModuleIndexEntry",
|
|
556
|
+
"OPAQUE_ARTIFACT_FORMAT",
|
|
557
|
+
"OpaqueModuleContext",
|
|
558
|
+
"ParsedOpaqueArtifact",
|
|
559
|
+
"TOKEN_SIZE",
|
|
560
|
+
"canonical_module_index_bytes",
|
|
561
|
+
"decode_module_index",
|
|
562
|
+
"decrypt_module_index",
|
|
563
|
+
"derive_module_index_key",
|
|
564
|
+
"derive_module_root_key",
|
|
565
|
+
"derive_opaque_artifact_key",
|
|
566
|
+
"encrypt_module_index",
|
|
567
|
+
"module_index_aad",
|
|
568
|
+
"module_index_commitment",
|
|
569
|
+
"normalize_module_index",
|
|
570
|
+
"opaque_artifact_aad",
|
|
571
|
+
"pack_opaque_artifact",
|
|
572
|
+
"parse_opaque_artifact",
|
|
573
|
+
"unpack_opaque_artifact",
|
|
574
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Public runtime interface copied into every protected distribution."""
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
import sys as _sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_sys.dont_write_bytecode = True
|
|
8
|
+
_runtime_directory = _os.path.dirname(_os.path.realpath(__file__))
|
|
9
|
+
_distribution_root = _os.path.dirname(_runtime_directory)
|
|
10
|
+
_expected_runtime_files = {"__init__.py", "_mp_main.py", "_runtime.py", "_build.py"}
|
|
11
|
+
try:
|
|
12
|
+
with _os.scandir(_runtime_directory) as _runtime_iterator:
|
|
13
|
+
_runtime_entries = tuple(_runtime_iterator)
|
|
14
|
+
except OSError as exc:
|
|
15
|
+
raise ImportError("pyencode runtime directory is missing or unreadable") from exc
|
|
16
|
+
_runtime_names = {entry.name for entry in _runtime_entries}
|
|
17
|
+
_runtime_problems = []
|
|
18
|
+
if _expected_runtime_files - _runtime_names:
|
|
19
|
+
_runtime_problems.append("missing " + ", ".join(sorted(_expected_runtime_files - _runtime_names)))
|
|
20
|
+
if _runtime_names - _expected_runtime_files:
|
|
21
|
+
_runtime_problems.append("unexpected " + ", ".join(sorted(_runtime_names - _expected_runtime_files)))
|
|
22
|
+
_non_files = sorted(
|
|
23
|
+
entry.name
|
|
24
|
+
for entry in _runtime_entries
|
|
25
|
+
if entry.is_symlink() or not entry.is_file(follow_symlinks=False)
|
|
26
|
+
)
|
|
27
|
+
if _non_files:
|
|
28
|
+
_runtime_problems.append("not regular files " + ", ".join(_non_files))
|
|
29
|
+
if _runtime_problems:
|
|
30
|
+
raise ImportError(
|
|
31
|
+
"pyencode runtime directory contains unsigned files or directories ("
|
|
32
|
+
+ "; ".join(_runtime_problems)
|
|
33
|
+
+ ")"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
from . import _build as _bootstrap_build
|
|
37
|
+
|
|
38
|
+
_raw_bootstrap_paths = getattr(_bootstrap_build, "BOOTSTRAP_PATHS", ())
|
|
39
|
+
if not isinstance(_raw_bootstrap_paths, (tuple, list)):
|
|
40
|
+
raise ImportError("pyencode runtime bootstrap path configuration is invalid")
|
|
41
|
+
_bootstrap_roots = set()
|
|
42
|
+
for _relative in _raw_bootstrap_paths:
|
|
43
|
+
if _relative != "_vendor":
|
|
44
|
+
raise ImportError("pyencode runtime bootstrap path configuration is invalid")
|
|
45
|
+
_bootstrap_roots.add(
|
|
46
|
+
_os.path.normcase(_os.path.realpath(_os.path.join(_distribution_root, _relative)))
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_distribution_path(_entry):
|
|
51
|
+
if not isinstance(_entry, str):
|
|
52
|
+
return False
|
|
53
|
+
_candidate = _entry or _os.getcwd()
|
|
54
|
+
try:
|
|
55
|
+
_root = _os.path.normcase(_os.path.realpath(_distribution_root))
|
|
56
|
+
_resolved = _os.path.normcase(_os.path.realpath(_candidate))
|
|
57
|
+
if _resolved in _bootstrap_roots:
|
|
58
|
+
return False
|
|
59
|
+
return _os.path.commonpath((_root, _resolved)) == _root
|
|
60
|
+
except (OSError, TypeError, ValueError):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Do not let an injected top-level module beside the bundle shadow stdlib or
|
|
65
|
+
# cryptography while the verifier itself is importing. Relative imports from
|
|
66
|
+
# this already-located package continue to resolve through ``__path__``.
|
|
67
|
+
_original_sys_path = list(_sys.path)
|
|
68
|
+
_sys.path[:] = [_entry for _entry in _sys.path if not _is_distribution_path(_entry)]
|
|
69
|
+
try:
|
|
70
|
+
from cryptography.exceptions import InvalidSignature as _InvalidSignature
|
|
71
|
+
from cryptography.exceptions import InvalidTag as _InvalidTag
|
|
72
|
+
from cryptography.hazmat.primitives import hashes as _crypto_hashes
|
|
73
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
74
|
+
Ed25519PublicKey as _Ed25519PublicKey,
|
|
75
|
+
)
|
|
76
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM as _AESGCM
|
|
77
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF as _HKDF
|
|
78
|
+
|
|
79
|
+
from ._runtime import (
|
|
80
|
+
CompatibilityError,
|
|
81
|
+
IntegrityError,
|
|
82
|
+
PolicyError,
|
|
83
|
+
PyEncodeRuntimeError,
|
|
84
|
+
install,
|
|
85
|
+
load_entry,
|
|
86
|
+
run,
|
|
87
|
+
)
|
|
88
|
+
finally:
|
|
89
|
+
_sys.path[:] = _original_sys_path
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"CompatibilityError",
|
|
93
|
+
"IntegrityError",
|
|
94
|
+
"PolicyError",
|
|
95
|
+
"PyEncodeRuntimeError",
|
|
96
|
+
"install",
|
|
97
|
+
"load_entry",
|
|
98
|
+
"run",
|
|
99
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Build-specific values replaced by :mod:`pyencode` in protected output.
|
|
2
|
+
|
|
3
|
+
This file deliberately contains no usable defaults. Keeping the names here
|
|
4
|
+
makes the runtime template importable while it is part of the pyencode source
|
|
5
|
+
tree; a build must replace every value except ``MANIFEST_NAME``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
BUILD_ID = ""
|
|
9
|
+
PYTHON_TAG = ""
|
|
10
|
+
PUBLIC_KEY_B64 = ""
|
|
11
|
+
KEY_PARTS = ()
|
|
12
|
+
KEY_ORDER = ()
|
|
13
|
+
MANIFEST_NAME = "pyencode-manifest.json"
|
|
14
|
+
LAUNCHER_NAME = "run.py"
|
|
15
|
+
BOOTSTRAP_PATHS = []
|