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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Envelope encryption primitives.
|
|
2
|
+
|
|
3
|
+
Two-stage KEK derivation (v2 manifests):
|
|
4
|
+
|
|
5
|
+
passphrase ──scrypt──► base_kek
|
|
6
|
+
──HKDF(info=context_bytes)──► final_kek
|
|
7
|
+
build_id + tenant_id + runtime_fp ────────────────────────────┘
|
|
8
|
+
|
|
9
|
+
scrypt provides brute-force resistance on the passphrase.
|
|
10
|
+
HKDF binds the key to a specific build/tenant/runtime context so a stolen
|
|
11
|
+
artifact cannot be decrypted outside its intended environment.
|
|
12
|
+
|
|
13
|
+
v1 manifests use scrypt only (no context binding).
|
|
14
|
+
"""
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from cryptography.hazmat.primitives import hashes as _hashes
|
|
20
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
21
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
22
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
23
|
+
|
|
24
|
+
_NONCE = 12
|
|
25
|
+
_KEYLEN = 32
|
|
26
|
+
|
|
27
|
+
# Suffix appended to a protected file's plaintext rel to get its ciphertext
|
|
28
|
+
# blob on disk: pricing.py -> pricing.py.nkxc, and the mock stub
|
|
29
|
+
# pricing.py.mock.py -> pricing.py.mock.py.nkxc. Appended, never substituted:
|
|
30
|
+
# protect.py's _mock_plaintext_rel() and devmode's mock helpers all recover
|
|
31
|
+
# the plaintext rel by stripping exactly this suffix, and entry["mock"] is
|
|
32
|
+
# built assuming it starts with `rel`.
|
|
33
|
+
#
|
|
34
|
+
# It lives here, not in protect.py, because loader/materialize/rehook/devmode
|
|
35
|
+
# all need it and `protect` is absent from the sdk-python sync allowlist
|
|
36
|
+
# (Makefile RUNTIME_PY_MODULES) — importing it from there would break the
|
|
37
|
+
# synced runtime slice. `crypto` is in all three allowlists and every one of
|
|
38
|
+
# those modules already imports from it.
|
|
39
|
+
#
|
|
40
|
+
# NOT ".nkxd": that extension belongs to the NKXDGARD data/document format
|
|
41
|
+
# (Projects 2/3, nakodax-data + nakodax-box), which is a self-describing
|
|
42
|
+
# container — MAGIC, header-length prefix, header JSON, chunked ciphertext.
|
|
43
|
+
# The blob written here is headerless (nonce || AES-GCM(ct||tag)) and is
|
|
44
|
+
# meaningless without .nkx/manifest.json. Same suffix, two incompatible
|
|
45
|
+
# layouts, across three repos.
|
|
46
|
+
BLOB_EXT = ".nkxc"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def b64(data: bytes) -> str:
|
|
50
|
+
return base64.b64encode(data).decode()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def unb64(text: str) -> bytes:
|
|
54
|
+
return base64.b64decode(text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sha256(data: bytes) -> str:
|
|
58
|
+
return hashlib.sha256(data).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def zeroize(buf: bytearray | None) -> None:
|
|
62
|
+
"""Overwrite a mutable key buffer in place. A no-op on anything
|
|
63
|
+
immutable (bytes, str) — Python can't scrub those, which is exactly why
|
|
64
|
+
callers holding a decrypted key should keep it in a bytearray for as
|
|
65
|
+
long as they hold it, and call this the moment they're done."""
|
|
66
|
+
if not buf:
|
|
67
|
+
return
|
|
68
|
+
for i in range(len(buf)):
|
|
69
|
+
buf[i] = 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def new_kdf() -> dict:
|
|
73
|
+
# n=2**17 per current OWASP guidance for a passphrase-based KDF used as
|
|
74
|
+
# a brute-force barrier (audit finding B18) — 8x the prior 2**14.
|
|
75
|
+
return {"salt": b64(os.urandom(16)), "n": 2 ** 17, "r": 8, "p": 1}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def derive_kek(passphrase: str, kdf: dict) -> bytes:
|
|
79
|
+
scrypt = Scrypt(
|
|
80
|
+
salt=unb64(kdf["salt"]),
|
|
81
|
+
length=_KEYLEN,
|
|
82
|
+
n=kdf["n"],
|
|
83
|
+
r=kdf["r"],
|
|
84
|
+
p=kdf["p"],
|
|
85
|
+
)
|
|
86
|
+
return scrypt.derive(passphrase.encode())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def encrypt_blob(plaintext: bytes, dek: bytes, associated_data: bytes | None = None) -> bytes:
|
|
90
|
+
aes = AESGCM(dek)
|
|
91
|
+
nonce = os.urandom(_NONCE)
|
|
92
|
+
return nonce + aes.encrypt(nonce, plaintext, associated_data)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def decrypt_blob(blob: bytes, dek: bytes, associated_data: bytes | None = None) -> bytes:
|
|
96
|
+
aes = AESGCM(dek)
|
|
97
|
+
nonce, ct = blob[:_NONCE], blob[_NONCE:]
|
|
98
|
+
return aes.decrypt(nonce, ct, associated_data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def wrap_dek(dek: bytes, kek: bytes, associated_data: bytes | None = None) -> bytes:
|
|
102
|
+
return encrypt_blob(dek, kek, associated_data)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def unwrap_dek(wrapped: bytes, kek: bytes, associated_data: bytes | None = None) -> bytes:
|
|
106
|
+
return decrypt_blob(wrapped, kek, associated_data)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def slot_aad(rel: str, kind: str) -> bytes:
|
|
110
|
+
"""Associated data binding a piece of ciphertext to the manifest slot it
|
|
111
|
+
belongs to. AES-GCM's auth tag normally only proves "this ciphertext
|
|
112
|
+
decrypts validly under this key" — not which file it's *for* — so an
|
|
113
|
+
attacker with manifest write-access could copy one entry's
|
|
114
|
+
{ciphertext, wrapped_dek} pair into a different entry's slot and have
|
|
115
|
+
both decrypt "successfully", silently substituting the wrong content
|
|
116
|
+
under the wrong name. Binding each ciphertext to its own rel + kind
|
|
117
|
+
("content"/"dek"/"mock-content"/"mock-dek") makes that substitution fail
|
|
118
|
+
to decrypt instead (audit finding B16).
|
|
119
|
+
|
|
120
|
+
kind distinguishes the four things that get encrypted per file so a
|
|
121
|
+
real-content blob can't be swapped for its own wrapped_dek's slot (or a
|
|
122
|
+
mock's), even though they'd otherwise share the same rel.
|
|
123
|
+
"""
|
|
124
|
+
return f"{kind}:{rel}".encode()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def bind_context_kek(base_kek: bytes, context_info: bytes) -> bytes:
|
|
128
|
+
"""Bind a base KEK to a runtime context using HKDF.
|
|
129
|
+
|
|
130
|
+
context_info is the HKDF 'info' parameter — public and non-secret, but
|
|
131
|
+
cryptographically binding. A different environment produces different
|
|
132
|
+
context_info bytes, which produces a completely different final KEK, so
|
|
133
|
+
DEK unwrapping fails if the runtime context doesn't match exactly.
|
|
134
|
+
"""
|
|
135
|
+
return HKDF(
|
|
136
|
+
algorithm=_hashes.SHA256(),
|
|
137
|
+
length=_KEYLEN,
|
|
138
|
+
salt=None, # base_kek is already high-entropy; salt adds nothing here
|
|
139
|
+
info=context_info,
|
|
140
|
+
).derive(base_kek)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def new_dek() -> bytes:
|
|
144
|
+
return os.urandom(_KEYLEN)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Dev-mode import hook: serve mock stubs instead of `.nkxc` blobs.
|
|
2
|
+
|
|
3
|
+
DevModeFinder — sys.meta_path hook that loads stubs instead of .nkxc blobs
|
|
4
|
+
DevModeLoader — companion loader for DevModeFinder
|
|
5
|
+
|
|
6
|
+
Activated by NKX_DEVMODE=1 when there's no backend to derive a real KEK
|
|
7
|
+
from (NKX_KEK_URL absent) — works for v1 and v2/context-bound manifests
|
|
8
|
+
alike, since plaintext stubs never touch the KEK at all.
|
|
9
|
+
|
|
10
|
+
Stub *generation* lives in `mockgen.py`. This module must not import it: this
|
|
11
|
+
one ships to the customer runtime wheel and that one does not, which is the
|
|
12
|
+
entire point of the split.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib.abc
|
|
17
|
+
import importlib.util
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# ── dev-mode import hook ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
_ENCRYPTED_MOCK_ERROR = (
|
|
23
|
+
"nkx devmode: this mock is encrypted — a developer license and reachable "
|
|
24
|
+
"NKX_KEK_URL are required"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _mock_enc_rel(entry: dict) -> str:
|
|
29
|
+
"""Relative path of the encrypted mock blob for one manifest entry.
|
|
30
|
+
|
|
31
|
+
entry["mock"] is exactly the path _commit_stub_write() (protect.py)
|
|
32
|
+
actually wrote — the plaintext stub path during the dual-write window,
|
|
33
|
+
or the .nkxc path itself after the encrypted-only cutover. Both cases
|
|
34
|
+
are handled without a second manifest field: if it doesn't already end
|
|
35
|
+
in ".nkxc", the encrypted sibling is that same path with ".nkxc"
|
|
36
|
+
appended (exactly how the writer derives it), otherwise it already
|
|
37
|
+
*is* the encrypted path.
|
|
38
|
+
"""
|
|
39
|
+
from .crypto import BLOB_EXT # lazy, like every other import here
|
|
40
|
+
mock_rel = entry["mock"]
|
|
41
|
+
return mock_rel if mock_rel.endswith(BLOB_EXT) else mock_rel + BLOB_EXT
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _mock_plaintext_rel(entry: dict) -> str:
|
|
45
|
+
"""The plaintext-edit-path counterpart to _mock_enc_rel() above — same
|
|
46
|
+
rel + ".mock" + ext convention, just without the ".nkxc" suffix. Used to
|
|
47
|
+
find a developer's hand-edited plaintext during the MOCK_UNLOCKED
|
|
48
|
+
override window (see _DevModeLoader.exec_module)."""
|
|
49
|
+
from .crypto import BLOB_EXT # lazy, like every other import here
|
|
50
|
+
enc_rel = _mock_enc_rel(entry)
|
|
51
|
+
return enc_rel[: -len(BLOB_EXT)] if enc_rel.endswith(BLOB_EXT) else enc_rel
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _mock_unlocked_rels(root: Path) -> list[str]:
|
|
55
|
+
"""Read protect.py's MOCK_UNLOCKED sentinel fresh on every call (no
|
|
56
|
+
caching) — self-service unlock-mock/reseal-mock (Phase 8) run in a
|
|
57
|
+
separate CLI process from whatever long-running process is importing
|
|
58
|
+
protected modules, so a cached read here could miss an edit made after
|
|
59
|
+
this process started.
|
|
60
|
+
|
|
61
|
+
Uses a private copy of the MOCK_UNLOCKED path rather than importing it
|
|
62
|
+
from protect.py — protect.py (encrypt/reseal/rekey) is off the
|
|
63
|
+
customer-facing packaging allowlist (see Makefile's RUNTIME_PY_MODULES),
|
|
64
|
+
but this function runs on every devmode-served import of an
|
|
65
|
+
encrypted-mock file, which does ship there. Importing protect.py here
|
|
66
|
+
made every such import crash with ModuleNotFoundError in the packaged
|
|
67
|
+
nkx-runtime slice (and, historically, the now-retired dev-cli slice) —
|
|
68
|
+
same class of gap materialize.py's _load_manifest() already sidesteps
|
|
69
|
+
for the same reason."""
|
|
70
|
+
import json
|
|
71
|
+
MOCK_UNLOCKED = ".nkx/MOCK_UNLOCKED"
|
|
72
|
+
sentinel = root / MOCK_UNLOCKED
|
|
73
|
+
return json.loads(sentinel.read_text()) if sentinel.exists() else []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _DevModeLoader(importlib.abc.Loader):
|
|
77
|
+
def __init__(self, finder: "DevModeFinder", fullname: str):
|
|
78
|
+
self.finder = finder
|
|
79
|
+
self.fullname = fullname
|
|
80
|
+
|
|
81
|
+
def create_module(self, spec):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def exec_module(self, module):
|
|
85
|
+
rel, entry = self.finder.modules[self.fullname]
|
|
86
|
+
# MOCK_UNLOCKED override (Phase 8): while a file is listed there, a
|
|
87
|
+
# developer's live hand-edit on disk must win over decrypt-preference
|
|
88
|
+
# below — otherwise imports would keep serving stale ciphertext (from
|
|
89
|
+
# before the edit) or a freshly-derived placeholder, never what's
|
|
90
|
+
# actually sitting in the plaintext file they just changed. Per-file
|
|
91
|
+
# only: every other file in the same manifest stays decrypt-preferred.
|
|
92
|
+
if "wrapped_mock_dek" in entry and rel in _mock_unlocked_rels(self.finder.root):
|
|
93
|
+
plaintext_path = self.finder.root / _mock_plaintext_rel(entry)
|
|
94
|
+
if not plaintext_path.exists():
|
|
95
|
+
raise ImportError(
|
|
96
|
+
f"nkx devmode: {rel!r} is listed in MOCK_UNLOCKED but its "
|
|
97
|
+
f"plaintext is missing — run 'nkx-dev unlock-mock' again"
|
|
98
|
+
)
|
|
99
|
+
source = plaintext_path.read_bytes()
|
|
100
|
+
label = str(plaintext_path)
|
|
101
|
+
# Decrypt-preferred: a file with wrapped_mock_dek always attempts
|
|
102
|
+
# the encrypted read — the plaintext copy sitting next to it during
|
|
103
|
+
# the dual-write window is a fallback for *other, not-yet-upgraded*
|
|
104
|
+
# readers, not a same-process fallback for this one. Only entries
|
|
105
|
+
# with no wrapped_mock_dek at all (true legacy/plaintext-only
|
|
106
|
+
# files) read entry["mock"]'s plaintext path directly.
|
|
107
|
+
elif "wrapped_mock_dek" in entry:
|
|
108
|
+
source = self.finder._decrypt_mock(rel, entry)
|
|
109
|
+
label = str(self.finder.root / _mock_enc_rel(entry))
|
|
110
|
+
else:
|
|
111
|
+
mock_path = self.finder.root / entry.get("mock", rel + ".mock.py")
|
|
112
|
+
if not mock_path.exists():
|
|
113
|
+
raise ImportError(
|
|
114
|
+
f"nkx devmode: no stub found for {self.fullname!r} — "
|
|
115
|
+
f"run 'nkx mock <repo>' to generate stubs"
|
|
116
|
+
)
|
|
117
|
+
source = mock_path.read_bytes()
|
|
118
|
+
label = str(mock_path)
|
|
119
|
+
code = compile(source, label, "exec")
|
|
120
|
+
exec(code, module.__dict__)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DevModeFinder(importlib.abc.MetaPathFinder):
|
|
124
|
+
"""sys.meta_path hook that serves .mock.py stubs instead of .nkxc blobs.
|
|
125
|
+
|
|
126
|
+
Encrypted mock content (Python hook-tier entries with wrapped_mock_dek)
|
|
127
|
+
is decrypted lazily via a mock-content KEK derived from build_id/kdf/
|
|
128
|
+
key_version + license_jwt — a manifest that's entirely legacy/plaintext
|
|
129
|
+
triggers zero network calls; only the first import of a file that
|
|
130
|
+
actually has wrapped_mock_dek derives (and reuses, via a single shared
|
|
131
|
+
RemoteKekManager, since the mock KEK is the same for every file in the
|
|
132
|
+
manifest — it never depends on which file is being imported).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self, root: str, manifest: dict, *,
|
|
137
|
+
build_id: str = "", kdf: dict | None = None,
|
|
138
|
+
key_version: int | None = None, license_jwt: str = "",
|
|
139
|
+
kek_manager=None,
|
|
140
|
+
):
|
|
141
|
+
"""`kek_manager`, when given, is a manager the caller already derived.
|
|
142
|
+
|
|
143
|
+
Spec 16 phase 3: _install_core asks the backend once, and the *answer*
|
|
144
|
+
is what selects this finder over ProtectedFinder — so the mock KEK is
|
|
145
|
+
already in hand by the time this is constructed, and deriving a second
|
|
146
|
+
one here would be a second round trip that could disagree with the
|
|
147
|
+
first. The lazy path below still exists for the case that has no
|
|
148
|
+
release row to route through /derive at all (a repo protected but
|
|
149
|
+
never published), and for a manifest that is entirely legacy plaintext
|
|
150
|
+
and makes no network call whatsoever.
|
|
151
|
+
"""
|
|
152
|
+
self.root = Path(root)
|
|
153
|
+
self.modules = {
|
|
154
|
+
e["module"]: (rel, e)
|
|
155
|
+
for rel, e in manifest["files"].items()
|
|
156
|
+
if e.get("load_strategy", "hook") == "hook" and "module" in e
|
|
157
|
+
}
|
|
158
|
+
# Kept for binding_name(): the mock ciphertext is bound to the same
|
|
159
|
+
# repo-relative name as the real content, which in a per-root view is
|
|
160
|
+
# longer than the key this finder iterates. See manifest.binding_name.
|
|
161
|
+
self._manifest = manifest
|
|
162
|
+
self._build_id = build_id
|
|
163
|
+
self._kdf = kdf
|
|
164
|
+
self._key_version = key_version
|
|
165
|
+
self._license_jwt = license_jwt
|
|
166
|
+
self._mock_kek_manager = kek_manager # lazy when None — see class docstring
|
|
167
|
+
|
|
168
|
+
def _decrypt_mock(self, rel: str, entry: dict) -> bytes:
|
|
169
|
+
from .crypto import decrypt_blob, slot_aad, unb64, unwrap_dek, zeroize
|
|
170
|
+
from .manifest import binding_name
|
|
171
|
+
|
|
172
|
+
name = binding_name(self._manifest, rel)
|
|
173
|
+
|
|
174
|
+
# A fresh, mutable copy from the manager (audit finding B12) —
|
|
175
|
+
# scrub it here rather than leave it sitting in memory until GC
|
|
176
|
+
# (which wouldn't scrub it anyway; bytes can't be).
|
|
177
|
+
mock_kek = self._get_mock_kek()
|
|
178
|
+
try:
|
|
179
|
+
enc_path = self.root / _mock_enc_rel(entry)
|
|
180
|
+
try:
|
|
181
|
+
blob = enc_path.read_bytes()
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
# Must still be the distinct encrypted-mock ImportError, not a
|
|
184
|
+
# raw FileNotFoundError — a bare OSError subclass leaking out of
|
|
185
|
+
# exec_module() would violate the "distinct ImportError, never
|
|
186
|
+
# a generic/unrelated exception" contract this whole mechanism
|
|
187
|
+
# exists to provide (see _ENCRYPTED_MOCK_ERROR's module docstring).
|
|
188
|
+
raise ImportError(f"{_ENCRYPTED_MOCK_ERROR} (encrypted mock file missing on disk)") from None
|
|
189
|
+
try:
|
|
190
|
+
dek = unwrap_dek(unb64(entry["wrapped_mock_dek"]), mock_kek, slot_aad(name, "mock-dek"))
|
|
191
|
+
return decrypt_blob(blob, dek, slot_aad(name, "mock-content"))
|
|
192
|
+
except Exception:
|
|
193
|
+
raise ImportError(f"{_ENCRYPTED_MOCK_ERROR} (stale or revoked mock KEK)") from None
|
|
194
|
+
finally:
|
|
195
|
+
zeroize(mock_kek)
|
|
196
|
+
|
|
197
|
+
def _get_mock_kek(self) -> bytearray:
|
|
198
|
+
if self._mock_kek_manager is None:
|
|
199
|
+
if not self._license_jwt or self._kdf is None or self._key_version is None:
|
|
200
|
+
raise ImportError(_ENCRYPTED_MOCK_ERROR)
|
|
201
|
+
import functools
|
|
202
|
+
from . import remotekek
|
|
203
|
+
derive_fn = functools.partial(
|
|
204
|
+
remotekek.derive_mock_kek,
|
|
205
|
+
self._license_jwt, self._build_id, self._kdf, self._key_version,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
manager = remotekek.RemoteKekManager(derive_fn)
|
|
209
|
+
except Exception:
|
|
210
|
+
raise ImportError(_ENCRYPTED_MOCK_ERROR) from None
|
|
211
|
+
manager.start_refresh_thread()
|
|
212
|
+
self._mock_kek_manager = manager
|
|
213
|
+
try:
|
|
214
|
+
return self._mock_kek_manager.current_kek()
|
|
215
|
+
except Exception:
|
|
216
|
+
raise ImportError(_ENCRYPTED_MOCK_ERROR) from None
|
|
217
|
+
|
|
218
|
+
def find_spec(self, fullname, path, target=None):
|
|
219
|
+
if fullname in self.modules:
|
|
220
|
+
return importlib.util.spec_from_loader(
|
|
221
|
+
fullname, _DevModeLoader(self, fullname)
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Runtime instance fingerprinting for per-instance license anomaly detection.
|
|
2
|
+
|
|
3
|
+
This is deliberately separate from context.py's HKDF/KEK-binding collectors:
|
|
4
|
+
those bind artifact decryption to an environment (a security boundary — wrong
|
|
5
|
+
environment, no decrypt). This module produces an anomaly *signal* only — the
|
|
6
|
+
backend compares it against the last fingerprint seen for the same registered
|
|
7
|
+
nonce and flags `shared_nonce_suspected` when they disagree, for a human to
|
|
8
|
+
review. It is not, and does not need
|
|
9
|
+
to be, tamper-proof: the actual access-control boundary remains the signed
|
|
10
|
+
license JWT and its revoked/expired status, checked independently in
|
|
11
|
+
context.py. Worst case if a fingerprint is spoofed or stripped is a missed
|
|
12
|
+
flag, not a license bypass.
|
|
13
|
+
|
|
14
|
+
Fingerprint = sha256(persisted_nonce + machine_signal).
|
|
15
|
+
|
|
16
|
+
The persisted nonce alone proves nothing about machine identity — copying its
|
|
17
|
+
file to a different machine carries the nonce along. What makes that copy
|
|
18
|
+
visible as a *new* instance is the machine_signal half: it's gathered fresh
|
|
19
|
+
from the running machine each time, so a copied nonce on a different host
|
|
20
|
+
still produces a different combined fingerprint. The nonce's only job is
|
|
21
|
+
continuity — recognizing "this is the same install restarting" across process
|
|
22
|
+
restarts on the *same* machine, where machine_signal alone can be too noisy
|
|
23
|
+
(e.g. a fresh container ID on every restart even on the same host).
|
|
24
|
+
|
|
25
|
+
`.nkx/instance_nonce` used to hold a nonce generated locally by this
|
|
26
|
+
module. It now holds the backend-issued registered-instance credential
|
|
27
|
+
instead (instanceauth.ensure_instance_nonce(), the security boundary
|
|
28
|
+
POST /internal/keys/derive checks) — this module just reads whatever is
|
|
29
|
+
there. The two were unified deliberately: one file, one story, instead of a
|
|
30
|
+
second nonce with a second lifecycle to reason about.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
import hashlib
|
|
34
|
+
import os
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from .context import _machine_id, credentials_dir
|
|
38
|
+
|
|
39
|
+
# Core's phone-home cadence. backend/src/lib/runtimeInstances.ts documents the
|
|
40
|
+
# matching staleness margin — the two are independent constants across the
|
|
41
|
+
# Python/TS boundary and must be kept in sync by hand if tuned.
|
|
42
|
+
HEARTBEAT_INTERVAL_SECONDS = 600 # 10 min
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _persisted_nonce(repo_root: str | os.PathLike[str] | None = None) -> str:
|
|
46
|
+
"""Read the instance nonce persisted by instanceauth.py.
|
|
47
|
+
|
|
48
|
+
Returns "" if it isn't there yet (e.g. registration hasn't run, or this
|
|
49
|
+
deployment predates the registered-instance exchange) — callers treat a
|
|
50
|
+
missing/empty fingerprint input as non-fatal (see
|
|
51
|
+
context._instance_fingerprint_header()), so this degrades to a slightly
|
|
52
|
+
noisier detection signal rather than raising.
|
|
53
|
+
"""
|
|
54
|
+
nonce_file = credentials_dir(repo_root) / "instance_nonce"
|
|
55
|
+
if nonce_file.exists():
|
|
56
|
+
return nonce_file.read_text().strip()
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _pod_uid() -> str:
|
|
61
|
+
"""Kubernetes Downward API — requires the pod to expose POD_UID via
|
|
62
|
+
`fieldRef: metadata.uid` in its env. See core/README.md for the manifest
|
|
63
|
+
snippet operators need to add.
|
|
64
|
+
"""
|
|
65
|
+
return os.environ.get("POD_UID", "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _container_id() -> str:
|
|
69
|
+
"""Docker container ID, parsed from cgroup info. Ephemeral — changes on
|
|
70
|
+
every container recreate, which is the correct behavior (a recreated
|
|
71
|
+
container is a new instance for concurrency-limit purposes).
|
|
72
|
+
"""
|
|
73
|
+
for cgroup_path in ("/proc/self/cgroup", "/proc/1/cpuset"):
|
|
74
|
+
try:
|
|
75
|
+
text = Path(cgroup_path).read_text()
|
|
76
|
+
except Exception:
|
|
77
|
+
continue
|
|
78
|
+
for line in text.splitlines():
|
|
79
|
+
# Docker cgroup lines look like ".../docker/<64-char-id>"
|
|
80
|
+
parts = line.strip().split("/")
|
|
81
|
+
if parts and len(parts[-1]) == 64 and all(c in "0123456789abcdef" for c in parts[-1]):
|
|
82
|
+
return parts[-1]
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fallback_machine_id_file(repo_root: str | os.PathLike[str] | None = None) -> Path:
|
|
87
|
+
return credentials_dir(repo_root) / "machine_signal"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _nic_mac(repo_root: str | os.PathLike[str] | None = None) -> str:
|
|
91
|
+
"""Last-resort machine signal, when no pod UID / container id / OS
|
|
92
|
+
machine-id is available.
|
|
93
|
+
|
|
94
|
+
uuid.getnode() is NOT stable here, despite this being the "last resort
|
|
95
|
+
but still stable" tier: per Python's own documented behavior, when no
|
|
96
|
+
real hardware MAC can be found — the common case for exactly the
|
|
97
|
+
containers/sandboxes that reach this fallback — it synthesizes a
|
|
98
|
+
random 48-bit value fresh on every call. Every restart then looks like
|
|
99
|
+
a brand-new machine, making concurrency-anomaly detection permanently
|
|
100
|
+
noisy for that deployment (audit finding B15). Persist a random id to
|
|
101
|
+
local disk the first time instead, so later restarts on the same
|
|
102
|
+
host/container read the same value back — mirrors instanceauth.py's
|
|
103
|
+
instance_nonce persistence, but best-effort only: this is a detection
|
|
104
|
+
signal, not a security boundary (see module docstring), so a
|
|
105
|
+
filesystem hiccup here just degrades to the old per-process-random
|
|
106
|
+
behavior for that one run rather than failing anything.
|
|
107
|
+
"""
|
|
108
|
+
import uuid
|
|
109
|
+
|
|
110
|
+
path = _fallback_machine_id_file(repo_root)
|
|
111
|
+
try:
|
|
112
|
+
if path.exists():
|
|
113
|
+
existing = path.read_text().strip()
|
|
114
|
+
if existing:
|
|
115
|
+
return existing
|
|
116
|
+
value = uuid.uuid4().hex
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
path.write_text(value)
|
|
119
|
+
return value
|
|
120
|
+
except Exception:
|
|
121
|
+
return str(uuid.getnode())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _machine_signal(repo_root: str | os.PathLike[str] | None = None) -> str:
|
|
125
|
+
"""Best-effort machine identity, in priority order: Kubernetes pod UID,
|
|
126
|
+
Docker container ID, OS machine-id (reused from context.py), NIC MAC as
|
|
127
|
+
last resort. Each is empty-string on failure; the first non-empty wins.
|
|
128
|
+
"""
|
|
129
|
+
for collector in (_pod_uid, _container_id, _machine_id):
|
|
130
|
+
try:
|
|
131
|
+
value = collector()
|
|
132
|
+
except Exception:
|
|
133
|
+
value = ""
|
|
134
|
+
if value:
|
|
135
|
+
return value
|
|
136
|
+
# _nic_mac is called outside the loop, not wrapped in a lambda inside it,
|
|
137
|
+
# because it is the only collector that takes an argument — and the loop
|
|
138
|
+
# swallows every exception, so an arity mistake there would read as "this
|
|
139
|
+
# machine has no NIC" and silently degrade to "unknown" forever.
|
|
140
|
+
try:
|
|
141
|
+
return _nic_mac(repo_root) or "unknown"
|
|
142
|
+
except Exception:
|
|
143
|
+
return "unknown"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_fingerprint(repo_root: str | os.PathLike[str] | None = None) -> str:
|
|
147
|
+
"""Combined instance fingerprint sent on every validate call.
|
|
148
|
+
|
|
149
|
+
`repo_root` reaches the same credentials directory instanceauth.py wrote
|
|
150
|
+
the nonce to. Optional because this is a detection signal that degrades
|
|
151
|
+
rather than failing (see the module docstring), but a caller that has a
|
|
152
|
+
root should always pass it: reading the nonce from somewhere other than
|
|
153
|
+
where registration put it hashes an empty string and quietly makes every
|
|
154
|
+
restart look like a different machine.
|
|
155
|
+
"""
|
|
156
|
+
nonce = _persisted_nonce(repo_root)
|
|
157
|
+
signal = _machine_signal(repo_root)
|
|
158
|
+
return hashlib.sha256(f"{nonce}:{signal}".encode()).hexdigest()
|