nvdc 0.2.1__tar.gz → 0.2.2__tar.gz

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.
Files changed (43) hide show
  1. {nvdc-0.2.1 → nvdc-0.2.2}/PKG-INFO +11 -1
  2. {nvdc-0.2.1 → nvdc-0.2.2}/README.md +10 -0
  3. {nvdc-0.2.1 → nvdc-0.2.2}/pyproject.toml +1 -1
  4. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/__init__.py +1 -1
  5. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/agent.py +16 -1
  6. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/cli.py +4 -0
  7. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/coordinator.py +81 -12
  8. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/keys.py +16 -3
  9. nvdc-0.2.2/src/nvdc/models.py +100 -0
  10. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/protocol.py +1 -0
  11. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc.egg-info/PKG-INFO +11 -1
  12. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc.egg-info/SOURCES.txt +2 -0
  13. nvdc-0.2.2/tests/test_model_attestation.py +195 -0
  14. {nvdc-0.2.1 → nvdc-0.2.2}/setup.cfg +0 -0
  15. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/accounts.py +0 -0
  16. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/app.py +0 -0
  17. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/attestation.py +0 -0
  18. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/catalog.py +0 -0
  19. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/config.py +0 -0
  20. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/gpu.py +0 -0
  21. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/hardware.py +0 -0
  22. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/house.py +0 -0
  23. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/inference.py +0 -0
  24. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/payments.py +0 -0
  25. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/runtime.py +0 -0
  26. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/storage.py +0 -0
  27. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/update.py +0 -0
  28. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/wallet.py +0 -0
  29. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc/web/index.html +0 -0
  30. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc.egg-info/dependency_links.txt +0 -0
  31. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc.egg-info/entry_points.txt +0 -0
  32. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc.egg-info/requires.txt +0 -0
  33. {nvdc-0.2.1 → nvdc-0.2.2}/src/nvdc.egg-info/top_level.txt +0 -0
  34. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_accounts_auth.py +0 -0
  35. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_coordinator_security.py +0 -0
  36. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_house_node.py +0 -0
  37. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_persistence.py +0 -0
  38. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_self_serve.py +0 -0
  39. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_self_update.py +0 -0
  40. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_split_pricing.py +0 -0
  41. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_storage_money.py +0 -0
  42. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_stripe_webhook.py +0 -0
  43. {nvdc-0.2.1 → nvdc-0.2.2}/tests/test_version_gate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nvdc
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Bring your GPU onto the network: one command turns a GPU into a verifiable, OpenAI-compatible inference node.
5
5
  Author: NVDC
6
6
  License: Apache-2.0
@@ -150,6 +150,16 @@ Attestation is a first-class, pluggable component (`nvdc/attestation.py`):
150
150
  A coordinator can enforce policy with `--require-attested` to only route work to
151
151
  nodes whose attestation verifies.
152
152
 
153
+ **Model identity** is attested separately from hardware. Every node publishes a
154
+ content-addressed commitment of the artifact it serves (the Ollama manifest
155
+ digest) and signs it into each work receipt (`nvdc-work-v2`). The coordinator
156
+ independently checks the claim against `registry.ollama.ai` and surfaces a
157
+ verdict (`verified` / `mismatch` / `unknown`) per node and per receipt;
158
+ `--require-verified-models` restricts routing to verified artifacts. Without a
159
+ TEE this cannot *prove* which bytes ran — what it guarantees is that a node
160
+ claiming one model while serving another has signed a non-repudiable false
161
+ statement that contradicts public ground truth.
162
+
153
163
  > Note: the DGX Spark / GB10 cannot produce hardware attestation (NVIDIA disabled
154
164
  > CC on this SKU). It serves inference fine; it just joins as an unattested node.
155
165
 
@@ -128,6 +128,16 @@ Attestation is a first-class, pluggable component (`nvdc/attestation.py`):
128
128
  A coordinator can enforce policy with `--require-attested` to only route work to
129
129
  nodes whose attestation verifies.
130
130
 
131
+ **Model identity** is attested separately from hardware. Every node publishes a
132
+ content-addressed commitment of the artifact it serves (the Ollama manifest
133
+ digest) and signs it into each work receipt (`nvdc-work-v2`). The coordinator
134
+ independently checks the claim against `registry.ollama.ai` and surfaces a
135
+ verdict (`verified` / `mismatch` / `unknown`) per node and per receipt;
136
+ `--require-verified-models` restricts routing to verified artifacts. Without a
137
+ TEE this cannot *prove* which bytes ran — what it guarantees is that a node
138
+ claiming one model while serving another has signed a non-repudiable false
139
+ statement that contradicts public ground truth.
140
+
131
141
  > Note: the DGX Spark / GB10 cannot produce hardware attestation (NVIDIA disabled
132
142
  > CC on this SKU). It serves inference fine; it just joins as an unattested node.
