pyobfus-runtime 0.1.0__tar.gz
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-0.1.0/LICENSE +26 -0
- pyobfus_runtime-0.1.0/PKG-INFO +45 -0
- pyobfus_runtime-0.1.0/README.md +22 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/__init__.py +58 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/binding.py +435 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/embedded_data.py +96 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/opacity.py +210 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/policy.py +108 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/scrub.py +227 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/seal.py +205 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime/vault.py +243 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime.egg-info/PKG-INFO +45 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime.egg-info/SOURCES.txt +17 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime.egg-info/dependency_links.txt +1 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime.egg-info/requires.txt +1 -0
- pyobfus_runtime-0.1.0/pyobfus_runtime.egg-info/top_level.txt +1 -0
- pyobfus_runtime-0.1.0/pyproject.toml +37 -0
- pyobfus_runtime-0.1.0/setup.cfg +4 -0
- pyobfus_runtime-0.1.0/tests/test_runtime_contract.py +50 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
pyobfus Runtime Redistribution License, Version 1.0
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Rong Zhu. All rights reserved.
|
|
4
|
+
|
|
5
|
+
Permission is granted to install and use this runtime solely to execute
|
|
6
|
+
artifacts produced by a legitimately licensed copy or trial of pyobfus Pro.
|
|
7
|
+
|
|
8
|
+
A holder of a valid pyobfus Pro licence may redistribute copies of this
|
|
9
|
+
runtime, including its wheel or its files bundled with an artifact produced by
|
|
10
|
+
pyobfus Pro. The runtime must remain functionally unmodified, and this licence
|
|
11
|
+
notice must remain included. Recipients may use that copy solely to run such
|
|
12
|
+
artifacts.
|
|
13
|
+
|
|
14
|
+
Non-substantive transformations required solely for packaging or delivery,
|
|
15
|
+
including archiving, compression, byte-compilation, wheel vendoring, and
|
|
16
|
+
executable or application bundling, are permitted provided they do not alter
|
|
17
|
+
runtime behaviour or remove this licence notice.
|
|
18
|
+
|
|
19
|
+
You may not otherwise modify this runtime, use it as a component of or to
|
|
20
|
+
provide a build-time code-obfuscation or code-protection product or service,
|
|
21
|
+
sublicense it separately, or represent it as open-source software. The
|
|
22
|
+
pyobfus Pro build-time software and its source code are not covered by this
|
|
23
|
+
redistribution permission.
|
|
24
|
+
|
|
25
|
+
This software is provided "AS IS", without warranty of any kind. In no event
|
|
26
|
+
shall the copyright holder be liable for damages arising from its use.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyobfus-runtime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Redistributable runtime support for artifacts produced by pyobfus Pro.
|
|
5
|
+
Author: Rong Zhu
|
|
6
|
+
License-Expression: LicenseRef-pyobfus-Runtime-1.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/zhurong2020/pyobfus
|
|
8
|
+
Project-URL: Documentation, https://pyobfus.readthedocs.io/
|
|
9
|
+
Project-URL: Repository, https://github.com/zhurong2020/pyobfus
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: cryptography>=42.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# pyobfus-runtime
|
|
25
|
+
|
|
26
|
+
Minimal runtime support for artifacts built with pyobfus Pro. This package does
|
|
27
|
+
not contain the obfuscator, build-time transformers, payment code, or licence
|
|
28
|
+
verification client, and it does not require a pyobfus licence key at runtime.
|
|
29
|
+
|
|
30
|
+
After its first release, install it in the environment where a protected
|
|
31
|
+
artifact runs:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python -m pip install pyobfus-runtime
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Creating protected artifacts still requires a valid pyobfus Pro licence.
|
|
38
|
+
Until the first runtime release is published, install the wheel built from this
|
|
39
|
+
directory; do not release a builder that emits `pyobfus_runtime` imports first.
|
|
40
|
+
|
|
41
|
+
A valid pyobfus Pro licence permits redistribution of this runtime with an
|
|
42
|
+
artifact produced by Pro. Packaging-only transformations such as vendoring,
|
|
43
|
+
byte-compilation, or application bundling are permitted when runtime behaviour
|
|
44
|
+
and the included licence notice remain unchanged. See `LICENSE` for the full
|
|
45
|
+
terms.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# pyobfus-runtime
|
|
2
|
+
|
|
3
|
+
Minimal runtime support for artifacts built with pyobfus Pro. This package does
|
|
4
|
+
not contain the obfuscator, build-time transformers, payment code, or licence
|
|
5
|
+
verification client, and it does not require a pyobfus licence key at runtime.
|
|
6
|
+
|
|
7
|
+
After its first release, install it in the environment where a protected
|
|
8
|
+
artifact runs:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python -m pip install pyobfus-runtime
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Creating protected artifacts still requires a valid pyobfus Pro licence.
|
|
15
|
+
Until the first runtime release is published, install the wheel built from this
|
|
16
|
+
directory; do not release a builder that emits `pyobfus_runtime` imports first.
|
|
17
|
+
|
|
18
|
+
A valid pyobfus Pro licence permits redistribution of this runtime with an
|
|
19
|
+
artifact produced by Pro. Packaging-only transformations such as vendoring,
|
|
20
|
+
byte-compilation, or application bundling are permitted when runtime behaviour
|
|
21
|
+
and the included licence notice remain unchanged. See `LICENSE` for the full
|
|
22
|
+
terms.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Stable, redistributable runtime API for pyobfus-generated artifacts."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .binding import (
|
|
6
|
+
LicenseBindingError,
|
|
7
|
+
LicenseExpired,
|
|
8
|
+
bind_device_key,
|
|
9
|
+
current_machine_id,
|
|
10
|
+
default_counter_path,
|
|
11
|
+
expire_check,
|
|
12
|
+
period_check,
|
|
13
|
+
)
|
|
14
|
+
from .embedded_data import EmbeddedDataError, encrypt_data_file, get_embedded_data
|
|
15
|
+
from .opacity import OpacityRuntimeError, _encrypt_code, _l3_dispatch
|
|
16
|
+
from .policy import RuntimePolicyError, requires_runtime
|
|
17
|
+
from .scrub import (
|
|
18
|
+
ScrubError,
|
|
19
|
+
generate_keypair,
|
|
20
|
+
install_scrub_excepthook,
|
|
21
|
+
scrub_traceback_text,
|
|
22
|
+
unscrub_error_id,
|
|
23
|
+
)
|
|
24
|
+
from .seal import IntegrityError, _verify_seal
|
|
25
|
+
from .vault import Vault, VaultError, vault_secrets
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
__version__ = version("pyobfus-runtime")
|
|
29
|
+
except PackageNotFoundError:
|
|
30
|
+
__version__ = "0.1.0.dev0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"EmbeddedDataError",
|
|
34
|
+
"IntegrityError",
|
|
35
|
+
"LicenseBindingError",
|
|
36
|
+
"LicenseExpired",
|
|
37
|
+
"OpacityRuntimeError",
|
|
38
|
+
"RuntimePolicyError",
|
|
39
|
+
"ScrubError",
|
|
40
|
+
"Vault",
|
|
41
|
+
"VaultError",
|
|
42
|
+
"_encrypt_code",
|
|
43
|
+
"_l3_dispatch",
|
|
44
|
+
"_verify_seal",
|
|
45
|
+
"bind_device_key",
|
|
46
|
+
"current_machine_id",
|
|
47
|
+
"default_counter_path",
|
|
48
|
+
"encrypt_data_file",
|
|
49
|
+
"expire_check",
|
|
50
|
+
"generate_keypair",
|
|
51
|
+
"get_embedded_data",
|
|
52
|
+
"install_scrub_excepthook",
|
|
53
|
+
"period_check",
|
|
54
|
+
"requires_runtime",
|
|
55
|
+
"scrub_traceback_text",
|
|
56
|
+
"unscrub_error_id",
|
|
57
|
+
"vault_secrets",
|
|
58
|
+
]
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""License binding combo runtime: device / expire / period.
|
|
2
|
+
|
|
3
|
+
Three primitives, intentionally orthogonal so they can be used standalone
|
|
4
|
+
or composed.
|
|
5
|
+
|
|
6
|
+
**Device binding** — :func:`current_machine_id` + :func:`bind_device_key`:
|
|
7
|
+
A stable per-machine identifier (DMI ``/etc/machine-id`` on Linux,
|
|
8
|
+
``IOPlatformUUID`` on macOS, ``MachineGuid`` registry value on Windows,
|
|
9
|
+
deterministic fallback to ``sha256(hostname || username)`` everywhere)
|
|
10
|
+
fed through PBKDF2-HMAC-SHA256 with a build-time random salt yields a
|
|
11
|
+
32-byte AES-256 key. The build orchestrator emits this derivation as a
|
|
12
|
+
runtime statement that *replaces* the baked ``_LAYER_KEY = b"..."``
|
|
13
|
+
constant in P2-1 / ``_VAULT_KEY_<name>`` in P2-11. **Wrong device →
|
|
14
|
+
wrong derived key → GCM tag mismatch → decrypt fails → license gate
|
|
15
|
+
enforced inside the existing decryption path with no per-call check**.
|
|
16
|
+
|
|
17
|
+
**Calendar expiry** — :func:`expire_check`: reads an ISO-format date,
|
|
18
|
+
compares to ``date.today()``, raises :class:`LicenseExpired` if past.
|
|
19
|
+
Build orchestrator emits ``expire_check("2027-01-01")`` at module top.
|
|
20
|
+
|
|
21
|
+
**Run-counter expiry** — :func:`period_check`: atomic-rename file
|
|
22
|
+
counter incremented on each call; raises :class:`LicenseExpired` when
|
|
23
|
+
the counter exceeds ``max_runs``. The counter file lives at a path the
|
|
24
|
+
build orchestrator chooses (typically under ``~/.cache/pyobfus/<artifact-id>/runs``
|
|
25
|
+
or similar); its existence + contents are the user-visible runtime state.
|
|
26
|
+
|
|
27
|
+
The combination claim shape (P2-8 main inventive step): the layer key
|
|
28
|
+
is derived at runtime via :func:`bind_device_key` AND the calendar /
|
|
29
|
+
counter checks happen at module import. **The license check is
|
|
30
|
+
distributed across the cryptographic decryption path AND the explicit
|
|
31
|
+
guard calls** — neither single mechanism is the license; the combination
|
|
32
|
+
is. An attacker patching out one mechanism still hits the other
|
|
33
|
+
(plaintext code patches the calendar check away; cipher-cracking can't
|
|
34
|
+
patch out the device-key derivation without also having the device).
|
|
35
|
+
|
|
36
|
+
Patent-gated. See PATENT_NOTES.md and docs/P2-8_DESIGN.md.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import datetime as _datetime
|
|
42
|
+
import hashlib
|
|
43
|
+
import os
|
|
44
|
+
import platform
|
|
45
|
+
import shutil
|
|
46
|
+
from typing import cast
|
|
47
|
+
import tempfile
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
from cryptography.hazmat.primitives import hashes
|
|
51
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
52
|
+
|
|
53
|
+
_PBKDF2_DEFAULT_ITERATIONS = 200_000 # OWASP 2024 recommendation lower bound
|
|
54
|
+
_MIN_ITERATIONS = 100_000 # absolute floor
|
|
55
|
+
_MIN_SALT_BYTES = 16 # NIST SP 800-132 recommends 128-bit salts for password-derived keys
|
|
56
|
+
_DERIVED_KEY_SIZE = 32 # AES-256
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LicenseBindingError(RuntimeError):
|
|
60
|
+
"""Raised on invalid license-binding inputs (bad salt, bad iterations, etc.).
|
|
61
|
+
|
|
62
|
+
Distinct from :class:`LicenseExpired` so callers can distinguish "the
|
|
63
|
+
license check infrastructure is broken" from "the license has expired".
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LicenseExpired(RuntimeError):
|
|
68
|
+
"""Raised when a calendar / counter / device check determines the license
|
|
69
|
+
has expired or does not apply to the current environment.
|
|
70
|
+
|
|
71
|
+
Caught by the build orchestrator's emitted module-top guard call to
|
|
72
|
+
abort module import cleanly. Surfaces in the application's normal
|
|
73
|
+
exception path so customers see a meaningful error rather than a
|
|
74
|
+
cryptic decryption failure.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Device binding
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def current_machine_id() -> str:
|
|
84
|
+
"""Return a stable per-machine identifier as a UTF-8 string.
|
|
85
|
+
|
|
86
|
+
Source priority:
|
|
87
|
+
|
|
88
|
+
1. **Linux / FreeBSD**: ``/etc/machine-id`` (systemd) or
|
|
89
|
+
``/var/lib/dbus/machine-id`` (older systems). 32-character hex.
|
|
90
|
+
2. **macOS**: ``ioreg`` query for ``IOPlatformUUID``. UUID format.
|
|
91
|
+
3. **Windows**: ``MachineGuid`` value under
|
|
92
|
+
``HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography``.
|
|
93
|
+
4. **Fallback** (any platform; container-portable):
|
|
94
|
+
``sha256("pyobfus-machine-id-fallback-v1" || hostname || ":" || username).hexdigest()``.
|
|
95
|
+
Stable per-(host,user) but derivable, so weaker against attackers
|
|
96
|
+
who can spoof those values. Documented as a fallback and not the
|
|
97
|
+
preferred path.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A UTF-8 string. Length / format depends on the source path; opaque
|
|
101
|
+
to callers (only fed back into :func:`bind_device_key`).
|
|
102
|
+
|
|
103
|
+
Notes:
|
|
104
|
+
Caching is NOT applied — each call re-reads the source. This is
|
|
105
|
+
deliberate: machine_id is a read-once-per-process value the
|
|
106
|
+
orchestrator-emitted code captures into the AESGCM cipher object
|
|
107
|
+
at module import, so per-call cost doesn't matter and we'd rather
|
|
108
|
+
not hold OS-specific cached state across forks.
|
|
109
|
+
"""
|
|
110
|
+
system = platform.system()
|
|
111
|
+
|
|
112
|
+
if system in ("Linux", "FreeBSD"):
|
|
113
|
+
for path in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
|
|
114
|
+
try:
|
|
115
|
+
content = Path(path).read_text(encoding="ascii").strip()
|
|
116
|
+
except (OSError, UnicodeDecodeError):
|
|
117
|
+
continue
|
|
118
|
+
if content:
|
|
119
|
+
return content
|
|
120
|
+
|
|
121
|
+
elif system == "Darwin":
|
|
122
|
+
ioreg = shutil.which("ioreg")
|
|
123
|
+
if ioreg:
|
|
124
|
+
try:
|
|
125
|
+
import subprocess # noqa: S404 -- ioreg is a system tool
|
|
126
|
+
|
|
127
|
+
# S603: argv is fully-controlled list of constants + the
|
|
128
|
+
# absolute path to ioreg from `shutil.which`; no shell, no
|
|
129
|
+
# untrusted input. ioreg is a macOS system binary.
|
|
130
|
+
result = subprocess.run( # noqa: S603
|
|
131
|
+
[ioreg, "-rd1", "-c", "IOPlatformExpertDevice"],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=5,
|
|
135
|
+
check=False,
|
|
136
|
+
)
|
|
137
|
+
for line in result.stdout.splitlines():
|
|
138
|
+
if "IOPlatformUUID" in line:
|
|
139
|
+
# Format: ' "IOPlatformUUID" = "11111111-..."'
|
|
140
|
+
parts = line.split('"')
|
|
141
|
+
if len(parts) >= 4:
|
|
142
|
+
return parts[-2]
|
|
143
|
+
except (OSError, subprocess.SubprocessError):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
elif system == "Windows":
|
|
147
|
+
try:
|
|
148
|
+
import winreg # type: ignore[import-not-found,unused-ignore]
|
|
149
|
+
|
|
150
|
+
with winreg.OpenKey( # type: ignore[attr-defined]
|
|
151
|
+
winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined]
|
|
152
|
+
r"SOFTWARE\Microsoft\Cryptography",
|
|
153
|
+
) as k:
|
|
154
|
+
value, _ = winreg.QueryValueEx(k, "MachineGuid") # type: ignore[attr-defined]
|
|
155
|
+
if value:
|
|
156
|
+
return str(value)
|
|
157
|
+
except (OSError, ImportError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Fallback path: deterministic but weak. UTF-8 string suitable for
|
|
161
|
+
# PBKDF2 input; documented limitation.
|
|
162
|
+
hostname = platform.node() or "unknown-host"
|
|
163
|
+
username = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown-user"
|
|
164
|
+
h = hashlib.sha256()
|
|
165
|
+
h.update(b"pyobfus-machine-id-fallback-v1")
|
|
166
|
+
h.update(b"\x00")
|
|
167
|
+
h.update(hostname.encode("utf-8"))
|
|
168
|
+
h.update(b":")
|
|
169
|
+
h.update(username.encode("utf-8"))
|
|
170
|
+
return h.hexdigest()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def bind_device_key(
|
|
174
|
+
machine_id: str,
|
|
175
|
+
build_salt: bytes,
|
|
176
|
+
*,
|
|
177
|
+
iterations: int = _PBKDF2_DEFAULT_ITERATIONS,
|
|
178
|
+
) -> bytes:
|
|
179
|
+
"""Derive a 32-byte AES-256 key from a machine ID + build-time salt.
|
|
180
|
+
|
|
181
|
+
PBKDF2-HMAC-SHA256 over UTF-8 ``machine_id`` with the supplied
|
|
182
|
+
``build_salt`` and ``iterations``. The build orchestrator generates
|
|
183
|
+
a fresh random ``build_salt`` per build (32 bytes minimum
|
|
184
|
+
recommended), embeds it as a module-level constant in the emitted
|
|
185
|
+
artifact, and emits a runtime call to this function whose result
|
|
186
|
+
replaces the baked ``_LAYER_KEY`` / ``_VAULT_KEY_<name>`` constants.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
machine_id: Output of :func:`current_machine_id` (or any stable
|
|
190
|
+
UTF-8 string identifying the target device).
|
|
191
|
+
build_salt: At least :data:`_MIN_SALT_BYTES` (16) bytes of random
|
|
192
|
+
data generated at build time. Persisted in the artifact.
|
|
193
|
+
Different artifacts must use different salts.
|
|
194
|
+
iterations: PBKDF2 iteration count. Default
|
|
195
|
+
:data:`_PBKDF2_DEFAULT_ITERATIONS` (200,000) per OWASP 2024.
|
|
196
|
+
Minimum :data:`_MIN_ITERATIONS` (100,000); raise as hardware
|
|
197
|
+
speeds increase.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
32-byte AES-256 key.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
LicenseBindingError: on invalid machine_id type, salt too short,
|
|
204
|
+
or iterations below the floor.
|
|
205
|
+
|
|
206
|
+
**Patent-relevant**: the derived key is byte-identical iff the device
|
|
207
|
+
matches AND the salt matches. An attacker on the wrong device
|
|
208
|
+
derives a different key and AES-GCM tag verification fails on every
|
|
209
|
+
L3 cipher and Vault entry — the license check is the *AES-GCM tag
|
|
210
|
+
check itself*. No separate license-check call to patch out.
|
|
211
|
+
"""
|
|
212
|
+
if not isinstance(machine_id, str):
|
|
213
|
+
raise LicenseBindingError(f"machine_id must be a string, got {type(machine_id).__name__}")
|
|
214
|
+
if not machine_id:
|
|
215
|
+
raise LicenseBindingError("machine_id must be non-empty")
|
|
216
|
+
if not isinstance(build_salt, (bytes, bytearray)) or len(build_salt) < _MIN_SALT_BYTES:
|
|
217
|
+
raise LicenseBindingError(
|
|
218
|
+
f"build_salt must be at least {_MIN_SALT_BYTES} bytes, got "
|
|
219
|
+
f"{len(build_salt) if isinstance(build_salt, (bytes, bytearray)) else type(build_salt).__name__}"
|
|
220
|
+
)
|
|
221
|
+
if not isinstance(iterations, int) or iterations < _MIN_ITERATIONS:
|
|
222
|
+
raise LicenseBindingError(
|
|
223
|
+
f"iterations must be int >= {_MIN_ITERATIONS}, got {iterations!r}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
kdf = PBKDF2HMAC(
|
|
227
|
+
algorithm=hashes.SHA256(),
|
|
228
|
+
length=_DERIVED_KEY_SIZE,
|
|
229
|
+
salt=bytes(build_salt),
|
|
230
|
+
iterations=iterations,
|
|
231
|
+
)
|
|
232
|
+
return cast(bytes, kdf.derive(machine_id.encode("utf-8")))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Calendar expiry
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def expire_check(expire_iso: str, *, now: _datetime.date | None = None) -> None:
|
|
241
|
+
"""Raise :class:`LicenseExpired` if the current date is past ``expire_iso``.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
expire_iso: ISO-8601 calendar date string (``"YYYY-MM-DD"``).
|
|
245
|
+
Parsed via :meth:`datetime.date.fromisoformat`.
|
|
246
|
+
now: Override for the "current date" — testing aid; production
|
|
247
|
+
calls leave this ``None`` and use :meth:`datetime.date.today`.
|
|
248
|
+
|
|
249
|
+
The check is *date-only* (not datetime); a user on UTC+8 and a user
|
|
250
|
+
on UTC-8 see the same expiry behavior on the boundary day. For
|
|
251
|
+
sub-day precision a future API addition would take a datetime + tz.
|
|
252
|
+
|
|
253
|
+
**Pattern**: build orchestrator emits ``expire_check("2027-01-01")``
|
|
254
|
+
at module top. If expired, module import fails with
|
|
255
|
+
:class:`LicenseExpired` rather than producing a partially-loaded
|
|
256
|
+
module that crashes mysteriously elsewhere.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
LicenseBindingError: on malformed ``expire_iso``.
|
|
260
|
+
LicenseExpired: when ``now > expire_date``.
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
expire_date = _datetime.date.fromisoformat(expire_iso)
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise LicenseBindingError(
|
|
266
|
+
f"expire_iso must be ISO-8601 calendar date 'YYYY-MM-DD'; got {expire_iso!r}: {exc}"
|
|
267
|
+
) from exc
|
|
268
|
+
|
|
269
|
+
today = now if now is not None else _datetime.date.today()
|
|
270
|
+
if not isinstance(today, _datetime.date):
|
|
271
|
+
raise LicenseBindingError(f"now must be a datetime.date, got {type(today).__name__}")
|
|
272
|
+
|
|
273
|
+
if today > expire_date:
|
|
274
|
+
raise LicenseExpired(
|
|
275
|
+
f"license expired on {expire_date.isoformat()}; today is {today.isoformat()}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Run-counter expiry
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def period_check(
|
|
285
|
+
counter_path: str | os.PathLike,
|
|
286
|
+
max_runs: int,
|
|
287
|
+
*,
|
|
288
|
+
advance: bool = True,
|
|
289
|
+
) -> int:
|
|
290
|
+
"""Atomic file counter; raise :class:`LicenseExpired` when ``> max_runs``.
|
|
291
|
+
|
|
292
|
+
Reads the existing run count from ``counter_path`` (creates with 0 if
|
|
293
|
+
absent), and if ``advance`` is True (default), atomically increments
|
|
294
|
+
+ persists the new value via tempfile + rename. Returns the
|
|
295
|
+
*post-increment* counter value (or, when ``advance=False``, the
|
|
296
|
+
current value).
|
|
297
|
+
|
|
298
|
+
The atomic rename pattern survives crashes mid-update: either the
|
|
299
|
+
old value is intact or the new value is fully written. There is no
|
|
300
|
+
partial-write window where the counter could be corrupted.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
counter_path: Filesystem path to the counter file. Build
|
|
304
|
+
orchestrator chooses (typically under
|
|
305
|
+
``~/.cache/pyobfus/<artifact-id>/runs``).
|
|
306
|
+
max_runs: Inclusive upper bound. ``period_check(p, 100)`` allows
|
|
307
|
+
run #1 through #100 and raises on the 101st.
|
|
308
|
+
advance: When True (default), increment + persist. When False,
|
|
309
|
+
read-only check (useful for diagnostics or testing without
|
|
310
|
+
consuming a run).
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Post-increment counter value (or current value when ``advance=False``).
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
LicenseBindingError: on invalid ``max_runs`` or unreadable /
|
|
317
|
+
unwritable ``counter_path`` parent dir, or on corrupted
|
|
318
|
+
counter file (non-integer content).
|
|
319
|
+
LicenseExpired: when post-(read or increment) counter > max_runs.
|
|
320
|
+
|
|
321
|
+
**Threat model**: the counter file is on a filesystem the user owns;
|
|
322
|
+
they can manually reset it to 0 at any time. That's a *known
|
|
323
|
+
limitation* of the period-check axis used standalone. The
|
|
324
|
+
*combination* with ``--bind-device`` makes counter reset useless on a
|
|
325
|
+
different machine (the AES key is wrong); the combination with
|
|
326
|
+
``--expire`` makes counter reset useless past the expire date. The
|
|
327
|
+
patent claim is the *combination*, not period-check in isolation.
|
|
328
|
+
"""
|
|
329
|
+
if not isinstance(max_runs, int) or max_runs < 1:
|
|
330
|
+
raise LicenseBindingError(f"max_runs must be int >= 1, got {max_runs!r}")
|
|
331
|
+
counter_path = Path(counter_path)
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
counter_path.parent.mkdir(parents=True, exist_ok=True)
|
|
335
|
+
except OSError as exc:
|
|
336
|
+
raise LicenseBindingError(
|
|
337
|
+
f"unable to create counter directory {counter_path.parent}: {exc}"
|
|
338
|
+
) from exc
|
|
339
|
+
|
|
340
|
+
current = 0
|
|
341
|
+
if counter_path.exists():
|
|
342
|
+
try:
|
|
343
|
+
text = counter_path.read_text(encoding="ascii").strip()
|
|
344
|
+
except OSError as exc:
|
|
345
|
+
raise LicenseBindingError(f"unable to read counter file {counter_path}: {exc}") from exc
|
|
346
|
+
if text:
|
|
347
|
+
try:
|
|
348
|
+
current = int(text)
|
|
349
|
+
except ValueError as exc:
|
|
350
|
+
raise LicenseBindingError(
|
|
351
|
+
f"counter file {counter_path} is corrupted (not an int): {text!r}"
|
|
352
|
+
) from exc
|
|
353
|
+
if current < 0:
|
|
354
|
+
raise LicenseBindingError(
|
|
355
|
+
f"counter file {counter_path} contains negative value: {current}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
new_value = current + 1 if advance else current
|
|
359
|
+
|
|
360
|
+
if advance:
|
|
361
|
+
# Atomic write via tempfile + rename. NamedTemporaryFile is in the
|
|
362
|
+
# same directory so rename is guaranteed atomic on POSIX (same
|
|
363
|
+
# filesystem). delete=False because we move it explicitly; we
|
|
364
|
+
# also clean up on failure.
|
|
365
|
+
tmp_fd, tmp_path = tempfile.mkstemp(
|
|
366
|
+
dir=str(counter_path.parent), prefix=".counter.", suffix=".tmp"
|
|
367
|
+
)
|
|
368
|
+
try:
|
|
369
|
+
with os.fdopen(tmp_fd, "w", encoding="ascii") as f:
|
|
370
|
+
f.write(str(new_value))
|
|
371
|
+
f.flush()
|
|
372
|
+
os.fsync(f.fileno())
|
|
373
|
+
os.replace(tmp_path, counter_path)
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
try:
|
|
376
|
+
os.unlink(tmp_path)
|
|
377
|
+
except OSError:
|
|
378
|
+
pass
|
|
379
|
+
raise LicenseBindingError(
|
|
380
|
+
f"unable to persist counter increment to {counter_path}: {exc}"
|
|
381
|
+
) from exc
|
|
382
|
+
|
|
383
|
+
if new_value > max_runs:
|
|
384
|
+
raise LicenseExpired(
|
|
385
|
+
f"license run-counter exceeded: ran {new_value} times, max allowed is {max_runs}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return new_value
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
_COUNTER_ENV_VAR = "PYOBFUS_COUNTER_DIR"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def default_counter_path(artifact_id: str) -> Path:
|
|
395
|
+
"""Resolve the run-counter file path for an artifact, at *runtime*.
|
|
396
|
+
|
|
397
|
+
The build orchestrator emits ``period_check(default_counter_path("<id>"), N)``
|
|
398
|
+
at module top so the counter path is resolved on the **end-user's** machine
|
|
399
|
+
at import time, never baked in at build time (a build-time absolute path
|
|
400
|
+
would point at the *builder's* home). Honors ``$PYOBFUS_COUNTER_DIR`` (for a
|
|
401
|
+
read-only ``$HOME`` or to relocate runtime state); otherwise defaults to
|
|
402
|
+
``~/.cache/pyobfus/<artifact_id>/runs``.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
artifact_id: Stable per-artifact identifier (the orchestrator uses a
|
|
406
|
+
truncated sha256 of the module qualname). Sanitized to a single
|
|
407
|
+
``[A-Za-z0-9_]`` path segment before use, so a crafted value cannot
|
|
408
|
+
traverse out of the counter root.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
The ``Path`` to the counter file (its parent is created lazily by
|
|
412
|
+
:func:`period_check`).
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
LicenseBindingError: if ``artifact_id`` is not a non-empty string.
|
|
416
|
+
"""
|
|
417
|
+
if not isinstance(artifact_id, str) or not artifact_id:
|
|
418
|
+
raise LicenseBindingError("artifact_id must be a non-empty string")
|
|
419
|
+
# Reduce to a single safe path segment (alnum or underscore). This alone
|
|
420
|
+
# neutralizes '..' and path separators — no traversal reaches the FS.
|
|
421
|
+
safe = "".join(c if c.isalnum() else "_" for c in artifact_id)
|
|
422
|
+
base = os.environ.get(_COUNTER_ENV_VAR)
|
|
423
|
+
root = Path(base) if base else Path.home() / ".cache" / "pyobfus"
|
|
424
|
+
return root / safe / "runs"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
__all__ = [
|
|
428
|
+
"LicenseBindingError",
|
|
429
|
+
"LicenseExpired",
|
|
430
|
+
"bind_device_key",
|
|
431
|
+
"current_machine_id",
|
|
432
|
+
"default_counter_path",
|
|
433
|
+
"expire_check",
|
|
434
|
+
"period_check",
|
|
435
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Runtime accessor for ``--embed-data`` (P2-14): AES-256-GCM encrypted resource files.
|
|
2
|
+
|
|
3
|
+
Closes the Nuitka Commercial "Protect Data Files" / PyArmor ``--bind-data``
|
|
4
|
+
gap: at build time, the orchestrator reads a resource file's raw bytes,
|
|
5
|
+
encrypts them with AES-256-GCM (same construction as the Runtime String
|
|
6
|
+
Vault -- 12-byte random nonce, 32-byte key, GCM authentication tag), and
|
|
7
|
+
emits the result base85-encoded as a module-level string constant plus a
|
|
8
|
+
generated ``get_embedded_data()`` accessor that reverses the process at
|
|
9
|
+
call time. Decryption is on-demand, not at import.
|
|
10
|
+
|
|
11
|
+
Not part of the patent-gated P2-1/7/8/9/10/11 combination claims -- reuses
|
|
12
|
+
the same AES-256-GCM primitive as the Runtime String Vault (P2-11) but for
|
|
13
|
+
an arbitrary binary blob rather than a named string KV store.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from cryptography.exceptions import InvalidTag
|
|
22
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
|
+
|
|
24
|
+
_NONCE_SIZE = 12 # AES-GCM standard
|
|
25
|
+
_KEY_SIZE = 32 # 256-bit AES key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EmbeddedDataError(RuntimeError):
|
|
29
|
+
"""Raised when embedded-data encryption/decryption fails.
|
|
30
|
+
|
|
31
|
+
Covers a wrong-size key, malformed base85, or a tampered/wrong-key
|
|
32
|
+
ciphertext (GCM tag mismatch).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encrypt_data_file(data: bytes, key: bytes) -> bytes:
|
|
37
|
+
"""Encrypt raw bytes with AES-256-GCM. Returns ``nonce || ciphertext_with_tag``.
|
|
38
|
+
|
|
39
|
+
Called by the build orchestrator (``--embed-data``) at build time; not
|
|
40
|
+
typically called directly by end users of an obfuscated artifact.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
data: The raw file bytes to encrypt.
|
|
44
|
+
key: A 32-byte AES key (fresh random per build, generated by the
|
|
45
|
+
orchestrator via ``secrets.token_bytes(32)``).
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
EmbeddedDataError: if ``key`` is not exactly 32 bytes.
|
|
49
|
+
"""
|
|
50
|
+
if len(key) != _KEY_SIZE:
|
|
51
|
+
raise EmbeddedDataError(f"key must be {_KEY_SIZE} bytes; got {len(key)}")
|
|
52
|
+
nonce = os.urandom(_NONCE_SIZE)
|
|
53
|
+
aesgcm = AESGCM(key)
|
|
54
|
+
ciphertext = aesgcm.encrypt(nonce, data, associated_data=None)
|
|
55
|
+
return bytes(nonce + ciphertext)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_embedded_data(blob_b85: str, key: bytes) -> bytes:
|
|
59
|
+
"""Decode + decrypt a base85-encoded AES-256-GCM blob emitted by ``--embed-data``.
|
|
60
|
+
|
|
61
|
+
This is the function the build orchestrator's generated
|
|
62
|
+
``get_embedded_data()`` module-level wrapper calls at runtime.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
blob_b85: base85-encoded ``nonce || ciphertext_with_tag``, as
|
|
66
|
+
produced by :func:`encrypt_data_file` then
|
|
67
|
+
``base64.b85encode(...).decode("ascii")``.
|
|
68
|
+
key: The same 32-byte AES key used to encrypt.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The original file bytes.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
EmbeddedDataError: on a wrong-size key, malformed base85, a blob
|
|
75
|
+
too short to contain a valid nonce, or a GCM tag mismatch
|
|
76
|
+
(wrong key or tampered ciphertext).
|
|
77
|
+
"""
|
|
78
|
+
if len(key) != _KEY_SIZE:
|
|
79
|
+
raise EmbeddedDataError(f"key must be {_KEY_SIZE} bytes; got {len(key)}")
|
|
80
|
+
try:
|
|
81
|
+
blob = base64.b85decode(blob_b85)
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
raise EmbeddedDataError(f"blob is not valid base85: {exc}") from exc
|
|
84
|
+
if len(blob) < _NONCE_SIZE:
|
|
85
|
+
raise EmbeddedDataError("blob is too short to contain a valid nonce")
|
|
86
|
+
nonce, ciphertext = blob[:_NONCE_SIZE], blob[_NONCE_SIZE:]
|
|
87
|
+
aesgcm = AESGCM(key)
|
|
88
|
+
try:
|
|
89
|
+
return bytes(aesgcm.decrypt(nonce, ciphertext, associated_data=None))
|
|
90
|
+
except InvalidTag as exc:
|
|
91
|
+
raise EmbeddedDataError(
|
|
92
|
+
"unable to decrypt embedded data (wrong key or tampered blob)"
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["EmbeddedDataError", "encrypt_data_file", "get_embedded_data"]
|