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,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()