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.
@@ -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"]
@@ -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