nkx-runtime 2.2.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.

Potentially problematic release.


This version of nkx-runtime might be problematic. Click here for more details.

@@ -0,0 +1,41 @@
1
+ """Nakodax customer runtime SDK."""
2
+
3
+ from .client import RuntimeClient
4
+ from .config import RuntimeConfig
5
+ from .errors import (
6
+ ConfigurationError,
7
+ KekDeniedError,
8
+ KekUnreachableError,
9
+ NkxError,
10
+ RegistrationDeniedError,
11
+ )
12
+
13
+ __all__ = [
14
+ "RuntimeClient",
15
+ "RuntimeConfig",
16
+ "NkxError",
17
+ "ConfigurationError",
18
+ "KekDeniedError",
19
+ "KekUnreachableError",
20
+ "RegistrationDeniedError",
21
+ "__version__",
22
+ ]
23
+
24
+
25
+ def _installed_version() -> str:
26
+ """The installed distribution's version, not a constant.
27
+
28
+ The hardcoded string sat at 1.0.0 while the package shipped 2.1.1, because
29
+ the CI version gate compares the tag against pyproject.toml and
30
+ package.json only and never looked here (PKG-08). Reading the installed
31
+ distribution means there is no second place to update, and a repacked
32
+ wheel reports honestly. Mirrors cli.py's _version().
33
+ """
34
+ try:
35
+ from importlib.metadata import version
36
+ return version("nkx-runtime")
37
+ except Exception:
38
+ return "unknown"
39
+
40
+
41
+ __version__ = _installed_version()
@@ -0,0 +1 @@
1
+ """Synced runtime engine (private); do not edit — run make sync-sdk-python-runtime."""
@@ -0,0 +1,628 @@
1
+ """Runtime context collection and HKDF info assembly for multi-factor KEK binding.
2
+
3
+ Each "source" is a named collector that returns a stable string for the current
4
+ environment. The assembled info bytes are fed into HKDF as the non-secret but
5
+ binding `info` parameter — a different environment produces a different KEK, so
6
+ a copied artifact cannot be decrypted outside the environment it was bound to.
7
+
8
+ Available sources
9
+ -----------------
10
+ machine_id Per-machine OS identifier (/etc/machine-id on Linux,
11
+ IOPlatformUUID on macOS). Binds the artifact to one machine.
12
+ env_token NKX_RUNTIME_TOKEN env var. Operator-injected secret (Vault
13
+ lease, KMS-derived token, per-seat license key). The token is
14
+ the runtime "factor" — without it the KEK is wrong.
15
+ aws_instance_id EC2 instance ID from IMDS. Binds to one EC2 instance.
16
+ gcp_instance_id GCP instance ID from metadata server. Binds to one GCP VM.
17
+ license_key RS256-signed JWT from NKX_LICENSE_KEY env var (or
18
+ .nkx/license.key). Verifies signature + expiry offline;
19
+ optionally checks revocation via NKX_VALIDATE_URL.
20
+ At protect time, NKX_LICENSE_UUID bypasses JWT verification
21
+ so the vendor container can bind the UUID without the signed key.
22
+ The stable license UUID (JWT sub claim) is the HKDF input.
23
+ """
24
+ from __future__ import annotations
25
+ import base64
26
+ import hashlib
27
+ import json
28
+ import os
29
+ import platform
30
+ import subprocess
31
+ import time
32
+ from pathlib import Path
33
+
34
+ from .trace import trace
35
+
36
+
37
+ # ── LicenseError ──────────────────────────────────────────────────────────────
38
+
39
+ class LicenseError(Exception):
40
+ """Raised when a license check fails (invalid, expired, or revoked)."""
41
+
42
+
43
+ # ── Backend origin scheme enforcement (audit finding B17) ───────────────────
44
+ # NKX_KEK_URL carries the license JWT bearer token, the runtime token, the
45
+ # register token, and the derived KEK itself. Accepting a plain http:// origin
46
+ # would send all of those in cleartext with no other signal that a downgrade
47
+ # happened. NKX_ALLOW_INSECURE_BACKEND_URL is the explicit, documented escape
48
+ # hatch for local dev/test against a plaintext backend.
49
+
50
+ def _require_https_origin(origin: str, *, env_var: str) -> None:
51
+ if origin.lower().startswith("https://"):
52
+ return
53
+ if os.environ.get("NKX_ALLOW_INSECURE_BACKEND_URL", "").strip().lower() in ("1", "true"):
54
+ trace("context", "insecure_backend_url_allowed",
55
+ f"{env_var}={origin!r} is not https:// — allowed via NKX_ALLOW_INSECURE_BACKEND_URL",
56
+ level="warn")
57
+ return
58
+ raise LicenseError(
59
+ f"{env_var} must be an https:// URL (got {origin!r}) — it carries the "
60
+ "license token, runtime token, and derived KEK in cleartext otherwise. "
61
+ "Set NKX_ALLOW_INSECURE_BACKEND_URL=1 to explicitly allow http:// "
62
+ "(local dev/test only)."
63
+ )
64
+
65
+
66
+ # ── License public key (RS256) ────────────────────────────────────────────────
67
+ # This key is used for offline JWT signature verification.
68
+ # The matching private key lives only in the Nakodax backend.
69
+ # Set at runtime with the NKX_LICENSE_PUBLIC_KEY env var — no embedded default
70
+ # (removed — audit finding A5/B90/#1: a keypair shared across every deployment
71
+ # lets anyone who has it forge a signature offline-verified as genuine, and a
72
+ # *silent* fallback meant a broken secret-injection step failed open instead
73
+ # of loud, which is what B20 partially addressed by tracing it — the actual
74
+ # fix is not falling back at all).
75
+
76
+
77
+ def _get_public_key() -> str:
78
+ configured = os.environ.get("NKX_LICENSE_PUBLIC_KEY", "").strip()
79
+ if not configured:
80
+ raise LicenseError(
81
+ "NKX_LICENSE_PUBLIC_KEY is not set — there is no default public key "
82
+ "any more. Set it to the public half of the keypair the issuing "
83
+ "Nakodax backend signs licenses with."
84
+ )
85
+ return configured.replace("\\n", "\n")
86
+
87
+
88
+ def _b64url_decode(s: str) -> bytes:
89
+ s += "=" * (-len(s) % 4)
90
+ return base64.urlsafe_b64decode(s)
91
+
92
+
93
+ # Both sides of the expiry comparison are already UTC and carry no timezone:
94
+ # the backend mints `exp` as `Math.floor(expiresAt.getTime() / 1000)` (epoch
95
+ # seconds) and `time.time()` returns epoch seconds. So there is nothing to
96
+ # convert here, and a timezone is not what goes wrong. What goes wrong is the
97
+ # host clock being *wrong* — a container or a box with no NTP drifting ahead —
98
+ # which no amount of timezone handling can fix, because the instant itself is
99
+ # misread.
100
+ #
101
+ # A fast clock rejects a licence the control plane would have accepted, and the
102
+ # app does not start at all. This check is deliberately offline and runs before
103
+ # any network call (see loader.py), so the authoritative answer is not available
104
+ # to contradict it. Hence the leeway: small on purpose, because it cuts the
105
+ # other way too — it also extends a genuinely expired licence by the same
106
+ # margin. A minute absorbs ordinary drift without meaningfully prolonging a dead
107
+ # key.
108
+ CLOCK_SKEW_LEEWAY_SECONDS = 60
109
+
110
+
111
+ def _fmt_utc(epoch_seconds: float) -> str:
112
+ return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(epoch_seconds))
113
+
114
+
115
+ def _expiry_error(exp: float) -> str:
116
+ """The message a customer sees when the offline expiry check fails.
117
+
118
+ It names both causes, because this code cannot tell them apart: the licence
119
+ really has expired, or this machine's clock is ahead. The previous wording
120
+ asserted only the first ("Contact your vendor to renew"), which sent
121
+ customers to the vendor for a problem the vendor cannot see and cannot fix
122
+ — the licence reads as valid on the control plane, because it is.
123
+
124
+ Both timestamps are rendered in UTC, not local time. A customer comparing
125
+ two local-time strings across a timezone would see a plausible-looking gap
126
+ that is not the problem, and chase it.
127
+ """
128
+ now = time.time()
129
+ return (
130
+ f"License key expired at {_fmt_utc(exp)}, and this machine's clock "
131
+ f"reads {_fmt_utc(now)}.\n"
132
+ f"Either the license needs renewing (contact your vendor), or this "
133
+ f"machine's clock is wrong.\n"
134
+ f"If the two times above disagree with each other by more than you "
135
+ f"expect, check the clock first: run `date -u` and compare it against "
136
+ f"a known-good source. A host whose clock runs fast will reject a "
137
+ f"license that is still valid."
138
+ )
139
+
140
+
141
+ def _verify_license_jwt(token: str) -> dict:
142
+ """Verify RS256 signature and expiry. Returns decoded payload."""
143
+ from cryptography.hazmat.primitives import hashes, serialization
144
+ from cryptography.hazmat.primitives.asymmetric import padding
145
+
146
+ parts = token.split(".")
147
+ if len(parts) != 3:
148
+ raise LicenseError("Malformed license key.")
149
+
150
+ header_b64, payload_b64, sig_b64 = parts
151
+ header = json.loads(_b64url_decode(header_b64))
152
+ if header.get("alg") != "RS256":
153
+ raise LicenseError("Invalid license key algorithm.")
154
+
155
+ message = f"{header_b64}.{payload_b64}".encode()
156
+ signature = _b64url_decode(sig_b64)
157
+
158
+ pub_key = serialization.load_pem_public_key(_get_public_key().encode())
159
+ try:
160
+ pub_key.verify(signature, message, padding.PKCS1v15(), hashes.SHA256())
161
+ except Exception:
162
+ raise LicenseError("License key signature is invalid.")
163
+
164
+ payload = json.loads(_b64url_decode(payload_b64))
165
+ exp = payload.get("exp")
166
+ if exp is not None and time.time() - CLOCK_SKEW_LEEWAY_SECONDS > exp:
167
+ trace("license", "jwt_expired", "License key has expired", level="error",
168
+ sub=payload.get("sub"))
169
+ raise LicenseError(_expiry_error(exp))
170
+
171
+ trace("license", "jwt_verified", "License JWT signature and expiry verified",
172
+ sub=payload.get("sub"))
173
+ return payload
174
+
175
+
176
+ def credentials_dir(repo_root: str | os.PathLike[str] | None = None) -> Path:
177
+ """The directory holding this deployment's nkx credentials.
178
+
179
+ One resolver for instanceauth.py's `instance_nonce` and fingerprint.py's
180
+ `machine_signal`. Both modules used to carry their own copy of this
181
+ precedence and the copies did not agree with the Node port, which is how
182
+ PKG-02 happened: the copies fell back to the *process working directory*,
183
+ so `nkx-dev unlock ~/work/acme` run from anywhere else looked like a
184
+ brand-new machine, registered a fresh runtime instance and consumed a
185
+ licence seat — once per directory the command was ever run from.
186
+
187
+ NKX_CREDENTIALS_DIR wins when set. The runtime SDK's credential chain
188
+ resolves a directory and exports it before the loader runs, so a
189
+ deployment mounting credentials outside the repo keeps working.
190
+
191
+ Otherwise `<repo_root>/.nkx`, mirroring `nonceFilePath(repoRoot)` in
192
+ lang/javascript/instanceauth.js. `repo_root=None` means the caller has no
193
+ root in hand and accepts the working directory; every path that can
194
+ consume a seat passes one explicitly and must keep doing so.
195
+ """
196
+ cred_dir = os.environ.get("NKX_CREDENTIALS_DIR", "").strip()
197
+ if cred_dir:
198
+ return Path(cred_dir)
199
+ return (Path(repo_root) if repo_root is not None else Path(".")) / ".nkx"
200
+
201
+
202
+ def _instance_fingerprint_header(repo_root: str | os.PathLike[str] | None = None) -> dict[str, str]:
203
+ """Best-effort X-Nkx-Instance-Fingerprint header for the validate call.
204
+
205
+ A fingerprint failure must never block the existing non-fatal revocation
206
+ check — on any exception this just omits the header. The backend treats a
207
+ missing header as "skip instance logic", which also keeps this
208
+ backwards-compatible with backend deployments that predate this feature.
209
+
210
+ `repo_root` is threaded so the fingerprint reads the same nonce file the
211
+ registration wrote. Without it the two halves can disagree — the nonce is
212
+ presented from `<repo>/.nkx` while the fingerprint hashes an empty string
213
+ read from the working directory — which silently degrades the signal the
214
+ backend uses to spot concurrent copies.
215
+ """
216
+ try:
217
+ from .fingerprint import get_fingerprint
218
+ return {"X-Nkx-Instance-Fingerprint": get_fingerprint(repo_root)}
219
+ except Exception as exc:
220
+ trace("license", "fingerprint_failed", f"fingerprint generation failed (non-fatal): {exc}",
221
+ level="warn")
222
+ return {}
223
+
224
+
225
+ def _runtime_token_header() -> dict[str, str]:
226
+ """X-Nkx-Runtime-Token header for the derive call.
227
+
228
+ Only sent when NKX_RUNTIME_TOKEN is actually set — an absent header
229
+ keeps derive calls on a manifest without env_token binding unaffected,
230
+ and lets the backend distinguish "no token supplied" from "wrong token"
231
+ in its own denial reason.
232
+ """
233
+ token = _env_token()
234
+ return {"X-Nkx-Runtime-Token": token} if token else {}
235
+
236
+
237
+ def _check_revocation_online(payload: dict, token: str) -> None:
238
+ """Phone-home revocation check.
239
+
240
+ `token` is the raw licence JWT, sent as `Authorization: Bearer`. The
241
+ endpoint used to accept the licence id alone, but that id is baked into
242
+ every deployed artifact, so anyone holding a copy of the software could
243
+ write events into the owning customer's audit trail. The key is the part
244
+ that is actually secret, and the backend now checks its `sub` against the
245
+ id in the URL.
246
+
247
+ Non-fatal on network errors for an ordinary license (the check itself
248
+ failing to reach the server doesn't block a legitimate offline/air-gapped
249
+ deployment) — but when the license JWT carries `require_online_validation:
250
+ true` (set by the backend for every customer-purpose license, see
251
+ licenseKeys.ts), both an unset NKX_VALIDATE_URL *and* a failed check
252
+ (timeout, connection error, DNS failure, malformed response) are fatal:
253
+ that claim exists specifically so an operator can't make a revoked
254
+ license run forever just by never setting the env var, or by blocking
255
+ egress to it.
256
+ """
257
+ license_id = payload["sub"]
258
+ validate_url = os.environ.get("NKX_VALIDATE_URL", "").rstrip("/")
259
+ if not validate_url:
260
+ if payload.get("require_online_validation"):
261
+ trace("license", "revocation_check_required", "NKX_VALIDATE_URL is required but unset",
262
+ level="error", license_id=license_id)
263
+ raise LicenseError(
264
+ "This license requires online validation (NKX_VALIDATE_URL), "
265
+ "which is not configured. Set it in the deployment environment."
266
+ )
267
+ return
268
+ trace("license", "revocation_check_start", f"checking revocation for {license_id}",
269
+ license_id=license_id)
270
+ try:
271
+ import urllib.error
272
+ import urllib.request
273
+ req = urllib.request.Request(
274
+ f"{validate_url}/{license_id}",
275
+ headers={"Authorization": f"Bearer {token}", **_instance_fingerprint_header()},
276
+ )
277
+ try:
278
+ with urllib.request.urlopen(req, timeout=3) as r:
279
+ data = json.loads(r.read().decode())
280
+ except urllib.error.HTTPError as exc:
281
+ if exc.code in (401, 429):
282
+ # Neither is an answer about the licence, and neither may be
283
+ # read as one. 401 means this runtime failed to authenticate
284
+ # (no key, wrong key, or a backend that has rotated its signing
285
+ # keypair); 429 means it is simply going too fast. Falling
286
+ # through to the body below would parse `valid: false` out of
287
+ # either and tell the operator their licence had been revoked,
288
+ # which is the exact bug remotekek.js documents for a
289
+ # rate-limited derive: "it had not — the caller is going too
290
+ # fast, and the condition clears itself inside a minute".
291
+ #
292
+ # Raising here lands in the outer handler, which keeps the
293
+ # right semantics: non-fatal for an ordinary licence, fatal for
294
+ # one carrying require_online_validation. A check that could
295
+ # not be completed must never be scored as a pass.
296
+ raise RuntimeError(
297
+ f"the validate endpoint answered {exc.code} "
298
+ f"({'authentication failed' if exc.code == 401 else 'rate limited'}), "
299
+ "which is not a verdict on the licence"
300
+ ) from exc
301
+ # The validate endpoint returns 410 (not 200) for a revoked,
302
+ # suspended or expired licence, which urlopen raises as an
303
+ # HTTPError rather than a normal response — read its body instead
304
+ # of treating it as a network error. It no longer answers about a
305
+ # *machine*: per-instance tracking left that endpoint in spec 16
306
+ # phase 5, and a copied deployment is stopped by revoking its
307
+ # runtime instance, which /internal/keys/derive enforces.
308
+ data = json.loads(exc.read().decode())
309
+ if not data.get("valid", True):
310
+ trace("license", "revocation_check_failed",
311
+ f"License is invalid: {data.get('reason', 'unknown')}",
312
+ level="error", license_id=license_id, reason=data.get("reason"))
313
+ raise LicenseError(
314
+ f"License is no longer valid ({data.get('reason', 'unknown')}). Contact your vendor."
315
+ )
316
+ trace("license", "revocation_check_end", "License is valid", license_id=license_id)
317
+ except LicenseError:
318
+ raise
319
+ except Exception as exc:
320
+ if payload.get("require_online_validation"):
321
+ # A license that demands online validation must fail closed on
322
+ # any network/parse error too — otherwise blocking egress to
323
+ # NKX_VALIDATE_URL (firewall, outage, unplugged NIC) is an easy
324
+ # way to keep a revoked license running forever (audit B10).
325
+ trace("license", "revocation_check_error",
326
+ f"revocation check failed and is required: {exc}", level="error",
327
+ license_id=license_id, error=str(exc))
328
+ raise LicenseError(
329
+ "This license requires online validation (NKX_VALIDATE_URL), "
330
+ f"but the check could not be completed: {exc}. "
331
+ "Contact your vendor if this persists."
332
+ ) from exc
333
+ trace("license", "revocation_check_error",
334
+ f"revocation check failed (non-fatal): {exc}", level="warn",
335
+ license_id=license_id, error=str(exc))
336
+
337
+
338
+ # ── collectors ────────────────────────────────────────────────────────────────
339
+
340
+ def _machine_id() -> str:
341
+ """Stable per-machine identifier (Linux / macOS / Windows)."""
342
+ # Linux
343
+ mid = Path("/etc/machine-id")
344
+ if mid.exists():
345
+ return mid.read_text().strip()
346
+ # macOS
347
+ try:
348
+ out = subprocess.check_output(
349
+ ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
350
+ stderr=subprocess.DEVNULL, text=True,
351
+ )
352
+ for line in out.splitlines():
353
+ if "IOPlatformUUID" in line:
354
+ return line.split('"')[-2]
355
+ except Exception:
356
+ pass
357
+ # Windows — motherboard UUID via WMIC (stable, hardware-bound)
358
+ try:
359
+ out = subprocess.check_output(
360
+ ["wmic", "csproduct", "get", "UUID"],
361
+ stderr=subprocess.DEVNULL, text=True,
362
+ )
363
+ for line in out.splitlines():
364
+ line = line.strip()
365
+ if line and line.upper() != "UUID":
366
+ return line
367
+ except Exception:
368
+ pass
369
+ return platform.node() # hostname as last resort
370
+
371
+
372
+ def _env_token() -> str:
373
+ """Operator-injected token from NKX_RUNTIME_TOKEN.
374
+
375
+ This is the recommended runtime factor: set it from Vault, KMS, or a
376
+ per-seat license key in your deployment pipeline. Its value is never
377
+ stored in the manifest — only its effect on the derived KEK is.
378
+ """
379
+ return os.environ.get("NKX_RUNTIME_TOKEN", "")
380
+
381
+
382
+ def _aws_instance_id() -> str:
383
+ try:
384
+ import urllib.request
385
+ with urllib.request.urlopen(
386
+ "http://169.254.169.254/latest/meta-data/instance-id", timeout=1,
387
+ ) as r:
388
+ return r.read().decode().strip()
389
+ except Exception:
390
+ return ""
391
+
392
+
393
+ def _gcp_instance_id() -> str:
394
+ try:
395
+ import urllib.request
396
+ req = urllib.request.Request(
397
+ "http://metadata.google.internal/computeMetadata/v1/instance/id",
398
+ headers={"Metadata-Flavor": "Google"},
399
+ )
400
+ with urllib.request.urlopen(req, timeout=1) as r:
401
+ return r.read().decode().strip()
402
+ except Exception:
403
+ return ""
404
+
405
+
406
+ def _read_license_token() -> str:
407
+ """Read the raw license JWT from NKX_LICENSE_KEY or .nkx/license.key.
408
+
409
+ Does not verify the token or check revocation — just locates the raw
410
+ string. Shared by every caller that used to duplicate this lookup
411
+ (_license_key, check_license, is_developer_license) and by loader.py /
412
+ instanceauth.py / remotekek.py, which need the raw JWT itself (not just
413
+ its sub claim) to send as the derive/register call's Bearer token.
414
+ """
415
+ token = os.environ.get("NKX_LICENSE_KEY", "").strip()
416
+ if not token:
417
+ key_file = Path(".nkx") / "license.key"
418
+ if key_file.exists():
419
+ token = key_file.read_text().strip()
420
+ return token
421
+
422
+
423
+ def _license_key() -> str:
424
+ """Return the stable license UUID for HKDF context binding.
425
+
426
+ At protect time (vendor container): reads NKX_LICENSE_UUID directly —
427
+ the backend injects the UUID without a signed JWT so the container doesn't
428
+ need the full token.
429
+
430
+ At runtime (customer environment): reads NKX_LICENSE_KEY (a RS256 JWT),
431
+ verifies its signature and expiry offline, optionally checks revocation via
432
+ NKX_VALIDATE_URL, then returns the stable license UUID (sub claim).
433
+ """
434
+ # Protect-time shortcut: UUID injected directly by the backend
435
+ direct_uuid = os.environ.get("NKX_LICENSE_UUID", "").strip()
436
+ if direct_uuid:
437
+ trace("license", "uuid_direct", "license UUID injected directly (protect-time)")
438
+ return direct_uuid
439
+
440
+ token = _read_license_token()
441
+ if not token:
442
+ trace("license", "missing", "No license key found", level="error")
443
+ raise LicenseError(
444
+ "No license key found. Set NKX_LICENSE_KEY or place the key in "
445
+ ".nkx/license.key"
446
+ )
447
+
448
+ payload = _verify_license_jwt(token)
449
+ _check_revocation_online(payload, token)
450
+ purpose = payload.get("purpose", "customer")
451
+ if purpose != "customer":
452
+ trace("license", "purpose_detected", f"license purpose: {purpose}",
453
+ sub=payload["sub"], purpose=purpose)
454
+ return payload["sub"]
455
+
456
+
457
+ _COLLECTORS: dict[str, "callable[[], str]"] = {
458
+ "machine_id": _machine_id,
459
+ "env_token": _env_token,
460
+ "aws_instance_id": _aws_instance_id,
461
+ "gcp_instance_id": _gcp_instance_id,
462
+ "license_key": _license_key,
463
+ }
464
+
465
+ AVAILABLE_SOURCES: list[str] = list(_COLLECTORS)
466
+
467
+
468
+ # ── public API ────────────────────────────────────────────────────────────────
469
+
470
+ def check_license(token: str = "") -> dict:
471
+ """Verify the license JWT and check revocation. Raises LicenseError on failure.
472
+ Returns the decoded JWT payload (callers that don't need it can ignore it).
473
+
474
+ Reads NKX_LICENSE_KEY env var or .nkx/license.key when no token
475
+ is given. Checks expiry offline always; checks revocation online when
476
+ NKX_VALIDATE_URL is set. Called by the loader at ProtectedFinder init
477
+ time (once per process start) — not on every import. The returned payload
478
+ is also passed to start_heartbeat_thread() so the heartbeat doesn't
479
+ re-verify the JWT signature on every beat.
480
+
481
+ All license purposes (customer / internal / developer) are accepted.
482
+ """
483
+ if not token:
484
+ token = _read_license_token()
485
+ if not token:
486
+ raise LicenseError(
487
+ "No license key found. Set NKX_LICENSE_KEY or place the key "
488
+ "in .nkx/license.key"
489
+ )
490
+ payload = _verify_license_jwt(token)
491
+ _check_revocation_online(payload, token)
492
+ return payload
493
+
494
+
495
+ def start_heartbeat_thread(payload: dict) -> None:
496
+ """Background daemon thread that re-validates the license periodically so
497
+ a revoked or expired key is noticed by a long-running process rather than
498
+ only at startup. Since spec 16 phase 5 the backend's /licenses/validate/:id
499
+ answers about the *licence* only — per-machine tracking moved onto the
500
+ derive path, which knows which registered instance is asking.
501
+
502
+ Started once from ProtectedFinder.__init__ alongside the startup check,
503
+ only when NKX_VALIDATE_URL is actually configured (no point spinning
504
+ a thread that always no-ops). daemon=True so it never blocks process
505
+ exit. Heartbeat failures are logged via trace() and otherwise swallowed —
506
+ a flaky heartbeat must not crash an already-running customer process the
507
+ way a startup failure does. A heartbeat that discovers the license itself
508
+ was revoked mid-run is logged only, not enforced here — enforcement for a
509
+ running process is a deliberately out-of-scope product decision for this
510
+ feature (detection flags for review; it does not kill running processes).
511
+ """
512
+ if not os.environ.get("NKX_VALIDATE_URL", "").strip():
513
+ return
514
+
515
+ import threading
516
+ from .fingerprint import HEARTBEAT_INTERVAL_SECONDS
517
+
518
+ def _loop():
519
+ while True:
520
+ time.sleep(HEARTBEAT_INTERVAL_SECONDS)
521
+ try:
522
+ # Re-read rather than closing over one string: the heartbeat
523
+ # outlives any single call, and a key rotated on disk should be
524
+ # the one presented on the next tick.
525
+ _check_revocation_online(payload, _read_license_token())
526
+ except LicenseError as exc:
527
+ trace("license", "heartbeat_failed", str(exc), level="error",
528
+ license_id=payload.get("sub"))
529
+ except Exception as exc:
530
+ trace("license", "heartbeat_error", f"heartbeat check failed (non-fatal): {exc}",
531
+ level="warn", license_id=payload.get("sub"))
532
+
533
+ threading.Thread(target=_loop, daemon=True, name="nkx-license-heartbeat").start()
534
+
535
+
536
+ def is_developer_license(token: str = "") -> bool:
537
+ """Return True if the license JWT grants developer / dev-mode access.
538
+
539
+ Reads NKX_LICENSE_KEY or .nkx/license.key when no token is given.
540
+ Returns False on any error — dev licenses fail open (no exception raised).
541
+
542
+ `kind` since spec 16 phase 2; `purpose` is the older spelling and still
543
+ what a token issued before that carries. Reading both matches
544
+ loader._derive_for_install() and the JS client's own devmode routing,
545
+ which have always done so. This function read `purpose` alone, so a
546
+ token carrying only the new claim would answer False here while those
547
+ two answered True — and the offline-developer branch in _install_core()
548
+ that gates on this would fall through to ProtectedFinder and report the
549
+ backend as unreachable instead of serving stubs. Latent rather than
550
+ live: the backend currently signs both claims on every licence
551
+ (backend/src/lib/licenseKeys.ts signLicense()), so the trigger is a
552
+ routine cleanup dropping the old one.
553
+ """
554
+ if not token:
555
+ token = _read_license_token()
556
+ if not token:
557
+ return False
558
+ try:
559
+ payload = _verify_license_jwt(token)
560
+ return (payload.get("kind") or payload.get("purpose")) == "developer"
561
+ except Exception:
562
+ return False
563
+
564
+ def collect(sources: list[str], overrides: dict[str, str] | None = None) -> dict[str, str]:
565
+ """Run each named collector and return {source: value} mapping.
566
+
567
+ ``overrides`` lets callers supply a pre-known value for a source instead of
568
+ auto-collecting it. Useful when the protect command runs in a container
569
+ that is not the target machine: pass the target machine's ID via
570
+ ``--machine-id`` so the manifest is bound to the right fingerprint.
571
+ """
572
+ ov = overrides or {}
573
+ result: dict[str, str] = {}
574
+ for src in sources:
575
+ if src in ov:
576
+ result[src] = ov[src]
577
+ continue
578
+ fn = _COLLECTORS.get(src)
579
+ if fn is None:
580
+ raise ValueError(
581
+ f"unknown runtime source: {src!r}. "
582
+ f"Available: {AVAILABLE_SOURCES}"
583
+ )
584
+ result[src] = fn()
585
+ return result
586
+
587
+
588
+ _RESERVED_INFO_KEYS = frozenset({"build_id", "tenant_id", "customer_id", "purpose"})
589
+
590
+
591
+ def assemble_info(
592
+ build_id: str,
593
+ tenant_id: str,
594
+ runtime_values: dict[str, str],
595
+ customer_id: str = "",
596
+ purpose: str = "",
597
+ ) -> bytes:
598
+ """Build deterministic HKDF info bytes from all context fields.
599
+
600
+ Length-prefixed encoding prevents field-boundary ambiguity (so
601
+ build_id="ab", tenant_id="c" ≠ build_id="a", tenant_id="bc").
602
+ Fields are sorted so insertion order doesn't affect the result.
603
+
604
+ customer_id and purpose are only included when non-empty, preserving
605
+ backward compatibility with existing v2 manifests that don't carry them.
606
+ Both are set *after* the runtime_values spread so they're always
607
+ authoritative — runtime_values may not define any reserved key itself
608
+ (build_id/tenant_id/customer_id/purpose); that would otherwise be
609
+ silently shadowed by this ordering rather than rejected outright.
610
+ """
611
+ collision = _RESERVED_INFO_KEYS & runtime_values.keys()
612
+ if collision:
613
+ raise ValueError(
614
+ f"runtime_values must not define reserved key(s): {sorted(collision)}"
615
+ )
616
+ h = hashlib.sha256()
617
+ h.update(b"nkx-context-v2\x00")
618
+ fields = {"build_id": build_id, "tenant_id": tenant_id, **runtime_values}
619
+ if customer_id:
620
+ fields["customer_id"] = customer_id
621
+ if purpose:
622
+ fields["purpose"] = purpose
623
+ for key in sorted(fields):
624
+ val = fields[key]
625
+ k_b, v_b = key.encode(), val.encode()
626
+ h.update(len(k_b).to_bytes(2, "big") + k_b)
627
+ h.update(len(v_b).to_bytes(4, "big") + v_b)
628
+ return h.digest()