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.
- nkx_runtime/__init__.py +41 -0
- nkx_runtime/_core/__init__.py +1 -0
- nkx_runtime/_core/context.py +628 -0
- nkx_runtime/_core/crypto.py +144 -0
- nkx_runtime/_core/devmode.py +223 -0
- nkx_runtime/_core/fingerprint.py +158 -0
- nkx_runtime/_core/instanceauth.py +384 -0
- nkx_runtime/_core/loader.py +470 -0
- nkx_runtime/_core/manifest.py +345 -0
- nkx_runtime/_core/redact.py +27 -0
- nkx_runtime/_core/remotekek.py +705 -0
- nkx_runtime/_core/trace.py +39 -0
- nkx_runtime/bridge.py +49 -0
- nkx_runtime/cli.py +121 -0
- nkx_runtime/client.py +179 -0
- nkx_runtime/config.py +122 -0
- nkx_runtime/credentials/__init__.py +5 -0
- nkx_runtime/credentials/chain.py +184 -0
- nkx_runtime/errors.py +46 -0
- nkx_runtime/http/__init__.py +5 -0
- nkx_runtime/http/middleware.py +7 -0
- nkx_runtime/secrets.py +25 -0
- nkx_runtime-2.2.0.dist-info/METADATA +95 -0
- nkx_runtime-2.2.0.dist-info/RECORD +27 -0
- nkx_runtime-2.2.0.dist-info/WHEEL +5 -0
- nkx_runtime-2.2.0.dist-info/entry_points.txt +2 -0
- nkx_runtime-2.2.0.dist-info/top_level.txt +1 -0
nkx_runtime/__init__.py
ADDED
|
@@ -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()
|