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,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"]