133
143
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nvdc"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Bring your GPU onto the network: one command turns a GPU into a verifiable, OpenAI-compatible inference node."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """nvdc: bring your GPU onto the network as a verifiable, OpenAI-compatible inference node."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -21,6 +21,7 @@ from .gpu import detect_gpu, detect_gpus, detect_interconnect
21
21
  from .hardware import detect_hardware, machine_id as hw_machine_id
22
22
  from .inference import Backend, make_backend
23
23
  from .keys import Identity
24
+ from .models import local_ollama_commit
24
25
 
25
26
  log = logging.getLogger("nvdc.agent")
26
27
 
@@ -87,6 +88,7 @@ class NodeAgent:
87
88
  self.cluster = cluster
88
89
  self.private = private
89
90
  self.auto_update = auto_update
91
+ self.model_commit = "" # set at registration from the backend's store
90
92
  self.token = token
91
93
  self.require_attestation = require_attestation
92
94
  self.status_cb = status_cb
@@ -211,6 +213,17 @@ class NodeAgent:
211
213
  raise RuntimeError(
212
214
  f"attestation required but not verified: {att.reason or att.mode}"
213
215
  )
216
+ # Model identity commitment: the content digest of the artifact this
217
+ # node will serve. Signed into every work receipt (nvdc-work-v2) and
218
+ # checked by the coordinator against the public registry.
219
+ if getattr(self.backend, "name", "") == "ollama":
220
+ self.model_commit = await local_ollama_commit(
221
+ self.model, getattr(self.backend, "base_url", "http://localhost:11434"))
222
+ if self.model_commit:
223
+ log.info("model commitment: %s -> %s", self.model, self.model_commit[:23] + "…")
224
+ else:
225
+ log.warning("could not derive a model commitment for '%s'; "
226
+ "receipts will not carry a model identity", self.model)
214
227
  profile = protocol.NodeProfile(
215
228
  name=self.name,
216
229
  models=[self.model],
@@ -229,6 +242,7 @@ class NodeAgent:
229
242
  machine_id=self.machine_id,
230
243
  cluster=self.cluster,
231
244
  private=self.private,
245
+ model_commit=self.model_commit,
232
246
  agent_version=__version__,
233
247
  )
234
248
  await self._send(protocol.MSG_REGISTER, profile=protocol.node_profile_to_dict(profile))
@@ -324,7 +338,8 @@ class NodeAgent:
324
338
 
325
339
  def _sign_work(self, req_id, prompt_commit, tokens, response_commit, delivery) -> str:
326
340
  payload = keys.work_payload(req_id, self.model, prompt_commit, tokens,
327
- response_commit, delivery)
341
+ response_commit, delivery,
342
+ model_commit=self.model_commit)
328
343
  return self.identity.sign(payload)
329
344
 
330
345
  async def _send(self, msg_type: str, **fields: Any):
@@ -92,6 +92,7 @@ def cmd_coordinator(args: argparse.Namespace) -> int:
92
92
  require_payment=not args.no_payment,
93
93
  min_agent_version=args.min_agent_version
94
94
  or os.environ.get("NVDC_MIN_AGENT_VERSION", ""),
95
+ require_verified_models=args.require_verified_models or None,
95
96
  )
96
97
  print(f"nvdc coordinator on http://{args.host}:{args.port}")
97
98
  print(f" OpenAI endpoint : http://{args.host}:{args.port}/v1/chat/completions")
@@ -199,6 +200,9 @@ def build_parser() -> argparse.ArgumentParser:
199
200
  c.add_argument("--min-agent-version", default="",
200
201
  help="refuse agents older than this version at registration "
201
202
  "(or NVDC_MIN_AGENT_VERSION); empty = no gate")
203
+ c.add_argument("--require-verified-models", action="store_true",
204
+ help="only route to nodes whose claimed model digest matches "
205
+ "the public registry (or NVDC_REQUIRE_VERIFIED_MODELS)")
202
206
  c.set_defaults(func=cmd_coordinator)
203
207
 
204
208
  a = sub.add_parser("app", help="launch the visual node client (web UI)")
@@ -30,6 +30,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
30
30
  from starlette.concurrency import run_in_threadpool
31
31
 
32
32
  from . import __version__, accounts, keys, payments, protocol
33
+ from . import models as model_identity
33
34
  from .keys import Identity
34
35
  from .protocol import version_tuple
35
36
  from .storage import Account, Storage, make_storage, to_micro, to_usd
@@ -140,10 +141,14 @@ def _verify_node(r: Dict[str, Any]) -> bool:
140
141
  sig = r.get("node_sig", "")
141
142
  if not sig:
142
143
  return False
143
- payload = keys.work_payload(
144
- r.get("req_id", ""), r.get("model", ""), r.get("prompt_commit", ""),
145
- r.get("tokens", 0), r.get("response_commit", ""), r.get("delivery", ""))
146
- return keys.verify(r.get("miner_account", ""), payload, sig)
144
+ args = (r.get("req_id", ""), r.get("model", ""), r.get("prompt_commit", ""),
145
+ r.get("tokens", 0), r.get("response_commit", ""), r.get("delivery", ""))
146
+ account = r.get("node_account", "") or r.get("miner_account", "")
147
+ # v2 (signature covers the model artifact digest), then legacy v1.
148
+ if r.get("model_commit"):
149
+ if keys.verify(account, keys.work_payload(*args, model_commit=r["model_commit"]), sig):
150
+ return True
151
+ return keys.verify(account, keys.work_payload(*args), sig)
147
152
 
148
153
 
149
154
  def build_clusters(machines: List[Dict[str, Any]], live_ids: set) -> List[Dict[str, Any]]:
@@ -181,6 +186,11 @@ class Node:
181
186
  self.owner_account = profile.get("owner_account", "") or self.account_id
182
187
  self.machine_id = profile.get("machine_id", "") or self.id
183
188
  self.cluster = profile.get("cluster", "") or ""
189
+ # Model identity: the node's claimed artifact digest, and the verdict
190
+ # from checking that claim against the public registry at registration
191
+ # ("verified" | "mismatch" | "unknown").
192
+ self.model_commit = str(profile.get("model_commit", "") or "")
193
+ self.model_verdict = "unknown"
184
194
  self.draining = False
185
195
  self.pending: Dict[str, PendingRequest] = {}
186
196
  self._send_lock = asyncio.Lock()
@@ -255,6 +265,8 @@ class Node:
255
265
  "price_per_mtok": round(self.price_per_mtok, 6),
256
266
  "price_per_mtok_input": round(self.price_per_mtok_input, 6),
257
267
  "private": self.private,
268
+ "model_commit": self.model_commit,
269
+ "model_verdict": self.model_verdict,
258
270
  "attested": self.attested,
259
271
  "attestation_mode": self.attestation.get("mode", "none"),
260
272
  "agent_version": self.profile.get("agent_version", ""),
@@ -339,7 +351,8 @@ class Registry:
339
351
 
340
352
  def pick(self, model: str = "", models: Optional[List[str]] = None,
341
353
  require_attested: bool = False, pin: str = "", min_rating: int = 0,
342
- max_price: Optional[float] = None, consumer: str = "") -> Optional[Node]:
354
+ max_price: Optional[float] = None, consumer: str = "",
355
+ require_verified: bool = False) -> Optional[Node]:
343
356
  def model_ok(n: Node) -> bool:
344
357
  if model:
345
358
  return model in n.models
@@ -357,6 +370,8 @@ class Registry:
357
370
  # Private nodes serve only their owner (personal AI mode).
358
371
  and (not n.private or own(n))
359
372
  and (not require_attested or n.attested)
373
+ # Strict networks only route to registry-verified model artifacts.
374
+ and (not require_verified or n.model_verdict == "verified")
360
375
  and n.rating() >= min_rating
361
376
  # A max price never excludes your own node — it's free for you.
362
377
  and (max_price is None or own(n)
@@ -455,11 +470,18 @@ class Registry:
455
470
 
456
471
  def create_app(auth_token: str = "", require_attested: bool = False,
457
472
  require_payment: bool = True,
458
- min_agent_version: Optional[str] = None) -> FastAPI:
473
+ min_agent_version: Optional[str] = None,
474
+ require_verified_models: Optional[bool] = None) -> FastAPI:
459
475
  # Minimum agent version allowed to JOIN the network (older agents are
460
476
  # refused at the door with a clear upgrade message). Empty = no gate.
461
477
  if min_agent_version is None:
462
478
  min_agent_version = os.environ.get("NVDC_MIN_AGENT_VERSION", "").strip()
479
+ # Strict model-identity policy: only route to nodes whose claimed model
480
+ # digest matches the public registry. Default off — verdicts are always
481
+ # computed and surfaced either way.
482
+ if require_verified_models is None:
483
+ require_verified_models = bool(
484
+ os.environ.get("NVDC_REQUIRE_VERIFIED_MODELS", "").strip())
463
485
  app = FastAPI(title="nvdc coordinator")
464
486
  # Allow browser clients (e.g. the Vercel static site) to read the network
465
487
  # and stream chat. Inference billing identifies accounts by header, not
@@ -489,6 +511,34 @@ def create_app(auth_token: str = "", require_attested: bool = False,
489
511
  node.restore_stats(rec)
490
512
  return
491
513
 
514
+ # Registry ground truth for model digests, cached so registration doesn't
515
+ # hammer registry.ollama.ai (tags are mutable, hence the TTL refresh).
516
+ _model_digest_cache: Dict[str, tuple] = {}
517
+
518
+ async def expected_model_digest(model: str) -> str:
519
+ hit = _model_digest_cache.get(model)
520
+ if hit and time.time() - hit[1] < 6 * 3600:
521
+ return hit[0]
522
+ digest = await model_identity.fetch_registry_digest(model)
523
+ _model_digest_cache[model] = (digest, time.time())
524
+ return digest
525
+
526
+ async def _verify_node_model(node: Node) -> None:
527
+ """Check the node's claimed artifact digest against the registry."""
528
+ if not node.model_commit:
529
+ return # no claim made -> verdict stays "unknown"
530
+ try:
531
+ expected = await expected_model_digest(node.model)
532
+ except Exception:
533
+ expected = ""
534
+ node.model_verdict = model_identity.model_verdict(node.model_commit, expected)
535
+ if node.model_verdict == "mismatch":
536
+ log.warning(
537
+ "model commitment MISMATCH: node '%s' claims %s for '%s' but the "
538
+ "registry says %s", node.profile.get("name"),
539
+ node.model_commit[:19] + "…", node.model,
540
+ (expected[:19] + "…") if expected else "?")
541
+
492
542
  # House node: a coordinator-hosted miner backed by the Cerebras API, so
