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