fix: bind recipe identity to certified artifact bytes (DGR-025)

Append +artifact.<sha256> to the llama.cpp runtime axis, computed from the
exact bytes read by attest_loaded_runtime, so a differently-built shared
object with copied lock values can no longer forge a certified runtime
identity. Node/tracker parsers require the suffix; new test proves a
byte-identical-lock but different-binary artifact produces a different
recipe fingerprint. Regenerates conformance vectors accordingly.

105 passed in tests/test_native_identity_emission.py,
tests/test_runtime_pin_identity.py, tests/test_runtime_recipe_identity.py.
This commit is contained in:
Dobromir Popov
2026-07-21 13:22:02 +03:00
parent 902ecde363
commit 03e97ca31a
10 changed files with 1277 additions and 69 deletions

View File

@@ -73,6 +73,7 @@ RECIPE_IDENTITY_SCHEMA_VERSION = 1
ARTIFACT_DIGEST_DOMAIN = "meshnet.model-artifact.v1"
RECIPE_DIGEST_DOMAIN = "meshnet.runtime-recipe.v1"
SHARD_BINDING_DIGEST_DOMAIN = "meshnet.shard-binding.v1"
TOKENIZER_DIGEST_DOMAIN = "meshnet.tokenizer-identity.v1"
# The axes of a runtime recipe. Every one of these changes the numbers a Shard
# produces, so every one of them is part of identity and none of them may be
@@ -126,12 +127,22 @@ _AXIS_MISMATCH: Mapping[str, str] = {
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
_LLAMA_CPP_RUNTIME_PIN = re.compile(
r"^llama\.cpp@[0-9a-f]{40}\+patchstack\.[0-9a-f]{64}$"
r"^llama\.cpp@[0-9a-f]{40}\+patchstack\.[0-9a-f]{64}"
r"\+build\.[0-9a-f]{64}\+artifact\.[0-9a-f]{64}$"
)
_LLAMA_CPP_BACKEND_IDS = frozenset({"llama.cpp", "llama-cpp"})
# A revision that can move is not a pin. DGR-017 learned this on the artifact;
# it is just as true of a tokenizer.
# The one shape a tokenizer identity may take: a digest over the tokenizer's
# actual bytes (see `tokenizer_identity`). Any *label* — `origin/main`,
# `stable`, `release`, a tag, a symbolic ref — names a mutable pointer, and a
# denylist of known-mutable names can never enumerate them all. So the check is
# inverted: instead of rejecting labels we recognize as moving, accept only a
# value that could not be a label in the first place.
_TOKENIZER_IDENTITY = re.compile(r"^tokenizer\.v1:[0-9a-f]{64}$")
# A revision that can move is not a pin. DGR-017 learned this on the artifact.
# Used for diagnosis-only fields (`artifact.revision`); the digested tokenizer
# axis requires the strictly stronger `_TOKENIZER_IDENTITY` form.
_MOVING_REFS = frozenset({"main", "master", "head", "latest", "dev", "trunk"})
@@ -204,7 +215,50 @@ def _require_runtime_pin(value: Any, backend_id: Any) -> str:
if backend in _LLAMA_CPP_BACKEND_IDS and not _LLAMA_CPP_RUNTIME_PIN.fullmatch(text):
raise RecipeIdentityError(
"'recipe.runtime_version' for llama.cpp must be "
"'llama.cpp@<40-hex commit>+patchstack.<64-hex digest>'"
"'llama.cpp@<40-hex commit>+patchstack.<64-hex digest>"
"+build.<64-hex digest>+artifact.<64-hex digest>'"
)
return text
def tokenizer_identity(files: Mapping[str, bytes]) -> str:
"""The content-addressed identity of a tokenizer: a digest over its bytes.
`files` maps each numerically relevant tokenizer/config file name — for a
GGUF, the embedded tokenizer metadata blob; for a safetensors deployment,
`tokenizer.json`, `tokenizer_config.json`, `special_tokens_map.json` — to
that file's exact bytes. The identity commits to each name and each byte
set, so two tokenizers published under one label differ, and a one-byte
edit is a different tokenizer.
"""
if not isinstance(files, Mapping) or not files:
raise RecipeIdentityError(
"tokenizer identity requires at least one named tokenizer/config "
"byte set; an identity over nothing pins nothing"
)
digests: dict[str, str] = {}
for name, body in files.items():
if not isinstance(name, str) or not name.strip():
raise RecipeIdentityError(
"tokenizer identity file names must be non-empty strings"
)
if not isinstance(body, (bytes, bytearray)):
raise RecipeIdentityError(
f"tokenizer identity for {name!r} requires the file's bytes, "
"not a path or label"
)
digests[name] = hashlib.sha256(bytes(body)).hexdigest()
return "tokenizer.v1:" + _digest(TOKENIZER_DIGEST_DOMAIN, {"files": digests})
def _require_tokenizer_identity(value: Any, what: str) -> str:
text = _require_text(value, what)
if not _TOKENIZER_IDENTITY.fullmatch(text):
raise RecipeIdentityError(
f"{what!r} must be a content-addressed tokenizer identity "
"'tokenizer.v1:<64-hex digest>' derived from the tokenizer's bytes "
"(tokenizer_identity); a repository label, tag, branch, or symbolic "
"ref names a mutable pointer, not the bytes it currently resolves to"
)
return text
@@ -389,10 +443,13 @@ class RuntimeRecipe:
one in fp16, produce different logits from the same bytes. Keeping the axes
apart is the entire safety property; see :data:`RECIPE_AXES`.
`tokenizer_revision` and `runtime_version` must be exact pins, never moving
references. For the native runtime the canonical `runtime_version` value —
committing to the exact upstream commit *and* the ordered patch stack — is
derived from the DGR-027 lock manifest by :mod:`meshnet_node.runtime_pin`.
`tokenizer_revision` must be a content-addressed tokenizer identity
(:func:`tokenizer_identity`) — a digest over the tokenizer's actual bytes,
never a repository label that merely points at bytes. `runtime_version`
must be an exact pin; for the native runtime the canonical value —
committing to the exact upstream commit, the ordered patch stack, *and*
the numerically relevant build recipe — is derived from the DGR-027 lock
manifest by :mod:`meshnet_node.runtime_pin`.
The three label fields are diagnosis only and are not digested.
"""
@@ -419,7 +476,7 @@ class RuntimeRecipe:
_require_int(value, f"recipe.{axis}", 1)
else:
_require_text(value, f"recipe.{axis}")
_require_pin(self.tokenizer_revision, "recipe.tokenizer_revision")
_require_tokenizer_identity(self.tokenizer_revision, "recipe.tokenizer_revision")
_require_runtime_pin(self.runtime_version, self.backend_id)
_require_text(self.recipe_id, "recipe.recipe_id")
_require_text(self.recipe_version, "recipe.recipe_version")