493
543
  # the network always has at least one live node. Created only here from
494
544
  # the coordinator's own environment — the public node protocol has no way
@@ -543,6 +593,7 @@ def create_app(auth_token: str = "", require_attested: bool = False,
543
593
  node_id = uuid.uuid4().hex[:12]
544
594
  node = Node(node_id, ws, first.get("profile", {}))
545
595
  _rehydrate_stats(node) # a reconnect is not a brand-new miner
596
+ await _verify_node_model(node) # registry-anchored model identity
546
597
  registry.add(node)
547
598
  # Persist this machine under its owner so the account can list its
548
599
  # clusters/machines even after the node disconnects.
@@ -691,7 +742,8 @@ def create_app(auth_token: str = "", require_attested: bool = False,
691
742
  node = registry.pick(model, models=models_filter or None,
692
743
  require_attested=require_attested, pin=pin,
693
744
  min_rating=min_rating, max_price=max_price,
694
- consumer=consumer_id)
745
+ consumer=consumer_id,
746
+ require_verified=require_verified_models)
695
747
  if node is None:
696
748
  why = f"no available miner for model '{model or ','.join(models_filter)}'"
697
749
  if pin:
@@ -774,6 +826,7 @@ def create_app(auth_token: str = "", require_attested: bool = False,
774
826
  "max_price": round(max_price, 8) if max_price is not None else -1,
775
827
  "nonce": nonce, "auth_ts": auth_ts, "hold_u": hold_u,
776
828
  "price_in": price_in, "self_serve": self_serve,
829
+ "model_commit": node.model_commit, "model_verdict": node.model_verdict,
777
830
  "consumer_sig": consumer_sig, "consumer_sig_valid": consumer_sig_valid,
778
831
  }
779
832
  base_headers = {
@@ -801,13 +854,26 @@ def create_app(auth_token: str = "", require_attested: bool = False,
801
854
  # Refresh the persisted machine record so lifetime stats (TPS, rating
802
855
  # inputs, earnings) survive reconnects and coordinator restarts.
803
856
  ledger.record_machine(node.owner_account, node.machine_record())
804
- # Verify the node's completion signature over what it delivered.
857
+ # Verify the node's completion signature over what it delivered. A
858
+ # node that registered a model commitment signs nvdc-work-v2 (the
859
+ # signature also covers the artifact digest); older agents sign v1.
860
+ # The v1 fallback keeps legacy receipts verifiable but records that
861
+ # the model identity was NOT covered by the signature.
805
862
  node_sig_valid = False
863
+ model_commit_signed = False
806
864
  if node_sig:
807
- work = keys.work_payload(req_id, ctx["model"], ctx["prompt_commit"],
808
- tokens, response_commit, delivery)
809
- # The machine signs with its own key; earnings credit the owner.
810
- node_sig_valid = keys.verify(ctx["node_account"], work, node_sig)
865
+ mc = ctx.get("model_commit", "")
866
+ if mc:
867
+ work2 = keys.work_payload(req_id, ctx["model"], ctx["prompt_commit"],
868
+ tokens, response_commit, delivery,
869
+ model_commit=mc)
870
+ # The machine signs with its own key; earnings credit the owner.
871
+ node_sig_valid = keys.verify(ctx["node_account"], work2, node_sig)
872
+ model_commit_signed = node_sig_valid
873
+ if not node_sig_valid:
874
+ work = keys.work_payload(req_id, ctx["model"], ctx["prompt_commit"],
875
+ tokens, response_commit, delivery)
876
+ node_sig_valid = keys.verify(ctx["node_account"], work, node_sig)
811
877
  receipt = ledger.settle(ctx["consumer_id"], ctx["miner_account"], cost, {
812
878
  "req_id": req_id, "model": ctx["model"], "req_model": ctx["req_model"],
813
879
  "miner_node": ctx["node_id"], "node_account": ctx["node_account"], "tokens": tokens,
@@ -815,6 +881,9 @@ def create_app(auth_token: str = "", require_attested: bool = False,
815
881
  "price_per_mtok": round(price, 6),
816
882
  "price_per_mtok_input": round(ctx["price_in"], 6),
817
883
  "self_serve": ctx["self_serve"],
884
+ "model_commit": ctx.get("model_commit", ""),
885
+ "model_commit_signed": model_commit_signed,
886
+ "model_verdict": ctx.get("model_verdict", "unknown"),
818
887
  "delivery": delivery,
819
888
  "attested": ctx["attested"], "prompt_commit": ctx["prompt_commit"],
820
889
  "response_commit": response_commit,
@@ -44,13 +44,26 @@ def auth_payload(consumer: str, model: str, prompt_commit: str,
44
44
 
45
45
 
46
46
  def work_payload(req_id: str, model: str, prompt_commit: str, tokens: int,
47
- response_commit: str, delivery: str) -> Dict[str, Any]:
48
- """What the NODE signs at the end: attestation of work delivered."""
49
- return {
47
+ response_commit: str, delivery: str,
48
+ model_commit: str = "") -> Dict[str, Any]:
49
+ """What the NODE signs at the end: attestation of work delivered.
50
+
51
+ When the node knows the content digest of the artifact it served
52
+ (``model_commit``), the payload upgrades to v2 and the signature also
53
+ covers the model's identity — claiming one model while serving another
54
+ becomes a signed, non-repudiable false statement. With no commitment the
55
+ payload is byte-identical to v1, so existing nodes and receipts verify
56
+ unchanged.
57
+ """
58
+ payload = {
50
59
  "kind": "nvdc-work-v1", "req_id": req_id, "model": model,
51
60
  "prompt_commit": prompt_commit, "tokens": int(tokens),
52
61
  "response_commit": response_commit, "delivery": delivery,
53
62
  }
63
+ if model_commit:
64
+ payload["kind"] = "nvdc-work-v2"
65
+ payload["model_commit"] = model_commit
66
+ return payload
54
67
 
55
68
 
56
69
  def account_from_pubkey(pub: Ed25519PublicKey) -> str:
@@ -0,0 +1,100 @@
1
+ """Model identity: content-addressed commitments for served models.
2
+
3
+ Hardware attestation says WHERE work ran; this module is about WHAT was run.
4
+ Without a TEE we cannot cryptographically prove which bytes sat in GPU memory,
5
+ so the goal is different and honest: every node publishes a content-addressed
6
+ commitment (digest) of the artifact it serves, signs it into each work receipt
7
+ (``nvdc-work-v2`` in keys.py), and the coordinator independently checks the
8
+ claim against the same public registry nodes pull from. A node can still lie —
9
+ but the lie is now a signed, non-repudiable statement that contradicts public
10
+ ground truth, so it is detectable, attributable and punishable.
11
+
12
+ Ollama's model store is already content-addressed: every model resolves to an
13
+ OCI-style manifest whose sha256 digest uniquely identifies weights + config.
14
+ ``registry.ollama.ai`` serves those manifests publicly, which gives the
15
+ coordinator an authoritative expected digest for every catalog tag.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import logging
22
+ from typing import Any, Dict
23
+
24
+ import httpx
25
+
26
+ log = logging.getLogger("nvdc.models")
27
+
28
+ OLLAMA_REGISTRY = "https://registry.ollama.ai"
29
+
30
+
31
+ def _norm_digest(d: str) -> str:
32
+ d = str(d or "").strip()
33
+ if not d:
34
+ return ""
35
+ return d if d.startswith("sha256:") else "sha256:" + d
36
+
37
+
38
+ def _pick_digest(tags_json: Dict[str, Any], model: str) -> str:
39
+ """Pull the digest of ``model`` out of an Ollama ``/api/tags`` response."""
40
+ want = model if ":" in model else model + ":latest"
41
+ for m in tags_json.get("models", []):
42
+ if m.get("name") == want or m.get("model") == want:
43
+ return _norm_digest(m.get("digest", ""))
44
+ return ""
45
+
46
+
47
+ async def local_ollama_commit(model: str, ollama_url: str) -> str:
48
+ """The content digest of the locally installed model, or "" if unknown.
49
+
50
+ Reported by the same daemon that will execute the inference — i.e. the
51
+ runtime's own claim of identity, which the node then signs.
52
+ """
53
+ try:
54
+ async with httpx.AsyncClient(timeout=10) as c:
55
+ r = await c.get(f"{ollama_url.rstrip('/')}/api/tags")
56
+ r.raise_for_status()
57
+ return _pick_digest(r.json(), model)
58
+ except Exception as e:
59
+ log.debug("could not read local model digest for %s: %s", model, e)
60
+ return ""
61
+
62
+
63
+ async def fetch_registry_digest(model: str) -> str:
64
+ """Authoritative digest for a library tag from registry.ollama.ai.
65
+
66
+ Returns "" when unavailable (custom/private models, network failure) —
67
+ callers must treat that as "unknown", never as a mismatch.
68
+ """
69
+ if "/" in model: # only library/* tags have a public canonical digest
70
+ return ""
71
+ name, _, tag = model.partition(":")
72
+ if not name:
73
+ return ""
74
+ url = f"{OLLAMA_REGISTRY}/v2/library/{name}/manifests/{tag or 'latest'}"
75
+ headers = {"Accept": "application/vnd.docker.distribution.manifest.v2+json"}
76
+ try:
77
+ async with httpx.AsyncClient(timeout=8, follow_redirects=True) as c:
78
+ r = await c.get(url, headers=headers)
79
+ if r.status_code != 200:
80
+ return ""
81
+ dcd = r.headers.get("docker-content-digest", "")
82
+ if dcd:
83
+ return _norm_digest(dcd)
84
+ # OCI content addressing: the digest IS the sha256 of the bytes.
85
+ return "sha256:" + hashlib.sha256(r.content).hexdigest()
86
+ except Exception as e:
87
+ log.debug("registry digest fetch failed for %s: %s", model, e)
88
+ return ""
89
+
90
+
91
+ def model_verdict(claimed: str, expected: str) -> str:
92
+ """Compare a node's claimed digest with registry ground truth.
93
+
94
+ 'unknown' whenever either side is missing — absence of evidence is not a
95
+ mismatch. 'mismatch' only when both digests exist and differ.
96
+ """
97
+ claimed, expected = _norm_digest(claimed), _norm_digest(expected)
98
+ if not claimed or not expected:
99
+ return "unknown"
100
+ return "verified" if claimed == expected else "mismatch"
@@ -95,6 +95,7 @@ class NodeProfile:
95
95
  machine_id: str = "" # stable per-machine id (survives reconnects)
96
96
  cluster: str = "" # optional cluster label grouping machines
97
97
  private: bool = False # personal mode: serve only the owner account
98
+ model_commit: str = "" # content digest of the served model artifact
98
99
  agent_version: str = ""
99
100
  node_id: Optional[str] = None
100
101
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nvdc
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Bring your GPU onto the network: one command turns a GPU into a verifiable, OpenAI-compatible inference node.
5
5
  Author: NVDC
6
6
  License: Apache-2.0
@@ -150,6 +150,16 @@ Attestation is a first-class, pluggable component (`nvdc/attestation.py`):
150
150
  A coordinator can enforce policy with `--require-attested` to only route work to
151
151
  nodes whose attestation verifies.
152
152
 
153
+ **Model identity** is attested separately from hardware. Every node publishes a
154
+ content-addressed commitment of the artifact it serves (the Ollama manifest
155
+ digest) and signs it into each work receipt (`nvdc-work-v2`). The coordinator
156
+ independently checks the claim against `registry.ollama.ai` and surfaces a
157
+ verdict (`verified` / `mismatch` / `unknown`) per node and per receipt;
158
+ `--require-verified-models` restricts routing to verified artifacts. Without a
159
+ TEE this cannot *prove* which bytes ran — what it guarantees is that a node
160
+ claiming one model while serving another has signed a non-repudiable false
161
+ statement that contradicts public ground truth.
162
+
153
163
  > Note: the DGX Spark / GB10 cannot produce hardware attestation (NVIDIA disabled
154
164
  > CC on this SKU). It serves inference fine; it just joins as an unattested node.
155
165
 
@@ -14,6 +14,7 @@ src/nvdc/hardware.py
14
14
  src/nvdc/house.py
15
15
  src/nvdc/inference.py
16
16
  src/nvdc/keys.py
17
+ src/nvdc/models.py
17
18
  src/nvdc/payments.py
18
19
  src/nvdc/protocol.py
19
20
  src/nvdc/runtime.py
@@ -30,6 +31,7 @@ src/nvdc/web/index.html
30
31
  tests/test_accounts_auth.py
31
32
  tests/test_coordinator_security.py
32
33
  tests/test_house_node.py
34
+ tests/test_model_attestation.py
33
35
  tests/test_persistence.py
34
36
  tests/test_self_serve.py
35
37
  tests/test_self_update.py
@@ -0,0 +1,195 @@
1
+ """Model identity: signed commitments, registry verdicts, routing policy."""
2
+
3
+ import hashlib
4
+ import json
5
+
6
+ import pytest
7
+ from starlette.testclient import TestClient
8
+
9
+ from nvdc import keys, models, protocol
10
+ from nvdc.coordinator import Node, Registry, create_app
11
+ from nvdc.models import _pick_digest, model_verdict
12
+
13
+ DIG = "sha256:" + "a" * 64
14
+ OTHER = "sha256:" + "b" * 64
15
+
16
+
17
+ # ---- pure pieces -------------------------------------------------------------
18
+ def test_work_payload_v1_is_byte_identical_without_commitment():
19
+ p = keys.work_payload("r1", "m1", "pc", 5, "rc", "complete")
20
+ assert p == {"kind": "nvdc-work-v1", "req_id": "r1", "model": "m1",
21
+ "prompt_commit": "pc", "tokens": 5, "response_commit": "rc",
22
+ "delivery": "complete"}
23
+
24
+
25
+ def test_work_payload_v2_signature_covers_model_identity(identity):
26
+ v2 = keys.work_payload("r1", "m1", "pc", 5, "rc", "complete", model_commit=DIG)
27
+ assert v2["kind"] == "nvdc-work-v2" and v2["model_commit"] == DIG
28
+ sig = identity.sign(v2)
29
+ assert keys.verify(identity.account_id, v2, sig)
30
+ # The same signature must NOT verify a payload claiming a different model.
31
+ forged = keys.work_payload("r1", "m1", "pc", 5, "rc", "complete", model_commit=OTHER)
32
+ assert not keys.verify(identity.account_id, forged, sig)
33
+ # ...nor strip the commitment and pass as v1.
34
+ v1 = keys.work_payload("r1", "m1", "pc", 5, "rc", "complete")
35
+ assert not keys.verify(identity.account_id, v1, sig)
36
+
37
+
38
+ def test_verdict_logic():
39
+ assert model_verdict("", DIG) == "unknown" # no claim
40
+ assert model_verdict(DIG, "") == "unknown" # no ground truth
41
+ assert model_verdict(DIG, DIG) == "verified"
42
+ assert model_verdict("a" * 64, DIG) == "verified" # prefix normalization
43
+ assert model_verdict(DIG, OTHER) == "mismatch"
44
+
45
+
46
+ def test_pick_digest_from_ollama_tags():
47
+ tags = {"models": [
48
+ {"name": "llama3.1:8b", "digest": "a" * 64},
49
+ {"name": "mistral:latest", "digest": "sha256:" + "c" * 64},
50
+ ]}
51
+ assert _pick_digest(tags, "llama3.1:8b") == DIG
52
+ assert _pick_digest(tags, "mistral") == "sha256:" + "c" * 64
53
+ assert _pick_digest(tags, "missing:1b") == ""
54
+
55
+
56
+ # ---- registration verdicts ---------------------------------------------------
57
+ def _register(client, commit, model="llama3.1:8b"):
58
+ ws = client.websocket_connect("/node/ws")
59
+ ws.__enter__()
60
+ ws.send_text(protocol.encode(protocol.MSG_REGISTER, profile={
61
+ "name": "gpu", "account_id": "nvdc_machinekey",
62
+ "owner_account": "nvdc_owner", "machine_id": "m1",
63
+ "models": [model], "price_per_mtok": 1.0,
64
+ "model_commit": commit, "agent_version": "0.2.2",
65
+ "attestation": {"verified": False}}))
66
+ ack = json.loads(ws.receive_text())
67
+ return ws, ack
68
+
69
+
70
+ @pytest.mark.parametrize("claimed,expected,verdict", [
71
+ (DIG, DIG, "verified"),
72
+ (OTHER, DIG, "mismatch"),
73
+ ("", DIG, "unknown"), # legacy agent: no claim, never penalized
74
+ ])
75
+ def test_registration_checks_claim_against_registry(monkeypatch, claimed, expected, verdict):
76
+ async def fake_registry(model):
77
+ return expected
78
+ monkeypatch.setattr(models, "fetch_registry_digest", fake_registry)
79
+ app = create_app(require_payment=False)
80
+ client = TestClient(app)
81
+ ws, ack = _register(client, claimed)
82
+ node = app.state.registry.nodes[ack["node_id"]]
83
+ assert node.model_verdict == verdict
84
+ d = node.to_dict()
85
+ assert d["model_verdict"] == verdict and d["model_commit"] == claimed
86
+ ws.__exit__(None, None, None)
87
+
88
+
89
+ def test_registry_unreachable_is_unknown_not_mismatch(monkeypatch):
90
+ async def broken(model):
91
+ raise RuntimeError("registry down")
92
+ monkeypatch.setattr(models, "fetch_registry_digest", broken)
93
+ app = create_app(require_payment=False)
94
+ ws, ack = _register(TestClient(app), DIG)
95
+ assert app.state.registry.nodes[ack["node_id"]].model_verdict == "unknown"
96
+ ws.__exit__(None, None, None)
97
+
98
+
99
+ # ---- routing policy ----------------------------------------------------------
100
+ def _node(verdict, node_id="n1"):
101
+ n = Node(node_id, None, {"name": node_id, "models": ["m1"],
102
+ "price_per_mtok": 1.0})
103
+ n.model_verdict = verdict
104
+ return n
105
+
106
+
107
+ def test_strict_mode_routes_only_to_verified():
108
+ reg = Registry()
109
+ reg.add(_node("mismatch", "bad"))
110
+ reg.add(_node("unknown", "meh"))
111
+ assert reg.pick("m1", require_verified=True) is None
112
+ ok = _node("verified", "good")
113
+ reg.add(ok)
114
+ assert reg.pick("m1", require_verified=True) is ok
115
+ # Default policy: verdicts are informational, everyone routable.
116
+ assert reg.pick("m1") is not None
117
+
118
+
119
+ # ---- receipts ----------------------------------------------------------------
120
+ class _SigningNode(Node):
121
+ """In-process node that signs its work like a real v2 agent."""
122
+
123
+ def __init__(self, identity, commit):
124
+ super().__init__("s1", None, {
125
+ "name": "signer", "account_id": identity.account_id,
126
+ "owner_account": "nvdc_sowner", "machine_id": "sm1",
127
+ "models": ["m1"], "price_per_mtok": 1.0, "model_commit": commit,
128
+ "attestation": {"verified": False}})
129
+ self._identity = identity
130
+ self.model_verdict = "verified"
131
+
132
+ async def send(self, msg_type, **fields):
133
+ if msg_type != protocol.MSG_INFER:
134
+ return
135
+ req_id, body = fields["id"], fields["body"]
136
+ pc = hashlib.sha256(json.dumps(body.get("messages", []),
137
+ sort_keys=True).encode()).hexdigest()
138
+ rc = hashlib.sha256(b"ok").hexdigest()
139
+ sig = self._identity.sign(keys.work_payload(
140
+ req_id, "m1", pc, 7, rc, "complete", model_commit=self.model_commit))
141
+ self.pending[req_id].queue.put_nowait({
142
+ "t": protocol.MSG_RESULT, "id": req_id,
143
+ "body": {"choices": [{"message": {"role": "assistant", "content": "ok"}}],
144
+ "usage": {"prompt_tokens": 3, "completion_tokens": 7}},
145
+ "tokens": 7, "prompt_tokens": 3, "response_commit": rc, "sig": sig})
146
+
147
+
148
+ def test_receipt_carries_signed_model_commitment(identity):
149
+ app = create_app(require_payment=False)
150
+ app.state.registry.add(_SigningNode(identity, DIG))
151
+ client = TestClient(app)
152
+ r = client.post("/v1/chat/completions",
153
+ headers={"X-NVDC-Account": "nvdc_consumer"},
154
+ json={"model": "m1", "messages": [{"role": "user", "content": "hi"}]})
155
+ assert r.status_code == 200
156
+ receipt = app.state.ledger.recent(1)[0]
157
+ assert receipt["model_commit"] == DIG
158
+ assert receipt["model_commit_signed"] is True
159
+ assert receipt["node_sig_valid"] is True
160
+ assert receipt["model_verdict"] == "verified"
161
+ # /api/verify independently re-verifies the v2 node signature.
162
+ verdict = client.post("/api/verify", json=receipt).json()
163
+ assert verdict["node"] is True
164
+
165
+
166
+ def test_legacy_v1_nodes_still_verify_but_commitment_unsigned(identity):
167
+ class _LegacyNode(_SigningNode):
168
+ def __init__(self, ident):
169
+ super().__init__(ident, commit="") # pre-0.2.2: no commitment
170
+
171
+ async def send(self, msg_type, **fields):
172
+ if msg_type != protocol.MSG_INFER:
173
+ return
174
+ req_id, body = fields["id"], fields["body"]
175
+ pc = hashlib.sha256(json.dumps(body.get("messages", []),
176
+ sort_keys=True).encode()).hexdigest()
177
+ rc = hashlib.sha256(b"ok").hexdigest()
178
+ sig = self._identity.sign(keys.work_payload(req_id, "m1", pc, 7, rc, "complete"))
179
+ self.pending[req_id].queue.put_nowait({
180
+ "t": protocol.MSG_RESULT, "id": req_id,
181
+ "body": {"choices": [{"message": {"role": "assistant", "content": "ok"}}],
182
+ "usage": {"prompt_tokens": 3, "completion_tokens": 7}},
183
+ "tokens": 7, "prompt_tokens": 3, "response_commit": rc, "sig": sig})
184
+
185
+ app = create_app(require_payment=False)
186
+ app.state.registry.add(_LegacyNode(identity))
187
+ client = TestClient(app)
188
+ r = client.post("/v1/chat/completions",
189
+ headers={"X-NVDC-Account": "nvdc_consumer"},
190
+ json={"model": "m1", "messages": [{"role": "user", "content": "hi"}]})
191
+ assert r.status_code == 200
192
+ receipt = app.state.ledger.recent(1)[0]
193
+ assert receipt["node_sig_valid"] is True # legacy sigs still verify
194
+ assert receipt["model_commit_signed"] is False # but identity not covered
195
+ assert receipt["model_commit"] == ""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes