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/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """Command line interface for PyEncode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import date
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from . import __version__
12
+ from .builder import BuildOptions, build
13
+ from .errors import PyEncodeError
14
+
15
+
16
+ def _expiry(value: str) -> date:
17
+ try:
18
+ return date.fromisoformat(value)
19
+ except ValueError as exc:
20
+ raise argparse.ArgumentTypeError("expected YYYY-MM-DD") from exc
21
+
22
+
23
+ def _parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(
25
+ prog="pyencode",
26
+ description="Protect Python modules in authenticated encrypted .pye files.",
27
+ )
28
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
29
+ subparsers = parser.add_subparsers(dest="command", required=True)
30
+
31
+ build_parser = subparsers.add_parser("build", help="build a protected application")
32
+ build_parser.add_argument("source", type=Path, help="Python file, project directory, or package")
33
+ build_parser.add_argument("-o", "--output", type=Path, default=Path("dist"))
34
+ build_parser.add_argument("-e", "--entry", dest="entry_module", help="entry module name")
35
+ build_parser.add_argument(
36
+ "--exclude",
37
+ action="append",
38
+ default=[],
39
+ metavar="GLOB",
40
+ help="exclude a relative path glob; may be repeated",
41
+ )
42
+ build_parser.add_argument(
43
+ "--no-resources",
44
+ action="store_true",
45
+ help="do not copy non-Python files",
46
+ )
47
+ build_parser.add_argument(
48
+ "--keep-docstrings",
49
+ action="store_true",
50
+ help="keep module, class, and function docstrings",
51
+ )
52
+ build_parser.add_argument(
53
+ "--optimize",
54
+ type=int,
55
+ choices=(0, 1, 2),
56
+ default=0,
57
+ help="CPython optimization level (default: 0)",
58
+ )
59
+ build_parser.add_argument(
60
+ "--expires",
61
+ type=_expiry,
62
+ metavar="YYYY-MM-DD",
63
+ help="refuse to run after this UTC date",
64
+ )
65
+ build_parser.add_argument(
66
+ "--launcher",
67
+ type=Path,
68
+ help="copy, sign, and key-bind this custom plaintext launcher instead of run.py",
69
+ )
70
+ build_parser.add_argument(
71
+ "--rename-locals",
72
+ action="store_true",
73
+ help=(
74
+ "rename non-argument local metadata conservatively; opt in because "
75
+ "dynamic locals/frame introspection can depend on original names"
76
+ ),
77
+ )
78
+ build_parser.add_argument(
79
+ "--support",
80
+ action="append",
81
+ nargs=2,
82
+ default=[],
83
+ metavar=("SOURCE", "DEST"),
84
+ help=(
85
+ "copy, sign, and key-bind an additional file or directory at the "
86
+ "POSIX-relative output path DEST; may be repeated"
87
+ ),
88
+ )
89
+ build_parser.add_argument(
90
+ "--allow-extra-data",
91
+ action="store_true",
92
+ help=(
93
+ "allow the host to add unsigned non-code files and directories; "
94
+ "signed files and recognized Python/native modules remain protected"
95
+ ),
96
+ )
97
+ return parser
98
+
99
+
100
+ def main(argv: Sequence[str] | None = None) -> int:
101
+ parser = _parser()
102
+ args = parser.parse_args(argv)
103
+ try:
104
+ if args.command == "build":
105
+ result = build(
106
+ BuildOptions(
107
+ source=args.source,
108
+ output=args.output,
109
+ entry_module=args.entry_module,
110
+ excludes=tuple(args.exclude),
111
+ include_resources=not args.no_resources,
112
+ strip_docstrings=not args.keep_docstrings,
113
+ optimize=args.optimize,
114
+ expires=args.expires,
115
+ launcher=args.launcher,
116
+ rename_locals=args.rename_locals,
117
+ support=tuple((Path(source), destination) for source, destination in args.support),
118
+ allow_extra_data=args.allow_extra_data,
119
+ )
120
+ )
121
+ print(f"Built {result.module_count} protected module(s) in {result.output}")
122
+ if result.resource_count:
123
+ print(f"Copied {result.resource_count} resource file(s)")
124
+ if result.support_count:
125
+ print(f"Bound {result.support_count} additional support file(s)")
126
+ print(f"Entry: {result.entry_module} ({result.python_tag})")
127
+ print(f"Launcher: {result.launcher_name}")
128
+ print(f"Run: {sys.executable} {result.output / result.launcher_name}")
129
+ return 0
130
+ except PyEncodeError as exc:
131
+ print(f"pyencode: error: {exc}", file=sys.stderr)
132
+ return 2
133
+ except OSError as exc:
134
+ print(f"pyencode: filesystem error: {exc}", file=sys.stderr)
135
+ return 2
136
+ return 1
@@ -0,0 +1,185 @@
1
+ """Conservative, version-portable hardening for compiled code metadata.
2
+
3
+ The transformer deliberately changes only metadata fields accepted by
4
+ ``CodeType.replace`` on CPython 3.10 and newer. New CPython feature releases
5
+ are admitted on a forward-compatible basis and exercised by the CI matrix.
6
+ It does not rewrite bytecode or line tables.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import types
13
+
14
+
15
+ # These names are a conservative signal that a scope may depend on the names
16
+ # exposed through its fast-locals mapping. Dynamic aliases cannot be detected,
17
+ # so callers that rely on unusual frame introspection should disable local
18
+ # renaming explicitly.
19
+ _REFLECTIVE_LOCAL_NAMES = frozenset(
20
+ {
21
+ "__code__",
22
+ "_getframe",
23
+ "co_varnames",
24
+ "currentframe",
25
+ "dir",
26
+ "eval",
27
+ "exec",
28
+ "f_code",
29
+ "f_locals",
30
+ "getargvalues",
31
+ "getasyncgenlocals",
32
+ "getcoroutinelocals",
33
+ "getgeneratorlocals",
34
+ "locals",
35
+ "vars",
36
+ }
37
+ )
38
+
39
+ _CO_VARARGS = 0x04
40
+ _CO_VARKEYWORDS = 0x08
41
+ _LOCAL_ALIAS_DOMAIN = b"pyencode-local-name-v1\0"
42
+
43
+
44
+ def _validate_module_name(module_name: str) -> None:
45
+ if not isinstance(module_name, str):
46
+ raise TypeError("module_name must be a string")
47
+ if not module_name or any(
48
+ not component.isidentifier() for component in module_name.split(".")
49
+ ):
50
+ raise ValueError("module_name must be a dotted Python module name")
51
+
52
+
53
+ def _argument_slot_count(code: types.CodeType) -> int:
54
+ """Return the prefix of ``co_varnames`` occupied by call arguments."""
55
+
56
+ count = code.co_argcount + code.co_kwonlyargcount
57
+ if code.co_flags & _CO_VARARGS:
58
+ count += 1
59
+ if code.co_flags & _CO_VARKEYWORDS:
60
+ count += 1
61
+ return count
62
+
63
+
64
+ def _local_alias(
65
+ module_name: str,
66
+ scope_path: tuple[int, ...],
67
+ slot: int,
68
+ attempt: int,
69
+ ) -> str:
70
+ scope = ".".join(str(index) for index in scope_path)
71
+ material = f"{module_name}\0{scope}\0{slot}\0{attempt}".encode("utf-8")
72
+ digest = hashlib.sha256(_LOCAL_ALIAS_DOMAIN + material).hexdigest()[:12]
73
+ return f"_pye_{digest}"
74
+
75
+
76
+ def _renamed_varnames(
77
+ code: types.CodeType,
78
+ *,
79
+ module_name: str,
80
+ scope_path: tuple[int, ...],
81
+ ) -> tuple[str, ...]:
82
+ if _REFLECTIVE_LOCAL_NAMES.intersection(code.co_names):
83
+ return code.co_varnames
84
+
85
+ argument_slots = _argument_slot_count(code)
86
+ if argument_slots >= len(code.co_varnames):
87
+ return code.co_varnames
88
+
89
+ protected_closure_names = set(code.co_cellvars) | set(code.co_freevars)
90
+ used_names = (
91
+ set(code.co_varnames)
92
+ | protected_closure_names
93
+ | set(code.co_names)
94
+ )
95
+ renamed = list(code.co_varnames)
96
+
97
+ for slot in range(argument_slots, len(renamed)):
98
+ if renamed[slot] in protected_closure_names:
99
+ # On CPython 3.10 a captured local can occur in both co_varnames
100
+ # and co_cellvars. Keeping both names aligned avoids changing the
101
+ # cell-to-local mapping when CodeType.replace reconstructs it.
102
+ continue
103
+
104
+ attempt = 0
105
+ while True:
106
+ alias = _local_alias(module_name, scope_path, slot, attempt)
107
+ if alias not in used_names:
108
+ break
109
+ attempt += 1
110
+ renamed[slot] = alias
111
+ used_names.add(alias)
112
+
113
+ return tuple(renamed)
114
+
115
+
116
+ def _harden_code(
117
+ code: types.CodeType,
118
+ *,
119
+ module_name: str,
120
+ filename: str,
121
+ rename_locals: bool,
122
+ normalize_filenames: bool,
123
+ scope_path: tuple[int, ...],
124
+ ) -> types.CodeType:
125
+ constants = tuple(
126
+ _harden_code(
127
+ value,
128
+ module_name=module_name,
129
+ filename=filename,
130
+ rename_locals=rename_locals,
131
+ normalize_filenames=normalize_filenames,
132
+ scope_path=(*scope_path, index),
133
+ )
134
+ if isinstance(value, types.CodeType)
135
+ else value
136
+ for index, value in enumerate(code.co_consts)
137
+ )
138
+
139
+ changes: dict[str, object] = {"co_consts": constants}
140
+ if rename_locals:
141
+ changes["co_varnames"] = _renamed_varnames(
142
+ code,
143
+ module_name=module_name,
144
+ scope_path=scope_path,
145
+ )
146
+ if normalize_filenames:
147
+ changes["co_filename"] = filename
148
+
149
+ # In particular, do not pass co_linetable=b"". On CPython 3.11+ an
150
+ # incomplete positions iterator can make traceback formatting fail.
151
+ return code.replace(**changes)
152
+
153
+
154
+ def harden_code(
155
+ code: types.CodeType,
156
+ *,
157
+ module_name: str,
158
+ rename_locals: bool = True,
159
+ normalize_filenames: bool = True,
160
+ ) -> types.CodeType:
161
+ """Return a recursively metadata-hardened copy of *code*.
162
+
163
+ Argument names, global/name lookups, closure names, function names,
164
+ qualified names, constants, bytecode, exception tables, and line tables are
165
+ preserved. A scope that directly references a known locals/frame
166
+ introspection API keeps all of its local names as a compatibility guard.
167
+
168
+ Local-name reflection through dynamically aliased APIs cannot be detected;
169
+ pass ``rename_locals=False`` for code that relies on such behavior.
170
+ """
171
+
172
+ if not isinstance(code, types.CodeType):
173
+ raise TypeError("code must be a Python code object")
174
+ _validate_module_name(module_name)
175
+ return _harden_code(
176
+ code,
177
+ module_name=module_name,
178
+ filename=f"<pyencode:{module_name}>",
179
+ rename_locals=rename_locals,
180
+ normalize_filenames=normalize_filenames,
181
+ scope_path=(),
182
+ )
183
+
184
+
185
+ __all__ = ["harden_code"]
pyencode/container.py ADDED
@@ -0,0 +1,319 @@
1
+ """Binary container for encrypted Python module payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ import zlib
8
+ from dataclasses import dataclass
9
+ from typing import Any, Mapping, TypedDict, cast
10
+
11
+ from .crypto import (
12
+ NONCE_SIZE,
13
+ TAG_SIZE,
14
+ BytesLike,
15
+ decrypt_payload,
16
+ derive_module_key,
17
+ encrypt_payload,
18
+ )
19
+ from .errors import ContainerError, UnsupportedFormatError
20
+
21
+
22
+ MAGIC = b"PYE1"
23
+ FORMAT_VERSION = 1
24
+ COMPRESSION = "zlib"
25
+ _LENGTH = struct.Struct(">I")
26
+ _PREFIX_SIZE = len(MAGIC) + _LENGTH.size
27
+ _MAX_HEADER_SIZE = 64 * 1024
28
+
29
+
30
+ class ModuleHeader(TypedDict):
31
+ """Authenticated metadata stored before an encrypted module payload."""
32
+
33
+ format: int
34
+ module: str
35
+ package: bool
36
+ python_tag: str
37
+ build_id: str
38
+ compression: str
39
+ marshal_version: int
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class ParsedContainer:
44
+ """A parsed but still-encrypted module container."""
45
+
46
+ header: ModuleHeader
47
+ nonce: bytes
48
+ ciphertext: bytes
49
+ associated_data: bytes
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class UnpackedModule:
54
+ """The authenticated module header and decompressed plaintext payload."""
55
+
56
+ header: ModuleHeader
57
+ payload: bytes
58
+
59
+ def __iter__(self):
60
+ """Allow convenient ``header, payload = unpack(...)`` usage."""
61
+
62
+ yield self.header
63
+ yield self.payload
64
+
65
+
66
+ def _as_bytes(value: BytesLike, *, name: str) -> bytes:
67
+ if not isinstance(value, (bytes, bytearray, memoryview)):
68
+ raise ContainerError(f"{name} must be bytes-like")
69
+ return bytes(value)
70
+
71
+
72
+ def canonical_header_bytes(header: Mapping[str, Any]) -> bytes:
73
+ """Validate and serialize a header as canonical UTF-8 JSON."""
74
+
75
+ normalized = validate_header(header)
76
+ return canonical_json_bytes(normalized)
77
+
78
+
79
+ def canonical_json_bytes(value: Any) -> bytes:
80
+ """Serialize a JSON value using the canonical container/manifest encoding."""
81
+
82
+ try:
83
+ return json.dumps(
84
+ value,
85
+ sort_keys=True,
86
+ separators=(",", ":"),
87
+ ensure_ascii=False,
88
+ allow_nan=False,
89
+ ).encode("utf-8")
90
+ except (TypeError, ValueError, UnicodeEncodeError) as exc:
91
+ raise ContainerError("header cannot be encoded as canonical JSON") from exc
92
+
93
+
94
+ def validate_header(header: Mapping[str, Any]) -> ModuleHeader:
95
+ """Return a validated copy of a version-1 module header."""
96
+
97
+ if not isinstance(header, Mapping):
98
+ raise ContainerError("container header must be a JSON object")
99
+
100
+ required = {
101
+ "format",
102
+ "module",
103
+ "package",
104
+ "python_tag",
105
+ "build_id",
106
+ "compression",
107
+ "marshal_version",
108
+ }
109
+ missing = required.difference(header)
110
+ if missing:
111
+ raise ContainerError(
112
+ "container header is missing: " + ", ".join(sorted(missing))
113
+ )
114
+
115
+ version = header["format"]
116
+ if type(version) is not int:
117
+ raise ContainerError("header format must be an integer")
118
+ if version != FORMAT_VERSION:
119
+ raise UnsupportedFormatError(f"unsupported container format: {version}")
120
+
121
+ module = header["module"]
122
+ if not isinstance(module, str) or not module:
123
+ raise ContainerError("header module must be a non-empty string")
124
+ if "\x00" in module:
125
+ raise ContainerError("header module must not contain NUL")
126
+
127
+ package = header["package"]
128
+ if type(package) is not bool:
129
+ raise ContainerError("header package must be a boolean")
130
+
131
+ python_tag = header["python_tag"]
132
+ if not isinstance(python_tag, str) or not python_tag:
133
+ raise ContainerError("header python_tag must be a non-empty string")
134
+
135
+ build_id = header["build_id"]
136
+ if not isinstance(build_id, str) or not build_id:
137
+ raise ContainerError("header build_id must be a non-empty hexadecimal string")
138
+ try:
139
+ decoded_build_id = bytes.fromhex(build_id)
140
+ except ValueError as exc:
141
+ raise ContainerError("header build_id must be hexadecimal") from exc
142
+ if not decoded_build_id or len(build_id) != len(decoded_build_id) * 2:
143
+ # The length check rejects spaces accepted by bytes.fromhex().
144
+ raise ContainerError("header build_id must be canonical even-length hexadecimal")
145
+
146
+ compression = header["compression"]
147
+ if compression != COMPRESSION:
148
+ raise ContainerError(f"unsupported payload compression: {compression!r}")
149
+
150
+ marshal_version = header["marshal_version"]
151
+ if type(marshal_version) is not int or marshal_version < 0:
152
+ raise ContainerError("header marshal_version must be a non-negative integer")
153
+
154
+ # Version 1 has a deliberately fixed header schema. Rejecting additions
155
+ # prevents a producer and consumer from authenticating fields with different
156
+ # semantics.
157
+ extra = set(header).difference(required)
158
+ if extra:
159
+ raise ContainerError(
160
+ "container header has unknown fields: " + ", ".join(sorted(extra))
161
+ )
162
+
163
+ return cast(
164
+ ModuleHeader,
165
+ {
166
+ "format": version,
167
+ "module": module,
168
+ "package": package,
169
+ "python_tag": python_tag,
170
+ "build_id": build_id,
171
+ "compression": compression,
172
+ "marshal_version": marshal_version,
173
+ },
174
+ )
175
+
176
+
177
+ def _prefix(header_json: bytes) -> bytes:
178
+ if not header_json:
179
+ raise ContainerError("container header must not be empty")
180
+ if len(header_json) > _MAX_HEADER_SIZE:
181
+ raise ContainerError("container header is too large")
182
+ return MAGIC + _LENGTH.pack(len(header_json)) + header_json
183
+
184
+
185
+ def assemble_container(
186
+ header: Mapping[str, Any],
187
+ nonce: BytesLike,
188
+ ciphertext: BytesLike,
189
+ ) -> bytes:
190
+ """Assemble an already-encrypted payload into the version-1 wire format."""
191
+
192
+ header_json = canonical_header_bytes(header)
193
+ nonce_bytes = _as_bytes(nonce, name="nonce")
194
+ encrypted = _as_bytes(ciphertext, name="ciphertext")
195
+ if len(nonce_bytes) != NONCE_SIZE:
196
+ raise ContainerError("container nonce must be exactly 12 bytes")
197
+ if len(encrypted) < TAG_SIZE:
198
+ raise ContainerError("container ciphertext is shorter than the GCM tag")
199
+ return _prefix(header_json) + nonce_bytes + encrypted
200
+
201
+
202
+ def parse_container(data: BytesLike) -> ParsedContainer:
203
+ """Parse and validate the unencrypted structure of a container."""
204
+
205
+ blob = _as_bytes(data, name="data")
206
+ minimum = _PREFIX_SIZE + 2 + NONCE_SIZE + TAG_SIZE
207
+ if len(blob) < minimum:
208
+ raise ContainerError("container is truncated")
209
+ if blob[: len(MAGIC)] != MAGIC:
210
+ raise ContainerError("invalid container magic")
211
+
212
+ header_length = _LENGTH.unpack_from(blob, len(MAGIC))[0]
213
+ if header_length == 0:
214
+ raise ContainerError("container header must not be empty")
215
+ if header_length > _MAX_HEADER_SIZE:
216
+ raise ContainerError("container header is too large")
217
+
218
+ header_start = _PREFIX_SIZE
219
+ header_end = header_start + header_length
220
+ payload_start = header_end + NONCE_SIZE
221
+ if payload_start + TAG_SIZE > len(blob):
222
+ raise ContainerError("container is truncated")
223
+
224
+ header_json = blob[header_start:header_end]
225
+ try:
226
+ decoded = json.loads(header_json.decode("utf-8"))
227
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
228
+ raise ContainerError("container header is not valid UTF-8 JSON") from exc
229
+ if not isinstance(decoded, dict):
230
+ raise ContainerError("container header must be a JSON object")
231
+ header = validate_header(decoded)
232
+ if canonical_header_bytes(header) != header_json:
233
+ raise ContainerError("container header JSON is not canonical")
234
+
235
+ return ParsedContainer(
236
+ header=header,
237
+ nonce=blob[header_end:payload_start],
238
+ ciphertext=blob[payload_start:],
239
+ associated_data=header_json,
240
+ )
241
+
242
+
243
+ def pack(
244
+ payload: BytesLike,
245
+ header: Mapping[str, Any],
246
+ master_key: BytesLike,
247
+ *,
248
+ nonce: BytesLike | None = None,
249
+ compression_level: int = 9,
250
+ ) -> bytes:
251
+ """Compress, encrypt, and pack one marshalled module payload."""
252
+
253
+ plaintext = _as_bytes(payload, name="payload")
254
+ normalized = validate_header(header)
255
+ if not isinstance(compression_level, int) or not -1 <= compression_level <= 9:
256
+ raise ContainerError("zlib compression_level must be between -1 and 9")
257
+
258
+ header_json = canonical_header_bytes(normalized)
259
+ prefix = _prefix(header_json)
260
+ key = derive_module_key(
261
+ master_key,
262
+ normalized["build_id"],
263
+ normalized["module"],
264
+ )
265
+ compressed = zlib.compress(plaintext, level=compression_level)
266
+ encrypted = encrypt_payload(
267
+ compressed,
268
+ key,
269
+ aad=header_json,
270
+ nonce=nonce,
271
+ )
272
+ return prefix + encrypted.nonce + encrypted.ciphertext
273
+
274
+
275
+ def unpack(data: BytesLike, master_key: BytesLike) -> UnpackedModule:
276
+ """Authenticate, decrypt, and decompress one module container."""
277
+
278
+ parsed = parse_container(data)
279
+ key = derive_module_key(
280
+ master_key,
281
+ parsed.header["build_id"],
282
+ parsed.header["module"],
283
+ )
284
+ compressed = decrypt_payload(
285
+ parsed.ciphertext,
286
+ key,
287
+ parsed.nonce,
288
+ aad=parsed.associated_data,
289
+ )
290
+ try:
291
+ payload = zlib.decompress(compressed)
292
+ except zlib.error as exc:
293
+ raise ContainerError("authenticated payload is not valid zlib data") from exc
294
+ return UnpackedModule(parsed.header, payload)
295
+
296
+
297
+ # Explicit names make call sites self-documenting while the concise aliases are
298
+ # convenient for users of this module directly.
299
+ pack_module = pack
300
+ unpack_module = unpack
301
+
302
+
303
+ __all__ = [
304
+ "COMPRESSION",
305
+ "FORMAT_VERSION",
306
+ "MAGIC",
307
+ "ModuleHeader",
308
+ "ParsedContainer",
309
+ "UnpackedModule",
310
+ "assemble_container",
311
+ "canonical_header_bytes",
312
+ "canonical_json_bytes",
313
+ "pack",
314
+ "pack_module",
315
+ "parse_container",
316
+ "unpack",
317
+ "unpack_module",
318
+ "validate_header",
319
+ ]