pyobfus-runtime 0.1.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.
- pyobfus_runtime/__init__.py +58 -0
- pyobfus_runtime/binding.py +435 -0
- pyobfus_runtime/embedded_data.py +96 -0
- pyobfus_runtime/opacity.py +210 -0
- pyobfus_runtime/policy.py +108 -0
- pyobfus_runtime/scrub.py +227 -0
- pyobfus_runtime/seal.py +205 -0
- pyobfus_runtime/vault.py +243 -0
- pyobfus_runtime-0.1.0.dist-info/METADATA +45 -0
- pyobfus_runtime-0.1.0.dist-info/RECORD +13 -0
- pyobfus_runtime-0.1.0.dist-info/WHEEL +5 -0
- pyobfus_runtime-0.1.0.dist-info/licenses/LICENSE +26 -0
- pyobfus_runtime-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Runtime support for P2-1 Selective Opacity L3 lazy materialization.
|
|
2
|
+
|
|
3
|
+
The build-pass transformer (W3-B, separate commit) replaces the body of an
|
|
4
|
+
``@opacity("encrypted")`` function with a stub and emits two module-level
|
|
5
|
+
constants:
|
|
6
|
+
|
|
7
|
+
_CIPHER_<funcname> = b"<nonce(12) || ciphertext+tag>"
|
|
8
|
+
_LAYER_KEY = b"<32-byte-AES-256-key>"
|
|
9
|
+
|
|
10
|
+
@_l3_dispatch(_CIPHER_<funcname>, _LAYER_KEY)
|
|
11
|
+
def <funcname>(...):
|
|
12
|
+
pass # body replaced at first call by the decrypted CodeType
|
|
13
|
+
|
|
14
|
+
At first call to the wrapper, ``_l3_dispatch`` decrypts the cipher blob,
|
|
15
|
+
``marshal.loads`` the plaintext into a ``CodeType``, and patches the stub's
|
|
16
|
+
``__code__`` so that subsequent calls execute the real bytecode directly.
|
|
17
|
+
|
|
18
|
+
The cached state ("did we decrypt yet?" + the decrypted ``CodeType``) lives
|
|
19
|
+
in a **closure cell**, not as an attribute on either the wrapper or the
|
|
20
|
+
stub, mirroring the hardening pattern in ``seal.py``: an attacker cannot
|
|
21
|
+
pre-set ``stub._loaded = True`` to bypass decryption, because there is no
|
|
22
|
+
``_loaded`` attribute -- the boolean only exists in the closure.
|
|
23
|
+
|
|
24
|
+
**At-rest property** (the load-bearing patent claim):
|
|
25
|
+
|
|
26
|
+
Plaintext bytecode never exists in the build artifact and never exists
|
|
27
|
+
in the imported module before the first call. An attacker dumping
|
|
28
|
+
``marshal.dumps(<imported_module>.critical.__code__)`` immediately
|
|
29
|
+
after import gets the *stub*'s code, not the protected function's
|
|
30
|
+
code. The protected code only materializes inside the closure cell,
|
|
31
|
+
only after the first call, only for the lifetime of the process.
|
|
32
|
+
|
|
33
|
+
W3-A scope (this module): the runtime side. The build-tool helper
|
|
34
|
+
``_encrypt_code`` is also in this module so the build pass and the
|
|
35
|
+
runtime share a single encryption rule. Build-pass AST transformer is
|
|
36
|
+
W3-B.
|
|
37
|
+
|
|
38
|
+
Patent-gated. See PATENT_NOTES.md and docs/P2-1_DESIGN.md.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import functools
|
|
44
|
+
import marshal
|
|
45
|
+
import secrets
|
|
46
|
+
import types
|
|
47
|
+
from typing import Callable, TypeVar, cast
|
|
48
|
+
|
|
49
|
+
from cryptography.exceptions import InvalidTag
|
|
50
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
51
|
+
|
|
52
|
+
_F = TypeVar("_F", bound=Callable)
|
|
53
|
+
|
|
54
|
+
_NONCE_SIZE = 12 # AES-GCM standard
|
|
55
|
+
_KEY_SIZE = 32 # 256-bit AES key
|
|
56
|
+
_TAG_SIZE = 16 # AES-GCM authentication tag
|
|
57
|
+
_MIN_CIPHER_LEN = _NONCE_SIZE + _TAG_SIZE # nonce + (zero-length-ct) + tag
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OpacityRuntimeError(RuntimeError):
|
|
61
|
+
"""Raised when L3 lazy materialization fails.
|
|
62
|
+
|
|
63
|
+
Possible causes (all surface uniformly to avoid leaking which one
|
|
64
|
+
triggered, in line with the failure-mode discipline established for
|
|
65
|
+
P2-10 and P2-11):
|
|
66
|
+
|
|
67
|
+
- wrong layer key (GCM tag mismatch)
|
|
68
|
+
- tampered cipher blob (GCM tag mismatch)
|
|
69
|
+
- truncated cipher blob (length check)
|
|
70
|
+
- corrupted plaintext (marshal.loads failure)
|
|
71
|
+
- unexpected payload type (decrypted to non-CodeType)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _encrypt_code(code: types.CodeType, key: bytes | bytearray) -> bytes:
|
|
76
|
+
"""Build-time helper: encrypt a CodeType for L3 lazy materialization.
|
|
77
|
+
|
|
78
|
+
The build pass calls this once per L3-decorated function and embeds the
|
|
79
|
+
return value as ``_CIPHER_<funcname> = b"..."`` in the emitted module.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
code: The compiled CodeType of the function being protected. Must
|
|
83
|
+
be a CodeType, not a function or lambda; callers should pass
|
|
84
|
+
``func.__code__``.
|
|
85
|
+
key: 32-byte AES-256 key. The build pass either generates this
|
|
86
|
+
randomly per build (default) or derives it from a license /
|
|
87
|
+
device fingerprint (combination with P2-8 ``--bind-device``).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
``nonce(12 bytes) || ciphertext_with_tag`` -- a single bytes object
|
|
91
|
+
ready to be embedded as a module constant.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
TypeError: if ``code`` is not a CodeType.
|
|
95
|
+
ValueError: if ``key`` is not exactly 32 bytes.
|
|
96
|
+
|
|
97
|
+
Note: nonce is generated with ``secrets.token_bytes`` (CSPRNG) per the
|
|
98
|
+
workspace ENGINEERING_BASELINE Tier 2 rule. Each call produces a unique
|
|
99
|
+
blob even for the same code + same key.
|
|
100
|
+
"""
|
|
101
|
+
if not isinstance(code, types.CodeType):
|
|
102
|
+
raise TypeError(f"_encrypt_code expects a CodeType, got {type(code).__name__}")
|
|
103
|
+
if not isinstance(key, (bytes, bytearray)) or len(key) != _KEY_SIZE:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"key must be exactly {_KEY_SIZE} bytes, got "
|
|
106
|
+
f"{len(key) if isinstance(key, (bytes, bytearray)) else type(key).__name__}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
plaintext = marshal.dumps(code)
|
|
110
|
+
nonce = secrets.token_bytes(_NONCE_SIZE)
|
|
111
|
+
aesgcm = AESGCM(bytes(key))
|
|
112
|
+
ciphertext_with_tag = cast(bytes, aesgcm.encrypt(nonce, plaintext, associated_data=None))
|
|
113
|
+
return nonce + ciphertext_with_tag
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _decrypt_code(cipher: bytes | bytearray, key: bytes | bytearray) -> types.CodeType:
|
|
117
|
+
"""Reverse of ``_encrypt_code``: cipher blob + key -> CodeType.
|
|
118
|
+
|
|
119
|
+
All failure modes raise ``OpacityRuntimeError`` with a generic message
|
|
120
|
+
so an attacker probing with crafted ciphers cannot distinguish "wrong
|
|
121
|
+
key" from "tampered blob" from "corrupted marshal" via the exception
|
|
122
|
+
type or message text.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(key, (bytes, bytearray)) or len(key) != _KEY_SIZE:
|
|
125
|
+
raise OpacityRuntimeError("L3 decryption failed: invalid key size")
|
|
126
|
+
if not isinstance(cipher, (bytes, bytearray)) or len(cipher) < _MIN_CIPHER_LEN:
|
|
127
|
+
raise OpacityRuntimeError("L3 decryption failed: cipher blob too short")
|
|
128
|
+
|
|
129
|
+
nonce = bytes(cipher[:_NONCE_SIZE])
|
|
130
|
+
ciphertext_with_tag = bytes(cipher[_NONCE_SIZE:])
|
|
131
|
+
|
|
132
|
+
aesgcm = AESGCM(bytes(key))
|
|
133
|
+
try:
|
|
134
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext_with_tag, associated_data=None)
|
|
135
|
+
except InvalidTag as exc:
|
|
136
|
+
raise OpacityRuntimeError(
|
|
137
|
+
"L3 decryption failed (key mismatch or tampered ciphertext)"
|
|
138
|
+
) from exc
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# S302: marshal.loads on attacker-controlled bytes is dangerous.
|
|
142
|
+
# Here the bytes are NOT attacker-controlled: AESGCM.decrypt()
|
|
143
|
+
# above raises InvalidTag on any modification or wrong key, so
|
|
144
|
+
# `plaintext` is provably what we encrypted at build time. The
|
|
145
|
+
# marshal payload's integrity is bound to the GCM tag, not its
|
|
146
|
+
# own structure.
|
|
147
|
+
code = marshal.loads(plaintext) # noqa: S302
|
|
148
|
+
except Exception as exc: # noqa: BLE001 -- marshal raises ValueError/EOFError/TypeError
|
|
149
|
+
raise OpacityRuntimeError("L3 decryption failed: corrupted payload") from exc
|
|
150
|
+
|
|
151
|
+
if not isinstance(code, types.CodeType):
|
|
152
|
+
raise OpacityRuntimeError(
|
|
153
|
+
f"L3 decryption failed: payload is {type(code).__name__}, expected CodeType"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return code
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _l3_dispatch(cipher: bytes | bytearray, key: bytes | bytearray) -> Callable[[_F], _F]:
|
|
160
|
+
"""Wrap an L3 stub function with first-call decrypt + ``__code__`` patch.
|
|
161
|
+
|
|
162
|
+
The build pass emits::
|
|
163
|
+
|
|
164
|
+
_CIPHER_critical = b"..."
|
|
165
|
+
|
|
166
|
+
@_l3_dispatch(_CIPHER_critical, _LAYER_KEY)
|
|
167
|
+
def critical(x):
|
|
168
|
+
pass # discarded at first call
|
|
169
|
+
|
|
170
|
+
On the first call to ``critical``, the wrapper:
|
|
171
|
+
|
|
172
|
+
1. Calls ``_decrypt_code(cipher, key)`` -> CodeType
|
|
173
|
+
2. Sets ``stub.__code__ = decrypted_code`` so the stub's body is now
|
|
174
|
+
the protected function's body
|
|
175
|
+
3. Marks the closure-cell ``loaded`` flag True so subsequent calls
|
|
176
|
+
skip step 1-2
|
|
177
|
+
|
|
178
|
+
On subsequent calls, the wrapper does a single boolean check then
|
|
179
|
+
delegates to the patched stub.
|
|
180
|
+
|
|
181
|
+
The decrypted CodeType is held in a closure cell (not a function
|
|
182
|
+
attribute) so an attacker cannot pre-set
|
|
183
|
+
``critical._loaded = True`` to skip the integrity-bound decryption.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
cipher: ``nonce || ciphertext_with_tag`` produced by
|
|
187
|
+
``_encrypt_code`` at build time.
|
|
188
|
+
key: 32-byte AES-256 key matching the one used at build time.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A decorator that takes a stub function and returns the dispatch
|
|
192
|
+
wrapper.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def decorator(stub: _F) -> _F:
|
|
196
|
+
loaded = False
|
|
197
|
+
cached_code: types.CodeType | None = None # noqa: F841 -- closure-held debug handle
|
|
198
|
+
|
|
199
|
+
@functools.wraps(stub)
|
|
200
|
+
def wrapper(*args, **kwargs):
|
|
201
|
+
nonlocal loaded, cached_code
|
|
202
|
+
if not loaded:
|
|
203
|
+
cached_code = _decrypt_code(cipher, key)
|
|
204
|
+
stub.__code__ = cached_code
|
|
205
|
+
loaded = True
|
|
206
|
+
return stub(*args, **kwargs)
|
|
207
|
+
|
|
208
|
+
return wrapper # type: ignore[return-value]
|
|
209
|
+
|
|
210
|
+
return decorator
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Runtime platform-policy guard (P2-16).
|
|
2
|
+
|
|
3
|
+
Generalizes PyArmor BCC's platform-restriction mode in pure Python: refuse
|
|
4
|
+
to import an obfuscated module unless the running environment satisfies
|
|
5
|
+
build-time-baked constraints on OS, minimum Python version, and/or CPU
|
|
6
|
+
architecture (e.g. "this build is licensed for Linux production only").
|
|
7
|
+
|
|
8
|
+
Follows the same module-top-guard-call shape as :func:`expire_check` /
|
|
9
|
+
:func:`period_check` in :mod:`pyobfus_pro.license_binding` — a plain
|
|
10
|
+
function call the build orchestrator injects at the top of the emitted
|
|
11
|
+
module, raising a distinct error class on mismatch so the failure surfaces
|
|
12
|
+
in the normal exception path rather than as a cryptic import error.
|
|
13
|
+
|
|
14
|
+
Not part of the patent-gated P2-1/7/8/9/10/11 combination claims — this is
|
|
15
|
+
a standalone environment check with no cryptographic binding, so unlike
|
|
16
|
+
``--bind-device`` it does not affect ciphertext keys.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import platform
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RuntimePolicyError(RuntimeError):
|
|
27
|
+
"""Raised when the running environment violates a build-time runtime policy.
|
|
28
|
+
|
|
29
|
+
Distinct from :class:`pyobfus_pro.license_binding.LicenseExpired` — this
|
|
30
|
+
is an environment mismatch (wrong OS / Python version / architecture),
|
|
31
|
+
not a time- or count-based license expiry.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_python_min(python_min: str) -> Tuple[int, int]:
|
|
36
|
+
"""Parse an ``"X.Y"`` minimum-Python-version string into a comparable tuple."""
|
|
37
|
+
parts = python_min.split(".")
|
|
38
|
+
if len(parts) < 2 or not all(p.isdigit() for p in parts[:2]):
|
|
39
|
+
raise RuntimePolicyError(
|
|
40
|
+
f"requires_runtime: python_min must be 'X.Y' (e.g. '3.10'), got {python_min!r}"
|
|
41
|
+
)
|
|
42
|
+
return (int(parts[0]), int(parts[1]))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def requires_runtime(
|
|
46
|
+
os_allowed: Optional[Sequence[str]] = None,
|
|
47
|
+
python_min: Optional[str] = None,
|
|
48
|
+
arch_allowed: Optional[Sequence[str]] = None,
|
|
49
|
+
*,
|
|
50
|
+
current_os: Optional[str] = None,
|
|
51
|
+
current_python: Optional[Tuple[int, int]] = None,
|
|
52
|
+
current_arch: Optional[str] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Enforce build-time platform constraints at import time.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
os_allowed: Allowed :func:`platform.system` values (e.g.
|
|
58
|
+
``("Linux", "Darwin")``). Case-insensitive. ``None``/empty means
|
|
59
|
+
no OS restriction.
|
|
60
|
+
python_min: Minimum required Python version as ``"X.Y"`` (e.g.
|
|
61
|
+
``"3.10"``), compared against ``sys.version_info[:2]``. ``None``
|
|
62
|
+
means no minimum.
|
|
63
|
+
arch_allowed: Allowed :func:`platform.machine` values (e.g.
|
|
64
|
+
``("x86_64", "arm64")``). Case-insensitive. Common aliases are
|
|
65
|
+
NOT normalized (``"x86_64"`` on Linux vs ``"AMD64"`` on Windows
|
|
66
|
+
are different strings) — list every alias your target platforms
|
|
67
|
+
actually report.
|
|
68
|
+
current_os: Override for :func:`platform.system` — testing aid;
|
|
69
|
+
production calls leave this ``None`` and use the real value.
|
|
70
|
+
current_python: Override for ``sys.version_info[:2]`` — testing aid;
|
|
71
|
+
production calls leave this ``None``.
|
|
72
|
+
current_arch: Override for :func:`platform.machine` — testing aid;
|
|
73
|
+
production calls leave this ``None``.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
RuntimePolicyError: if any supplied constraint is violated, or if
|
|
77
|
+
``python_min`` is not a valid ``"X.Y"`` string.
|
|
78
|
+
"""
|
|
79
|
+
if os_allowed:
|
|
80
|
+
resolved_os = current_os if current_os is not None else platform.system()
|
|
81
|
+
allowed_lower = {o.lower() for o in os_allowed}
|
|
82
|
+
if resolved_os.lower() not in allowed_lower:
|
|
83
|
+
raise RuntimePolicyError(
|
|
84
|
+
f"This build requires one of {tuple(os_allowed)!r}, "
|
|
85
|
+
f"but the current OS is {resolved_os!r}."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if python_min:
|
|
89
|
+
min_version = _parse_python_min(python_min)
|
|
90
|
+
resolved_version = current_python if current_python is not None else sys.version_info[:2]
|
|
91
|
+
if resolved_version < min_version:
|
|
92
|
+
raise RuntimePolicyError(
|
|
93
|
+
f"This build requires Python >= {python_min}, "
|
|
94
|
+
f"but the running interpreter is "
|
|
95
|
+
f"{resolved_version[0]}.{resolved_version[1]}."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if arch_allowed:
|
|
99
|
+
resolved_arch = current_arch if current_arch is not None else platform.machine()
|
|
100
|
+
allowed_lower = {a.lower() for a in arch_allowed}
|
|
101
|
+
if resolved_arch.lower() not in allowed_lower:
|
|
102
|
+
raise RuntimePolicyError(
|
|
103
|
+
f"This build requires one of {tuple(arch_allowed)!r}, "
|
|
104
|
+
f"but the current architecture is {resolved_arch!r}."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = ["RuntimePolicyError", "requires_runtime"]
|
pyobfus_runtime/scrub.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Production-side traceback scrubbing for --scrub-traceback.
|
|
2
|
+
|
|
3
|
+
When installed as ``sys.excepthook``, an unhandled exception in production
|
|
4
|
+
code no longer prints the traceback to stderr. Instead, the traceback is
|
|
5
|
+
serialized, encrypted asymmetrically with a build-time-baked public key,
|
|
6
|
+
and emitted as an opaque error identifier of the form::
|
|
7
|
+
|
|
8
|
+
PYOBFUS-ERR:eyJhbGc...
|
|
9
|
+
|
|
10
|
+
Customers report this identifier; the developer who has the corresponding
|
|
11
|
+
private key reverses it back to the original traceback via
|
|
12
|
+
:func:`unscrub_error_id`.
|
|
13
|
+
|
|
14
|
+
This is the inverse of pyobfus Core's ``unmap`` flow (which deobfuscates a
|
|
15
|
+
customer-visible traceback). ``unmap`` assumes the customer sends a trace
|
|
16
|
+
that contains obfuscated names; ``scrub`` removes the assumption that the
|
|
17
|
+
customer sees a trace at all.
|
|
18
|
+
|
|
19
|
+
Encryption: hybrid asymmetric. RSA-2048-OAEP wraps a per-error random
|
|
20
|
+
AES-256-GCM key; the AES key encrypts the traceback payload. This bounds
|
|
21
|
+
RSA work to one short blob per error regardless of payload size, while the
|
|
22
|
+
asymmetric envelope ensures the customer cannot decrypt errors emitted on
|
|
23
|
+
their own machine.
|
|
24
|
+
|
|
25
|
+
Patent-gated. See PATENT_NOTES.md and docs/P2-10_DESIGN.md.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import os
|
|
32
|
+
import sys
|
|
33
|
+
import traceback as _traceback
|
|
34
|
+
from typing import cast
|
|
35
|
+
|
|
36
|
+
from cryptography.exceptions import InvalidTag
|
|
37
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
38
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
39
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
40
|
+
|
|
41
|
+
_NONCE_SIZE = 12 # AES-GCM standard
|
|
42
|
+
_AES_KEY_SIZE = 32 # 256-bit
|
|
43
|
+
_KEY_LENGTH_BYTES = 2 # length-prefix for the RSA-encrypted AES key (uint16)
|
|
44
|
+
_DEFAULT_PREFIX = "PYOBFUS-ERR"
|
|
45
|
+
_MIN_BLOB_LEN = _NONCE_SIZE + _KEY_LENGTH_BYTES
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ScrubError(RuntimeError):
|
|
49
|
+
"""Raised when scrubbing or unscrubbing fails (corrupt input, wrong key)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def generate_keypair(*, key_size: int = 2048) -> tuple[bytes, bytes]:
|
|
53
|
+
"""Generate a fresh RSA keypair for scrub/unscrub.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
key_size: RSA modulus size. 2048 for production (default); smaller
|
|
57
|
+
sizes acceptable only for testing.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
``(private_pem, public_pem)`` -- PEM-serialized PKCS#8 private key
|
|
61
|
+
and SubjectPublicKeyInfo public key. Bytes, not strings.
|
|
62
|
+
"""
|
|
63
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size)
|
|
64
|
+
private_pem = private_key.private_bytes(
|
|
65
|
+
encoding=serialization.Encoding.PEM,
|
|
66
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
67
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
68
|
+
)
|
|
69
|
+
public_pem = private_key.public_key().public_bytes(
|
|
70
|
+
encoding=serialization.Encoding.PEM,
|
|
71
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
72
|
+
)
|
|
73
|
+
return private_pem, public_pem
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _scrub_payload(payload: bytes, public_key_pem: bytes) -> str:
|
|
77
|
+
"""Hybrid-encrypt ``payload`` to a URL-safe base64 string.
|
|
78
|
+
|
|
79
|
+
Blob format inside the b64::
|
|
80
|
+
|
|
81
|
+
nonce(12) | enc_key_len(2 BE) | enc_key | ciphertext
|
|
82
|
+
"""
|
|
83
|
+
public_key = serialization.load_pem_public_key(public_key_pem)
|
|
84
|
+
if not isinstance(public_key, rsa.RSAPublicKey):
|
|
85
|
+
raise ScrubError("public_key_pem must be an RSA public key")
|
|
86
|
+
|
|
87
|
+
aes_key = os.urandom(_AES_KEY_SIZE)
|
|
88
|
+
nonce = os.urandom(_NONCE_SIZE)
|
|
89
|
+
|
|
90
|
+
aesgcm = AESGCM(aes_key)
|
|
91
|
+
ciphertext = aesgcm.encrypt(nonce, payload, associated_data=None)
|
|
92
|
+
|
|
93
|
+
enc_key = public_key.encrypt(
|
|
94
|
+
aes_key,
|
|
95
|
+
padding.OAEP(
|
|
96
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
97
|
+
algorithm=hashes.SHA256(),
|
|
98
|
+
label=None,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
blob = nonce + len(enc_key).to_bytes(_KEY_LENGTH_BYTES, "big") + enc_key + ciphertext
|
|
103
|
+
return base64.urlsafe_b64encode(blob).decode("ascii")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _unscrub_payload(error_id: str, private_key_pem: bytes) -> bytes:
|
|
107
|
+
"""Decrypt an error ID produced by :func:`_scrub_payload`."""
|
|
108
|
+
try:
|
|
109
|
+
blob = base64.urlsafe_b64decode(error_id.encode("ascii"))
|
|
110
|
+
except (ValueError, UnicodeEncodeError) as exc:
|
|
111
|
+
raise ScrubError(f"error_id is not valid base64: {exc}") from exc
|
|
112
|
+
|
|
113
|
+
if len(blob) < _MIN_BLOB_LEN:
|
|
114
|
+
raise ScrubError("error_id is too short to contain a valid blob")
|
|
115
|
+
|
|
116
|
+
nonce = blob[:_NONCE_SIZE]
|
|
117
|
+
enc_key_len = int.from_bytes(blob[_NONCE_SIZE : _NONCE_SIZE + _KEY_LENGTH_BYTES], "big")
|
|
118
|
+
enc_key_start = _NONCE_SIZE + _KEY_LENGTH_BYTES
|
|
119
|
+
enc_key_end = enc_key_start + enc_key_len
|
|
120
|
+
if enc_key_end > len(blob):
|
|
121
|
+
raise ScrubError("error_id length prefix overruns blob")
|
|
122
|
+
enc_key = blob[enc_key_start:enc_key_end]
|
|
123
|
+
ciphertext = blob[enc_key_end:]
|
|
124
|
+
|
|
125
|
+
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
|
|
126
|
+
if not isinstance(private_key, rsa.RSAPrivateKey):
|
|
127
|
+
raise ScrubError("private_key_pem must be an RSA private key")
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
aes_key = private_key.decrypt(
|
|
131
|
+
enc_key,
|
|
132
|
+
padding.OAEP(
|
|
133
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
134
|
+
algorithm=hashes.SHA256(),
|
|
135
|
+
label=None,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise ScrubError(f"unable to decrypt AES key (wrong private key?): {exc}") from exc
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return cast(bytes, AESGCM(aes_key).decrypt(nonce, ciphertext, associated_data=None))
|
|
143
|
+
except InvalidTag as exc:
|
|
144
|
+
raise ScrubError(f"unable to decrypt payload (corrupted blob): {exc}") from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def scrub_traceback_text(traceback_text: str, public_key_pem: bytes) -> str:
|
|
148
|
+
"""Encrypt a traceback string into an opaque base64 error ID (no prefix).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
traceback_text: The full traceback text to encrypt.
|
|
152
|
+
public_key_pem: The build-time-embedded RSA public key.
|
|
153
|
+
"""
|
|
154
|
+
return _scrub_payload(traceback_text.encode("utf-8"), public_key_pem)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def unscrub_error_id(
|
|
158
|
+
error_id: str,
|
|
159
|
+
private_key_pem: bytes,
|
|
160
|
+
*,
|
|
161
|
+
prefix: str | None = None,
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Decrypt an error ID back to the original traceback text.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
error_id: An ID emitted by :func:`install_scrub_excepthook`. Both
|
|
167
|
+
the bare base64 blob and the prefixed form
|
|
168
|
+
``"<prefix>:<blob>"`` are accepted.
|
|
169
|
+
private_key_pem: The private key matched to the public key used at
|
|
170
|
+
scrub time.
|
|
171
|
+
prefix: If specified, only IDs with this prefix are accepted; an ID
|
|
172
|
+
without ``:`` is rejected when ``prefix`` is non-None.
|
|
173
|
+
If None (default), any prefix is accepted and stripped, or a
|
|
174
|
+
bare blob is accepted as-is.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The original traceback text.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ScrubError: if the ID is malformed, the prefix doesn't match, or
|
|
181
|
+
decryption fails.
|
|
182
|
+
"""
|
|
183
|
+
if ":" in error_id:
|
|
184
|
+
observed_prefix, blob = error_id.split(":", 1)
|
|
185
|
+
if prefix is not None and observed_prefix != prefix:
|
|
186
|
+
raise ScrubError(f"prefix mismatch: expected {prefix!r}, got {observed_prefix!r}")
|
|
187
|
+
error_id = blob
|
|
188
|
+
elif prefix is not None:
|
|
189
|
+
raise ScrubError(f"error_id missing required prefix {prefix!r}:")
|
|
190
|
+
|
|
191
|
+
return _unscrub_payload(error_id, private_key_pem).decode("utf-8")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def install_scrub_excepthook(
|
|
195
|
+
public_key_pem: bytes,
|
|
196
|
+
*,
|
|
197
|
+
prefix: str = _DEFAULT_PREFIX,
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Replace ``sys.excepthook`` so unhandled exceptions print only an opaque ID.
|
|
200
|
+
|
|
201
|
+
The hook captures the unhandled exception's full traceback, hybrid-
|
|
202
|
+
encrypts it with the supplied public key, and writes
|
|
203
|
+
``"<prefix>:<base64>"`` to stderr. The customer never sees the original
|
|
204
|
+
traceback; the developer (with the matching private key) recovers it
|
|
205
|
+
via :func:`unscrub_error_id`.
|
|
206
|
+
|
|
207
|
+
On internal failure (e.g., malformed key, encryption error), the hook
|
|
208
|
+
emits ``"<prefix>:SCRUB_FAILED"`` rather than falling back to the
|
|
209
|
+
default traceback display -- falling back would leak the very stack
|
|
210
|
+
details we are protecting.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
public_key_pem: The build-time-embedded RSA public key.
|
|
214
|
+
prefix: Identifier prepended to the error ID. Customers reporting
|
|
215
|
+
the error see this prefix; it helps them identify which error
|
|
216
|
+
format they are looking at.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def hook(exc_type, exc_value, exc_traceback):
|
|
220
|
+
try:
|
|
221
|
+
tb_text = "".join(_traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
222
|
+
error_id = scrub_traceback_text(tb_text, public_key_pem)
|
|
223
|
+
print(f"{prefix}:{error_id}", file=sys.stderr)
|
|
224
|
+
except Exception: # noqa: BLE001 -- production hook must never re-raise
|
|
225
|
+
print(f"{prefix}:SCRUB_FAILED", file=sys.stderr)
|
|
226
|
+
|
|
227
|
+
sys.excepthook = hook
|