From 7930e5b0d88a32ffe13048d6360ef1fb5c2427be Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:16:43 +0000 Subject: [PATCH 01/12] experiments: preregister signed model manifest (#58) --- .../20261104-58-signed-manifest/PREREG.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 experiments/20261104-58-signed-manifest/PREREG.md diff --git a/experiments/20261104-58-signed-manifest/PREREG.md b/experiments/20261104-58-signed-manifest/PREREG.md new file mode 100644 index 00000000..0ea849e7 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/PREREG.md @@ -0,0 +1,34 @@ +# PREREG: 20261104-58-signed-manifest +Issue: #58 (part of #6, #36, #9). Lane: signed model manifest + vast/abliterated design. Date (UTC): 2026-11-04 22:25. Base commit: eb6e6ea379db87b3bd34c6377414422ed6a236dd. + +## Hypothesis +A detached, ed25519-signed manifest checked by a client-side tool next to `cp_verify` refuses every single-field tamper of the manifest, every substitution of a bound file (configuration files, proof's commitment ID, tokenizer, chat template, system prompt, verifier build digest), and every wrong signer key, while accepting the untampered stored example (`examples/accepted-proof/`) with zero false refusals. + +## Metric +- Tamper refusal rate: for each manifest field (every leaf of the canonical JSON, enumerated mechanically, not hand-picked) one mutation, re-signed by an attacker key and also left with the original signature; count refusals / mutations. Also: each bound file replaced by a one-byte-changed copy; manifest signed by a different key; signature bit flipped; non-canonical JSON (key reorder, whitespace) with the original signature. +- False refusal count on the untampered example: n runs. +- Verify wall time on CPU (seconds, `time.perf_counter`), informational only. +- `cp_verify` end-to-end: the tool runs the real `cp_verify` binary on the stored proof with the manifest's ID and reports its exit code and `result-v1` line. +- ed25519 implementation correctness: RFC 8032 section 7.1 test vectors 1-3 and, where the `cryptography` package exists, cross-signature agreement on random messages. + +## Acceptance threshold +PASS iff: 100% of tampers refused (any single miss = FAIL), 0 false refusals in n=10 untampered verifies, all RFC 8032 vectors match, the untampered path ends with `cp_verify` exit 0 and `YES`. Timing has no threshold. + +## Kill criterion +CPU only, no GPU, no paid API, no model download. Time box 3 h wall. If `cp_verify` cannot be built on this CPU host, the end-to-end row is NOT MEASURED and the manifest-only rows still report. If the pure-Python ed25519 fails any RFC vector and cannot be fixed in 30 min, stop and publish the negative. + +## Runs +Untampered verify: n=10, same inputs, deterministic (repeat counts a determinism check, not independent trials). Tamper set: one mutation per field, fully enumerated, ordered by JSON path; each mutation run once (deterministic). Random cross-check vs `cryptography`: 64 messages, seed 1104. Nothing excluded. + +## Prior art +- Sigstore / cosign and in-toto/SLSA provenance: signed supply-chain attestations binding artifacts to build recipes. This manifest is a tiny special case for one committed model. +- minisign / signify / SSH signatures: detached ed25519 signatures on files. +- Hugging Face repo revisions + file sha256 (LFS oids): the upstream pin this manifest records. +- Tinfoil Modelwrap (https://tinfoil.sh/blog/2026-02-03-proving-model-identity): model identity bound inside a TEE attestation, not a proof. +- amiller/dsmack (https://github.com/amiller/dsmack): approved-code registry + hardware attestation (Apple App Attest), compared in DESIGN-VAST-ABLITERATED.md. +- zkML (EZKL, zkLLM, Jolt, Expander), TEEs (H100 CC, TDX, SEV-SNP; Phala, Atoma, Nillion): adjacent, none is changed here. +- Threat model PR #54, mitigations M1 and M2: this lane implements their detached-manifest form. Binding the manifest into the configuration itself (in-statement) is out of scope (parser change). +What is new: nothing cryptographically; it is the missing glue between a FlashProve commitment ID and an upstream checkpoint + serving template, with tamper tests. + +## Amendments +(empty at registration) From 95b126083555f90c3e2dd1fd64d179c75d01cb1e Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:26:44 +0000 Subject: [PATCH 02/12] tools/manifest: signed model manifest, client-side verify next to cp_verify, tamper tests (#58) --- .../20261104-58-signed-manifest/run.py | 167 +++++++ tools/manifest/ed25519.py | 132 ++++++ tools/manifest/manifest.py | 422 ++++++++++++++++++ tools/manifest/tamper.py | 170 +++++++ tools/manifest/test_manifest.py | 298 +++++++++++++ 5 files changed, 1189 insertions(+) create mode 100644 experiments/20261104-58-signed-manifest/run.py create mode 100644 tools/manifest/ed25519.py create mode 100644 tools/manifest/manifest.py create mode 100644 tools/manifest/tamper.py create mode 100644 tools/manifest/test_manifest.py diff --git a/experiments/20261104-58-signed-manifest/run.py b/experiments/20261104-58-signed-manifest/run.py new file mode 100644 index 00000000..f3bcaa41 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/run.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Measure the PREREG metrics of 20261104-58-signed-manifest. CPU only. Writes numbers.json. + +Needs bin/cp_verify (verifier/build.sh). Run from the repository root: + python3 experiments/20261104-58-signed-manifest/run.py +""" + +import contextlib +import io +import json +import pathlib +import platform +import random +import shutil +import statistics +import sys +import tempfile +import time + +HERE = pathlib.Path(__file__).resolve().parent +REPO = HERE.parents[1] +sys.path.insert(0, str(REPO / "tools" / "manifest")) +import ed25519 # noqa: E402 +import manifest as mf # noqa: E402 +import tamper as t # noqa: E402 + +N_UNTAMPERED = 10 + + +def run(tmp, m, recipe, secret=t.TRUSTED_SECRET, overrides=None, raw=None, sig=None): + mp, sp = t.write_signed(tmp, m, secret) + if raw is not None: + mp.write_bytes(raw) + if sig is not None: + sp.write_bytes(sig) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = mf.main(t.verify_argv(tmp, mp, sp, recipe, overrides)) + return code, buf.getvalue().strip().splitlines()[-1] + + +def main(): + out = {"rows": [], "untampered": [], "rfc8032": None, "cross_check": None} + build = t.build_digest() + with tempfile.TemporaryDirectory() as d: + tmp = pathlib.Path(d) + m, recipe = t.make_fixture(tmp, build) + + # untampered, n = 10 + for i in range(N_UNTAMPERED): + t0 = time.perf_counter() + code, last = run(tmp, m, recipe) + out["untampered"].append({"i": i, "exit": code, "last": last, + "wall_s": time.perf_counter() - t0}) + + # every field x 3 tamper kinds + good_sig = t.write_signed(tmp, m)[1].read_bytes() + for path in t.leaves(m): + name = t.path_name(path) + bad = t.mutated(m, path) + for kind, kw in (("orig-sig", {"sig": good_sig}), + ("attacker-sig", {"secret": t.ATTACKER_SECRET}), + ("trusted-sig", {})): + code, last = run(tmp, bad, recipe, **kw) + out["rows"].append({"field": name, "kind": kind, "exit": code, "last": last}) + bad2 = json.loads(json.dumps(bad)) + bad2["signer"]["public_key"] = ed25519.public_key(t.ATTACKER_SECRET).hex() + code, last = run(tmp, bad2, recipe, secret=t.ATTACKER_SECRET) + out["rows"].append({"field": name, "kind": "attacker-sig+key", "exit": code, + "last": last}) + + # file substitutions + subs = [] + for which in ("policy.txt", "KL.bin", "layout.txt"): + c = t.copy_example(tmp / ("c-" + which)) + t.flip_byte(c / "config" / which, offset=-2 if which == "policy.txt" else None) + subs.append(("config/" + which, {"--config": str(c / "config")})) + for which in ("example.proof", "example.req", "example.y", "ivk/key.bin", "ivk/leaf/0.bin"): + c = t.copy_example(tmp / ("p-" + which.replace("/", "_"))) + t.flip_byte(c / which) + subs.append((which, {"--ivk": str(c / "ivk"), "--request": str(c / "example.req"), + "--y": str(c / "example.y"), "--proof": str(c / "example.proof")})) + for which, src in (("tokenizer.json", tmp / "tokenizer" / "tokenizer.json"), + ("chat_template", tmp / "chat_template.jinja"), + ("system_prompt", tmp / "system_prompt.txt"), + ("recipe", recipe)): + dst = tmp / ("s-" + which) + if which == "tokenizer.json": + shutil.copytree(tmp / "tokenizer", dst) + t.flip_byte(dst / "tokenizer.json") + subs.append((which, {"--tokenizer-dir": str(dst)})) + else: + shutil.copy(src, dst) + t.flip_byte(dst) + flag = {"chat_template": "--chat-template", "system_prompt": "--system-prompt", + "recipe": "--recipe"}[which] + subs.append((which, {flag: str(dst)})) + extra = tmp / "s-extra-tok" + shutil.copytree(tmp / "tokenizer", extra) + (extra / "added_tokens.json").write_text("{}\n") + subs.append(("tokenizer extra file", {"--tokenizer-dir": str(extra)})) + (tmp / "other.pub").write_text(ed25519.public_key(t.ATTACKER_SECRET).hex() + "\n") + subs.append(("wrong trusted key", {"--signer-key": str(tmp / "other.pub")})) + subs.append(("end tokens differ", {"--end-tokens": "2"})) + for name, over in subs: + code, last = run(tmp, m, recipe, overrides=over) + out["rows"].append({"field": name, "kind": "file-substitution", "exit": code, + "last": last}) + for i in range(0, 64, 7): + sig = bytearray(good_sig) + sig[i] ^= 0x80 + code, last = run(tmp, m, recipe, sig=bytes(sig)) + out["rows"].append({"field": "signature byte %d" % i, "kind": "sig-bitflip", + "exit": code, "last": last}) + pretty = json.dumps(m, indent=2, sort_keys=True).encode() + b"\n" + code, last = run(tmp, m, recipe, raw=pretty, + sig=ed25519.sign(t.TRUSTED_SECRET, mf.signed_bytes(pretty))) + out["rows"].append({"field": "non-canonical JSON", "kind": "encoding", "exit": code, + "last": last}) + + # RFC 8032 + cross-check + sys.path.insert(0, str(REPO / "tools" / "manifest")) + import test_manifest as tm + ok = 0 + for sk, pk, msg, sig in tm.RFC8032: + sk, pk, msg, sig = map(bytes.fromhex, (sk, pk, msg, sig)) + ok += ed25519.public_key(sk) == pk and ed25519.sign(sk, msg) == sig and ed25519.verify(pk, msg, sig) + out["rfc8032"] = {"matched": ok, "total": len(tm.RFC8032)} + try: + from cryptography.hazmat.primitives.asymmetric import ed25519 as ref + import cryptography + rng = random.Random(1104) + agree = 0 + for _ in range(64): + sk = rng.randbytes(32) + msg = rng.randbytes(rng.randrange(0, 300)) + agree += ed25519.sign(sk, msg) == ref.Ed25519PrivateKey.from_private_bytes(sk).sign(msg) + out["cross_check"] = {"agree": agree, "total": 64, "cryptography": cryptography.__version__} + except ImportError: + out["cross_check"] = "not run: cryptography not installed" + + # summary + rows = out["rows"] + must_refuse = [r for r in rows if r["kind"] != "trusted-sig"] + trusted = [r for r in rows if r["kind"] == "trusted-sig"] + walls = [u["wall_s"] for u in out["untampered"]] + out["summary"] = { + "untampered_accepted": sum(u["exit"] == 0 for u in out["untampered"]), + "untampered_n": len(out["untampered"]), + "verify_wall_s": {"median": statistics.median(walls), "sd": statistics.stdev(walls), + "min": min(walls), "max": max(walls), "n": len(walls), + "note": "includes one cp_verify process (about 1.5 s of it)"}, + "fields": len(trusted), + "must_refuse_total": len(must_refuse), + "must_refuse_refused": sum(r["exit"] == 1 for r in must_refuse), + "trusted_resign_refused": sum(r["exit"] == 1 for r in trusted), + "trusted_resign_accepted_fields": sorted(r["field"] for r in trusted if r["exit"] == 0), + } + out["host"] = {"python": platform.python_version(), "machine": platform.machine()} + (HERE / "numbers.json").write_text(json.dumps(out, indent=1) + "\n") + print(json.dumps(out["summary"], indent=1)) + print(json.dumps({"rfc8032": out["rfc8032"], "cross_check": out["cross_check"]})) + + +if __name__ == "__main__": + main() diff --git a/tools/manifest/ed25519.py b/tools/manifest/ed25519.py new file mode 100644 index 00000000..eeb47669 --- /dev/null +++ b/tools/manifest/ed25519.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Ed25519 (RFC 8032, section 5.1), pure Python, standard library only. + +Written from the RFC's reference description so that tools/manifest has no dependency. Verification +works on public data only. Signing here is NOT constant time: it is for tests and for prototypes. +A publisher who signs real manifests should sign with a vetted implementation (OpenSSL, +libsodium, the `cryptography` package, minisign or ssh-keygen -Y); the signature bytes are the same +(RFC 8032 is deterministic), and tests/test_manifest.py cross-checks this file against +`cryptography` where it is installed. +""" + +import hashlib + +P = 2**255 - 19 +L = 2**252 + 27742317777372353535851937790883648493 +D = (-121665 * pow(121666, P - 2, P)) % P +SQRT_M1 = pow(2, (P - 1) // 4, P) + + +def _sha512(data): + return hashlib.sha512(data).digest() + + +# Points in extended coordinates (X, Y, Z, T), x = X/Z, y = Y/Z, x*y = T/Z. +def _add(p, q): + a = (p[1] - p[0]) * (q[1] - q[0]) % P + b = (p[1] + p[0]) * (q[1] + q[0]) % P + c = 2 * p[3] * q[3] * D % P + d = 2 * p[2] * q[2] % P + e, f, g, h = b - a, d - c, d + c, b + a + return (e * f % P, g * h % P, f * g % P, e * h % P) + + +def _mul(s, p): + q = (0, 1, 1, 0) + while s > 0: + if s & 1: + q = _add(q, p) + p = _add(p, p) + s >>= 1 + return q + + +def _equal(p, q): + if (p[0] * q[2] - q[0] * p[2]) % P: + return False + return (p[1] * q[2] - q[1] * p[2]) % P == 0 + + +def _recover_x(y, sign): + if y >= P: + return None + x2 = (y * y - 1) * pow(D * y * y + 1, P - 2, P) % P + if x2 == 0: + return None if sign else 0 + x = pow(x2, (P + 3) // 8, P) + if (x * x - x2) % P: + x = x * SQRT_M1 % P + if (x * x - x2) % P: + return None + if (x & 1) != sign: + x = P - x + return x + + +_GY = 4 * pow(5, P - 2, P) % P +_GX = _recover_x(_GY, 0) +G = (_GX, _GY, 1, _GX * _GY % P) + + +def _compress(p): + zinv = pow(p[2], P - 2, P) + x, y = p[0] * zinv % P, p[1] * zinv % P + return int.to_bytes(y | ((x & 1) << 255), 32, "little") + + +def _decompress(data): + if len(data) != 32: + return None + y = int.from_bytes(data, "little") + sign = y >> 255 + y &= (1 << 255) - 1 + x = _recover_x(y, sign) + if x is None: + return None + return (x, y, 1, x * y % P) + + +def _secret_expand(secret): + if len(secret) != 32: + raise ValueError("an Ed25519 secret key has 32 bytes") + h = _sha512(secret) + a = int.from_bytes(h[:32], "little") + a &= (1 << 254) - 8 + a |= 1 << 254 + return a, h[32:] + + +def public_key(secret): + a, _ = _secret_expand(secret) + return _compress(_mul(a, G)) + + +def sign(secret, message): + a, prefix = _secret_expand(secret) + big_a = _compress(_mul(a, G)) + r = int.from_bytes(_sha512(prefix + message), "little") % L + big_r = _compress(_mul(r, G)) + h = int.from_bytes(_sha512(big_r + big_a + message), "little") % L + s = (r + h * a) % L + return big_r + int.to_bytes(s, 32, "little") + + +def verify(public, message, signature): + """Return True iff `signature` is a valid Ed25519 signature of `message` under `public`. + + Strict on encodings: 32-byte key, 64-byte signature, S below L, both points decodable. + Uses the cofactorless check [S]B = R + [k]A, as the RFC's reference code does. + """ + if len(public) != 32 or len(signature) != 64: + return False + big_a = _decompress(public) + if big_a is None: + return False + big_r = _decompress(signature[:32]) + if big_r is None: + return False + s = int.from_bytes(signature[32:], "little") + if s >= L: + return False + h = int.from_bytes(_sha512(signature[:32] + public + message), "little") % L + return _equal(_mul(s, G), _add(big_r, _mul(h, big_a))) diff --git a/tools/manifest/manifest.py b/tools/manifest/manifest.py new file mode 100644 index 00000000..fd29205c --- /dev/null +++ b/tools/manifest/manifest.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Signed model manifest for a FlashProve commitment: make, sign and verify. + +A manifest is a detached statement, signed by whoever vouches for a model, that ties one FlashProve +commitment ID to: + - the upstream checkpoint (Hugging Face repo, revision, SHA-256 of every file used), or a named + synthetic generator; + - the conversion recipe that made the committed integer artifact (script, its digest, the repo + commit it ran from, version, parameters, digests of the committed files, the -public-weights + seed file's digest if the commitment is public); + - the three configuration files that cp_verify reads (policy.txt, KL.bin, layout.txt); + - what the server puts around a client's message: tokenizer files, chat template, system prompt, + end tokens (each a digest, or an explicit "none"); + - the decode policy (exact match, or clipped gap with its limits); + - the cp_verify build digest (the build= field of its result-v1 line). + +`verify` refuses (exit 1) on any mismatch between the manifest, the signer key that the CALLER +names (never one taken from the manifest), the files the caller holds and what cp_verify prints. +It does not change and does not replace cp_verify: it runs it, with the manifest's ID. + +What it cannot check: whether the signer told the truth about fields the caller has no files for +(upstream files, conversion inputs). Those are the signer's word unless the caller passes +--upstream-dir / --committed-dir / --recipe so they are rehashed. See README.md. + +Standard library only (ed25519.py next to this file). +""" + +import argparse +import hashlib +import json +import os +import pathlib +import re +import subprocess +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +import ed25519 # noqa: E402 + +FORMAT = "flashprove-manifest-v1" +DOMAIN = b"flashprove-manifest-v1 signature\n" +HEX64 = re.compile(r"[0-9a-f]{64}\Z") +HEX40 = re.compile(r"[0-9a-f]{40}\Z") +CONFIG_FILES = ("policy.txt", "KL.bin", "layout.txt") +POLICY_FIELDS = ("C", "theta", "cap", "s", "sh", "sT", "F") +EXACT = {"C": 256, "theta": 256, "cap": 0, "sT": 4} + + +class Refusal(Exception): + """The manifest, a file or cp_verify's answer does not fit. str() says which field.""" + + +# ---- canonical bytes ---- + + +def canonical(obj): + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + b"\n" + + +def signed_bytes(manifest_bytes): + return DOMAIN + manifest_bytes + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + h.update(block) + return h.hexdigest() + + +# ---- schema (strict: no unknown key, no missing key) ---- + + +def _keys(obj, where, required, optional=()): + if not isinstance(obj, dict): + raise Refusal("%s: not an object" % where) + missing = [k for k in required if k not in obj] + extra = [k for k in obj if k not in required and k not in optional] + if missing: + raise Refusal("%s: missing key %s" % (where, missing[0])) + if extra: + raise Refusal("%s: unknown key %s" % (where, extra[0])) + + +def _hex64(value, where): + if not isinstance(value, str) or not HEX64.match(value): + raise Refusal("%s: not 64 lowercase hex digits" % where) + + +def _str(value, where): + if not isinstance(value, str) or not value or len(value) > 4096: + raise Refusal("%s: not a non-empty string" % where) + + +def _int(value, where): + if type(value) is not int or not 0 <= value < 2**32: + raise Refusal("%s: not an integer in [0, 2**32)" % where) + + +def _file_list(files, where, allow_empty=False): + if not isinstance(files, list) or (not files and not allow_empty): + raise Refusal("%s: not a%s list" % (where, "" if allow_empty else " non-empty")) + seen = set() + for i, entry in enumerate(files): + _keys(entry, "%s[%d]" % (where, i), ("path", "sha256")) + _str(entry["path"], "%s[%d].path" % (where, i)) + p = entry["path"] + if p.startswith("/") or ".." in p.split("/") or p in seen: + raise Refusal("%s[%d].path: absolute, has .., or repeated" % (where, i)) + seen.add(p) + _hex64(entry["sha256"], "%s[%d].sha256" % (where, i)) + if [e["path"] for e in files] != sorted(seen): + raise Refusal("%s: paths not sorted" % where) + + +def _digest_or_none(obj, where): + if obj == {"none": True}: + return + _keys(obj, where, ("sha256",)) + _hex64(obj["sha256"], where + ".sha256") + + +def check_schema(m): + _keys(m, "manifest", ("format", "commitment", "upstream", "conversion", "configuration", + "serving", "decode_policy", "verifier", "signer")) + if m["format"] != FORMAT: + raise Refusal("format: not %s" % FORMAT) + + c = m["commitment"] + _keys(c, "commitment", ("id", "kind")) + _hex64(c["id"], "commitment.id") + if c["kind"] not in ("public-weights", "hiding"): + raise Refusal("commitment.kind: not public-weights or hiding") + + u = m["upstream"] + if not isinstance(u, dict) or u.get("kind") not in ("huggingface", "synthetic"): + raise Refusal("upstream.kind: not huggingface or synthetic") + if u["kind"] == "huggingface": + _keys(u, "upstream", ("kind", "repo", "revision", "files")) + _str(u["repo"], "upstream.repo") + if not isinstance(u["revision"], str) or not HEX40.match(u["revision"]): + raise Refusal("upstream.revision: not a 40-hex commit (a branch name can move)") + _file_list(u["files"], "upstream.files") + else: + _keys(u, "upstream", ("kind", "generator", "seed")) + _str(u["generator"], "upstream.generator") + _int(u["seed"], "upstream.seed") + + v = m["conversion"] + _keys(v, "conversion", ("script", "script_sha256", "repo_commit", "version", "params", + "committed_files", "public_weights_seed_sha256")) + _str(v["script"], "conversion.script") + _hex64(v["script_sha256"], "conversion.script_sha256") + if not isinstance(v["repo_commit"], str) or not HEX40.match(v["repo_commit"]): + raise Refusal("conversion.repo_commit: not a 40-hex commit") + _str(v["version"], "conversion.version") + if not isinstance(v["params"], dict): + raise Refusal("conversion.params: not an object") + for k, val in v["params"].items(): + if not isinstance(val, (str, int)) or isinstance(val, bool): + raise Refusal("conversion.params.%s: not a string or integer" % k) + _file_list(v["committed_files"], "conversion.committed_files", + allow_empty=u["kind"] == "synthetic") + if c["kind"] == "public-weights": + _hex64(v["public_weights_seed_sha256"], "conversion.public_weights_seed_sha256") + elif v["public_weights_seed_sha256"] is not None: + raise Refusal("conversion.public_weights_seed_sha256: must be null for a hiding commitment") + + cfg = m["configuration"] + _keys(cfg, "configuration", CONFIG_FILES) + for name in CONFIG_FILES: + _hex64(cfg[name], "configuration." + name) + + s = m["serving"] + _keys(s, "serving", ("tokenizer", "chat_template", "system_prompt", "end_tokens")) + if s["tokenizer"] != {"none": True}: + _keys(s["tokenizer"], "serving.tokenizer", ("files",)) + _file_list(s["tokenizer"]["files"], "serving.tokenizer.files") + _digest_or_none(s["chat_template"], "serving.chat_template") + _digest_or_none(s["system_prompt"], "serving.system_prompt") + if not isinstance(s["end_tokens"], list): + raise Refusal("serving.end_tokens: not a list") + for i, t in enumerate(s["end_tokens"]): + _int(t, "serving.end_tokens[%d]" % i) + + d = m["decode_policy"] + _keys(d, "decode_policy", ("kind",) + POLICY_FIELDS) + for name in POLICY_FIELDS: + _int(d[name], "decode_policy." + name) + is_exact = all(d[k] == v for k, v in EXACT.items()) + if d["kind"] != ("exact" if is_exact else "clipped-gap"): + raise Refusal("decode_policy.kind: does not match its limits") + + vf = m["verifier"] + _keys(vf, "verifier", ("build_digest", "source_commit")) + _hex64(vf["build_digest"], "verifier.build_digest") + if not isinstance(vf["source_commit"], str) or not HEX40.match(vf["source_commit"]): + raise Refusal("verifier.source_commit: not a 40-hex commit") + + g = m["signer"] + _keys(g, "signer", ("name", "public_key")) + _str(g["name"], "signer.name") + _hex64(g["public_key"], "signer.public_key") + + +# ---- load + signature ---- + + +def load(manifest_bytes, signature, trusted_key): + """Parse, check canonical form, schema and signature. Return the manifest dict.""" + if len(trusted_key) != 32: + raise Refusal("signer key: not 32 bytes") + if not ed25519.verify(trusted_key, signed_bytes(manifest_bytes), signature): + raise Refusal("signature: does not verify under the trusted signer key") + try: + m = json.loads(manifest_bytes.decode("ascii")) + except (UnicodeDecodeError, ValueError) as e: + raise Refusal("manifest: not ASCII JSON (%s)" % e) + if canonical(m) != manifest_bytes: + raise Refusal("manifest: not in canonical form") + check_schema(m) + if bytes.fromhex(m["signer"]["public_key"]) != trusted_key: + raise Refusal("signer.public_key: not the trusted signer key") + return m + + +# ---- binding checks ---- + + +def parse_policy_file(data): + values = {} + for line in data.decode("ascii").splitlines(): + words = line.split(" ") + if len(words) == 2 and words[0] in POLICY_FIELDS: + if words[0] in values: + raise Refusal("policy.txt: repeats %s" % words[0]) + values[words[0]] = int(words[1]) + return values + + +def check_config(m, config_dir): + for name in CONFIG_FILES: + if sha256_file(os.path.join(config_dir, name)) != m["configuration"][name]: + raise Refusal("configuration.%s: file digest differs" % name) + values = parse_policy_file(pathlib.Path(config_dir, "policy.txt").read_bytes()) + for name in POLICY_FIELDS: + if values.get(name) != m["decode_policy"][name]: + raise Refusal("decode_policy.%s: differs from policy.txt" % name) + + +def check_files(entries, root, where): + root = pathlib.Path(root) + listed = {e["path"] for e in entries} + for e in entries: + p = root / e["path"] + if not p.is_file(): + raise Refusal("%s: %s missing" % (where, e["path"])) + if sha256_file(p) != e["sha256"]: + raise Refusal("%s: %s digest differs" % (where, e["path"])) + present = {str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()} + extra = sorted(present - listed) + if extra: + raise Refusal("%s: %s is present but not listed" % (where, extra[0])) + + +def check_serving(m, args): + s = m["serving"] + if s["tokenizer"] == {"none": True}: + if not args.no_tokenizer: + raise Refusal("serving.tokenizer: manifest says none; pass --no-tokenizer to accept") + else: + if not args.tokenizer_dir: + raise Refusal("serving.tokenizer: pass --tokenizer-dir") + check_files(s["tokenizer"]["files"], args.tokenizer_dir, "serving.tokenizer.files") + for field, path_arg, none_arg in (("chat_template", args.chat_template, args.no_chat_template), + ("system_prompt", args.system_prompt, args.no_system_prompt)): + want = s[field] + if want == {"none": True}: + if path_arg or not none_arg: + raise Refusal("serving.%s: manifest says none; pass --no-%s and no file" + % (field, field.replace("_", "-"))) + else: + if not path_arg: + raise Refusal("serving.%s: pass the file you used" % field) + if sha256_file(path_arg) != want["sha256"]: + raise Refusal("serving.%s: file digest differs" % field) + if args.end_tokens is not None and args.end_tokens != s["end_tokens"]: + raise Refusal("serving.end_tokens: differ from --end-tokens") + + +def check_recipe(m, args): + v = m["conversion"] + if args.recipe and sha256_file(args.recipe) != v["script_sha256"]: + raise Refusal("conversion.script_sha256: recipe file digest differs") + if args.committed_dir: + check_files(v["committed_files"], args.committed_dir, "conversion.committed_files") + if args.upstream_dir: + if m["upstream"]["kind"] != "huggingface": + raise Refusal("upstream: --upstream-dir given but upstream is synthetic") + check_files(m["upstream"]["files"], args.upstream_dir, "upstream.files") + if args.public_weights_seed: + want = v["public_weights_seed_sha256"] + if want is None or sha256_file(args.public_weights_seed) != want: + raise Refusal("conversion.public_weights_seed_sha256: seed file digest differs") + + +def run_cp_verify(m, args): + words = [args.cp_verify, args.config, args.ivk, args.request, args.y, args.proof, + "-id", m["commitment"]["id"], "-q"] + done = subprocess.run(words, capture_output=True, text=True, timeout=args.timeout, + env={"PATH": "/usr/bin:/bin"}) + lines = done.stdout.rstrip("\n").split("\n") + if done.returncode != 0 or len(lines) < 2 or lines[-1] != "YES": + raise Refusal("cp_verify: exit %d, last line %r" % (done.returncode, lines[-1:])) + result = lines[-2] + if not result.startswith("result-v1 "): + raise Refusal("cp_verify: no result-v1 line before YES") + fields = dict(w.split("=", 1) for w in result.split(" ")[1:] if "=" in w) + if fields.get("status") != "0": + raise Refusal("cp_verify: status not 0") + if fields.get("id") != m["commitment"]["id"]: + raise Refusal("commitment.id: differs from cp_verify's id=") + if fields.get("build") != m["verifier"]["build_digest"]: + raise Refusal("verifier.build_digest: differs from cp_verify's build=") + for name in POLICY_FIELDS: + if fields.get(name) != str(m["decode_policy"][name]): + raise Refusal("decode_policy.%s: differs from cp_verify's %s=" % (name, name)) + want_claim = "exact-match" if m["decode_policy"]["kind"] == "exact" else "clipped-gap" + if fields.get("policy") != want_claim: + raise Refusal("decode_policy.kind: cp_verify says policy=%s" % fields.get("policy")) + return result + + +def verify(args): + manifest_bytes = pathlib.Path(args.manifest).read_bytes() + signature = pathlib.Path(args.signature).read_bytes() + trusted = bytes.fromhex(pathlib.Path(args.signer_key).read_text().strip()) + m = load(manifest_bytes, signature, trusted) + check_config(m, args.config) + check_serving(m, args) + check_recipe(m, args) + return m, run_cp_verify(m, args) + + +# ---- CLI ---- + + +def cmd_keygen(args): + secret = os.urandom(32) + fd = os.open(args.secret_out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + f.write(secret.hex() + "\n") + pathlib.Path(args.public_out).write_text(ed25519.public_key(secret).hex() + "\n") + + +def cmd_sign(args): + m = json.loads(pathlib.Path(args.manifest_json).read_text()) + secret = bytes.fromhex(pathlib.Path(args.secret).read_text().strip()) + m["signer"]["public_key"] = ed25519.public_key(secret).hex() + check_schema(m) + data = canonical(m) + pathlib.Path(args.out).write_bytes(data) + pathlib.Path(args.out + ".sig").write_bytes(ed25519.sign(secret, signed_bytes(data))) + + +def add_verify_args(p): + p.add_argument("manifest") + p.add_argument("signature") + p.add_argument("--signer-key", required=True, help="file: the signer's public key, hex, from a source you trust") + p.add_argument("--config", required=True) + p.add_argument("--ivk", required=True) + p.add_argument("--request", required=True) + p.add_argument("--y", required=True) + p.add_argument("--proof", required=True) + p.add_argument("--cp-verify", required=True) + p.add_argument("--timeout", type=int, default=600) + p.add_argument("--tokenizer-dir") + p.add_argument("--no-tokenizer", action="store_true") + p.add_argument("--chat-template") + p.add_argument("--no-chat-template", action="store_true") + p.add_argument("--system-prompt") + p.add_argument("--no-system-prompt", action="store_true") + p.add_argument("--end-tokens", type=lambda t: [int(x) for x in t.split(",") if x] if t != "none" else []) + p.add_argument("--recipe") + p.add_argument("--committed-dir") + p.add_argument("--upstream-dir") + p.add_argument("--public-weights-seed") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + k = sub.add_parser("keygen") + k.add_argument("secret_out") + k.add_argument("public_out") + s = sub.add_parser("sign") + s.add_argument("manifest_json") + s.add_argument("secret") + s.add_argument("out") + v = sub.add_parser("verify") + add_verify_args(v) + args = parser.parse_args(argv) + if args.cmd == "keygen": + cmd_keygen(args) + return 0 + if args.cmd == "sign": + cmd_sign(args) + return 0 + try: + m, result = verify(args) + except Refusal as r: + print("manifest: REFUSED: %s" % r) + return 1 + print(result) + print("manifest: ACCEPTED id=%s signer=%s" % (m["commitment"]["id"], m["signer"]["name"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/manifest/tamper.py b/tools/manifest/tamper.py new file mode 100644 index 00000000..e97d80d9 --- /dev/null +++ b/tools/manifest/tamper.py @@ -0,0 +1,170 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Fixture and tamper enumeration for manifest.py, shared by test_manifest.py and the experiment. + +The fixture is the stored example examples/accepted-proof (synthetic weights, hiding commitment, +exact match), with a manifest that tells the truth about it. That example has a synthetic +vocabulary and no tokenizer, so the serving fields point at small fixture files written here +(a tokenizer.json, a chat template, a system prompt): they test the binding, not a real model. +""" + +import copy +import json +import pathlib +import re +import shutil +import subprocess + +import ed25519 +import manifest as mf + +REPO = pathlib.Path(__file__).resolve().parents[2] +EXAMPLE = REPO / "examples" / "accepted-proof" +CP_VERIFY = REPO / "bin" / "cp_verify" +TRUSTED_SECRET = bytes(range(32)) # test key only +ATTACKER_SECRET = bytes(range(32, 64)) # test key only + + +def build_digest(): + """The build= that the local cp_verify prints for the stored example.""" + out = subprocess.run( + [str(CP_VERIFY), "config", "ivk", "example.req", "example.y", "example.proof", + "-id", (EXAMPLE / "ID.txt").read_text().strip(), "-q"], + cwd=EXAMPLE, capture_output=True, text=True, timeout=600, + ).stdout + return re.search(r" build=([0-9a-f]{64})", out).group(1) + + +def make_fixture(tmp, build): + tmp = pathlib.Path(tmp) + (tmp / "tokenizer").mkdir() + (tmp / "tokenizer" / "tokenizer.json").write_text('{"model":{"type":"WordLevel","vocab_size":512}}\n') + (tmp / "tokenizer" / "tokenizer_config.json").write_text('{"eos_token_id":null}\n') + (tmp / "chat_template.jinja").write_text("{% for m in messages %}{{ m.content }}{% endfor %}\n") + (tmp / "system_prompt.txt").write_text("You are a test fixture.\n") + recipe = REPO / "prover" / "examples" / "run_target_example.sh" + head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=REPO, capture_output=True, + text=True).stdout.strip() + tok = sorted(p.name for p in (tmp / "tokenizer").iterdir()) + m = { + "format": mf.FORMAT, + "commitment": {"id": (EXAMPLE / "ID.txt").read_text().strip(), "kind": "hiding"}, + "upstream": {"kind": "synthetic", "generator": "made-up-blocks", "seed": 31}, + "conversion": { + "script": "prover/examples/run_target_example.sh", + "script_sha256": mf.sha256_file(recipe), + "repo_commit": head, + "version": "cp_commit serving build, configuration target_small_f2_roomy", + "params": {"config": "prover/examples/target_small_f2_roomy", "M": 1, + "weights": "made-up-blocks"}, + "committed_files": [], + "public_weights_seed_sha256": None, + }, + "configuration": {n: mf.sha256_file(EXAMPLE / "config" / n) for n in mf.CONFIG_FILES}, + "serving": { + "tokenizer": {"files": [{"path": n, "sha256": mf.sha256_file(tmp / "tokenizer" / n)} + for n in tok]}, + "chat_template": {"sha256": mf.sha256_file(tmp / "chat_template.jinja")}, + "system_prompt": {"sha256": mf.sha256_file(tmp / "system_prompt.txt")}, + "end_tokens": [], + }, + "decode_policy": {"kind": "exact", "C": 256, "theta": 256, "cap": 0, "s": 16, "sh": 8, + "sT": 4, "F": 24}, + "verifier": {"build_digest": build, "source_commit": head}, + "signer": {"name": "test signer (fixture key)", + "public_key": ed25519.public_key(TRUSTED_SECRET).hex()}, + } + (tmp / "trusted.pub").write_text(ed25519.public_key(TRUSTED_SECRET).hex() + "\n") + return m, recipe + + +def write_signed(tmp, m, secret=TRUSTED_SECRET, name="manifest.json"): + data = mf.canonical(m) + p = pathlib.Path(tmp) / name + p.write_bytes(data) + (pathlib.Path(tmp) / (name + ".sig")).write_bytes(ed25519.sign(secret, mf.signed_bytes(data))) + return p, pathlib.Path(tmp) / (name + ".sig") + + +def verify_argv(tmp, manifest_path, sig_path, recipe, overrides=None): + tmp = pathlib.Path(tmp) + o = { + "--signer-key": str(tmp / "trusted.pub"), + "--config": str(EXAMPLE / "config"), + "--ivk": str(EXAMPLE / "ivk"), + "--request": str(EXAMPLE / "example.req"), + "--y": str(EXAMPLE / "example.y"), + "--proof": str(EXAMPLE / "example.proof"), + "--cp-verify": str(CP_VERIFY), + "--tokenizer-dir": str(tmp / "tokenizer"), + "--chat-template": str(tmp / "chat_template.jinja"), + "--system-prompt": str(tmp / "system_prompt.txt"), + "--end-tokens": "none", + "--recipe": str(recipe), + } + o.update(overrides or {}) + argv = ["verify", str(manifest_path), str(sig_path)] + for k, v in o.items(): + if v is None: + continue + argv += [k] if v is True else [k, v] + return argv + + +def leaves(obj, path=()): + """Every leaf (and every empty container) of the manifest, as a JSON path.""" + if isinstance(obj, dict) and obj: + for k in sorted(obj): + yield from leaves(obj[k], path + (k,)) + elif isinstance(obj, list) and obj: + for i, v in enumerate(obj): + yield from leaves(v, path + (i,)) + else: + yield path + + +def mutate_value(v): + if isinstance(v, bool): + return not v + if isinstance(v, int): + return v + 1 + if v is None: + return "0" * 64 + if isinstance(v, str): + if re.fullmatch(r"[0-9a-f]+", v): + return ("1" if v[-1] != "1" else "2").join([v[:-1], ""]) + return v + "x" + if isinstance(v, list): + return [0] + if isinstance(v, dict): + return {"x": 1} + raise TypeError(v) + + +def mutated(m, path): + m2 = copy.deepcopy(m) + cur = m2 + for k in path[:-1]: + cur = cur[k] + cur[path[-1]] = mutate_value(cur[path[-1]]) + return m2 + + +def path_name(path): + return ".".join(str(p) for p in path) + + +def copy_example(tmp): + d = pathlib.Path(tmp) / "example-copy" + shutil.copytree(EXAMPLE, d) + return d + + +def flip_byte(path, offset=None): + data = bytearray(pathlib.Path(path).read_bytes()) + i = len(data) // 2 if offset is None else offset + data[i] ^= 0x01 + pathlib.Path(path).write_bytes(bytes(data)) + + +def dump(obj): + return json.dumps(obj, indent=1, sort_keys=True) diff --git a/tools/manifest/test_manifest.py b/tools/manifest/test_manifest.py new file mode 100644 index 00000000..50f25650 --- /dev/null +++ b/tools/manifest/test_manifest.py @@ -0,0 +1,298 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for tools/manifest. Run: python3 -m pytest -q tools/manifest + +Three kinds of tamper, for every field of the manifest (enumerated mechanically by tamper.leaves): + 1. field changed, original signature kept -> must refuse (signature) + 2. field changed, re-signed by an attacker key -> must refuse (signer key is the caller's) + 3. field changed, re-signed by the TRUSTED key -> must refuse iff the field is bound to + something the caller holds (a file, cp_verify's output); otherwise the field is the signer's + word, and the test asserts exactly which fields those are (SIGNERS_WORD), so a new unbound + field cannot slip in unnoticed. +Plus: every bound file swapped for a one-byte-changed copy, non-canonical JSON, signature bit flip, +wrong trusted key, RFC 8032 vectors, cross-check against `cryptography` if installed. +""" + +import json +import os +import pathlib +import random +import shutil + +import pytest + +import ed25519 +import manifest as mf +import tamper as t + +needs_verifier = pytest.mark.skipif(not t.CP_VERIFY.is_file(), + reason="bin/cp_verify is not built (run verifier/build.sh)") + +# Fields that no caller-side file or cp_verify output pins, in the fixture's configuration. With +# --upstream-dir / --committed-dir / --public-weights-seed on a real model, upstream.files, +# conversion.committed_files and the seed digest move out of this set (tested below). +SIGNERS_WORD = { + "signer.name", + "upstream.generator", + "upstream.seed", + "conversion.script", # the path label; the script's digest is bound via --recipe + "conversion.repo_commit", + "conversion.version", + "conversion.params.M", + "conversion.params.config", + "conversion.params.weights", + "verifier.source_commit", +} + + +@pytest.fixture(scope="module") +def build(): + return t.build_digest() + + +@pytest.fixture() +def fx(tmp_path, build): + m, recipe = t.make_fixture(tmp_path, build) + return tmp_path, m, recipe + + +def run(tmp, m, recipe, secret=t.TRUSTED_SECRET, overrides=None, raw=None, sig=None): + mp, sp = t.write_signed(tmp, m, secret) + if raw is not None: + mp.write_bytes(raw) + if sig is not None: + sp.write_bytes(sig) + return mf.main(t.verify_argv(tmp, mp, sp, recipe, overrides)) + + +# ---- ed25519 ---- + +RFC8032 = [ # section 7.1, tests 1-3: secret, public, message, signature + ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"), + ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"), + ("c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"), +] + + +@pytest.mark.parametrize("sk,pk,msg,sig", RFC8032) +def test_rfc8032_vectors(sk, pk, msg, sig): + sk, pk, msg, sig = map(bytes.fromhex, (sk, pk, msg, sig)) + assert ed25519.public_key(sk) == pk + assert ed25519.sign(sk, msg) == sig + assert ed25519.verify(pk, msg, sig) + assert not ed25519.verify(pk, msg + b"\0", sig) + + +def test_cross_check_with_cryptography(): + c = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ed25519") + rng = random.Random(1104) + for _ in range(64): + sk = rng.randbytes(32) + msg = rng.randbytes(rng.randrange(0, 300)) + ref = c.Ed25519PrivateKey.from_private_bytes(sk) + assert ed25519.sign(sk, msg) == ref.sign(msg) + pub = ref.public_key().public_bytes_raw() + assert ed25519.public_key(sk) == pub + assert ed25519.verify(pub, msg, ref.sign(msg)) + + +def test_signature_s_not_reduced_is_refused(): + sk = bytes(32) + sig = ed25519.sign(sk, b"m") + s = int.from_bytes(sig[32:], "little") + ed25519.L + assert not ed25519.verify(ed25519.public_key(sk), b"m", sig[:32] + s.to_bytes(32, "little")) + + +# ---- the honest path ---- + + +@needs_verifier +def test_untampered_accepts(fx, capsys): + tmp, m, recipe = fx + assert run(tmp, m, recipe) == 0 + out = capsys.readouterr().out + assert "manifest: ACCEPTED" in out and "result-v1 status=0" in out + + +@needs_verifier +def test_none_fields_need_explicit_flags(fx): + tmp, m, recipe = fx + m["serving"]["system_prompt"] = {"none": True} + assert run(tmp, m, recipe) == 1 # caller still passes a system prompt file + assert run(tmp, m, recipe, overrides={"--system-prompt": None}) == 1 # no --no-system-prompt + assert run(tmp, m, recipe, overrides={"--system-prompt": None, "--no-system-prompt": True}) == 0 + + +# ---- tamper: every field ---- + + +def all_fields(build, tmp): + m, _ = t.make_fixture(tmp, build) + return [p for p in t.leaves(m)] + + +@needs_verifier +def test_every_field_tamper(tmp_path, build): + m, recipe = t.make_fixture(tmp_path, build) + paths = list(t.leaves(m)) + assert len(paths) >= 25 + accepted_by_trusted = set() + for path in paths: + name = t.path_name(path) + bad = t.mutated(m, path) + # 1. original signature kept + good_sig = t.write_signed(tmp_path, m)[1].read_bytes() + assert run(tmp_path, bad, recipe, sig=good_sig) == 1, name + # 2. attacker re-signs (with or without putting its own key in signer.public_key) + assert run(tmp_path, bad, recipe, secret=t.ATTACKER_SECRET) == 1, name + bad2 = json.loads(json.dumps(bad)) + if isinstance(bad2.get("signer"), dict): + bad2["signer"]["public_key"] = ed25519.public_key(t.ATTACKER_SECRET).hex() + assert run(tmp_path, bad2, recipe, secret=t.ATTACKER_SECRET) == 1, name + # 3. the trusted signer itself signs the changed field + if run(tmp_path, bad, recipe) == 0: + accepted_by_trusted.add(name) + assert accepted_by_trusted == SIGNERS_WORD + + +@needs_verifier +@pytest.mark.parametrize("which", ["policy.txt", "KL.bin", "layout.txt"]) +def test_config_file_swap(fx, which): + tmp, m, recipe = fx + d = t.copy_example(tmp) + t.flip_byte(d / "config" / which, offset=-2 if which == "policy.txt" else None) + assert run(tmp, m, recipe, overrides={"--config": str(d / "config")}) == 1 + + +@needs_verifier +@pytest.mark.parametrize("which", ["tokenizer", "chat_template", "system_prompt", "recipe"]) +def test_serving_file_swap(fx, which): + tmp, m, recipe = fx + target = {"tokenizer": tmp / "tokenizer" / "tokenizer.json", + "chat_template": tmp / "chat_template.jinja", + "system_prompt": tmp / "system_prompt.txt"}.get(which) + over = {} + if which == "recipe": + target = tmp / "recipe.sh" + shutil.copy(recipe, target) + over["--recipe"] = str(target) + t.flip_byte(target) + assert run(tmp, m, recipe, overrides=over) == 1 + + +@needs_verifier +def test_extra_tokenizer_file_refused(fx): + tmp, m, recipe = fx + (tmp / "tokenizer" / "special_tokens_map.json").write_text("{}\n") + assert run(tmp, m, recipe) == 1 + + +@needs_verifier +def test_missing_serving_files_refused(fx): + tmp, m, recipe = fx + for k in ("--tokenizer-dir", "--chat-template", "--system-prompt"): + assert run(tmp, m, recipe, overrides={k: None}) == 1, k + + +@needs_verifier +def test_end_tokens_mismatch(fx): + tmp, m, recipe = fx + assert run(tmp, m, recipe, overrides={"--end-tokens": "2"}) == 1 + + +@needs_verifier +@pytest.mark.parametrize("which", ["example.proof", "example.req", "example.y", "ivk/key.bin", + "ivk/leaf/0.bin"]) +def test_proof_side_swap_refused_by_cp_verify(fx, which): + tmp, m, recipe = fx + d = t.copy_example(tmp) + t.flip_byte(d / which) + over = {"--ivk": str(d / "ivk"), "--request": str(d / "example.req"), + "--y": str(d / "example.y"), "--proof": str(d / "example.proof")} + assert run(tmp, m, recipe, overrides=over) == 1 + + +@needs_verifier +def test_wrong_trusted_key(fx): + tmp, m, recipe = fx + (tmp / "other.pub").write_text(ed25519.public_key(t.ATTACKER_SECRET).hex() + "\n") + assert run(tmp, m, recipe, overrides={"--signer-key": str(tmp / "other.pub")}) == 1 + + +@needs_verifier +def test_signature_bit_flips(fx): + tmp, m, recipe = fx + good = t.write_signed(tmp, m)[1].read_bytes() + for i in range(0, 64, 7): + sig = bytearray(good) + sig[i] ^= 0x80 + assert run(tmp, m, recipe, sig=bytes(sig)) == 1, i + + +@needs_verifier +def test_non_canonical_json_refused(fx): + tmp, m, recipe = fx + pretty = json.dumps(m, indent=2, sort_keys=True).encode() + b"\n" + from tamper import TRUSTED_SECRET + sig = ed25519.sign(TRUSTED_SECRET, mf.signed_bytes(pretty)) # signed, but not canonical + assert run(tmp, m, recipe, raw=pretty, sig=sig) == 1 + + +@needs_verifier +def test_unknown_and_missing_keys_refused(fx): + tmp, m, recipe = fx + m2 = json.loads(json.dumps(m)) + m2["extra"] = 1 + assert run(tmp, m2, recipe) == 1 + m3 = json.loads(json.dumps(m)) + del m3["serving"]["system_prompt"] + assert run(tmp, m3, recipe) == 1 + + +@needs_verifier +def test_policy_kind_must_match_limits(fx): + tmp, m, recipe = fx + m["decode_policy"]["kind"] = "clipped-gap" + assert run(tmp, m, recipe) == 1 + + +# ---- optional rehash of upstream / committed files / public-weights seed ---- + + +def test_upstream_and_committed_files_rehashed(tmp_path): + up = tmp_path / "up" + up.mkdir() + (up / "model.safetensors").write_bytes(b"w" * 1000) + (up / "config.json").write_text("{}\n") + entries = [{"path": n, "sha256": mf.sha256_file(up / n)} for n in sorted(os.listdir(up))] + mf.check_files(entries, up, "upstream.files") + t.flip_byte(up / "model.safetensors") + with pytest.raises(mf.Refusal, match="model.safetensors digest differs"): + mf.check_files(entries, up, "upstream.files") + (up / "model.safetensors").write_bytes(b"w" * 1000) + (up / "adapter.safetensors").write_bytes(b"a") + with pytest.raises(mf.Refusal, match="adapter.safetensors is present but not listed"): + mf.check_files(entries, up, "upstream.files") + + +def test_huggingface_revision_must_be_a_commit(tmp_path, build): + m, _ = t.make_fixture(tmp_path, build) + m["upstream"] = {"kind": "huggingface", "repo": "org/model", "revision": "main", + "files": [{"path": "a", "sha256": "0" * 64}]} + with pytest.raises(mf.Refusal, match="revision"): + mf.check_schema(m) + m["upstream"]["revision"] = "a" * 40 + m["conversion"]["committed_files"] = [{"path": "b", "sha256": "1" * 64}] + mf.check_schema(m) + + +def test_public_weights_needs_seed_digest(tmp_path, build): + m, _ = t.make_fixture(tmp_path, build) + m["commitment"]["kind"] = "public-weights" + with pytest.raises(mf.Refusal, match="public_weights_seed"): + mf.check_schema(m) From 078d7429ede8d319d0c3c06ac525558340ace93a Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:31:55 +0000 Subject: [PATCH 03/12] experiments: signed manifest results (PASS 137/137), design note on proven abliterated models on vast (#58) --- .../20261104-58-signed-manifest/ENV.json | 8 + .../20261104-58-signed-manifest/PREREG.md | 3 +- .../20261104-58-signed-manifest/RESULTS.md | 53 + .../20261104-58-signed-manifest/numbers.json | 1151 +++++++++++++++++ .../20261104-58-signed-manifest/run.py | 2 +- research/security/DESIGN-VAST-ABLITERATED.md | 292 +++++ tools/manifest/README.md | 53 + 7 files changed, 1560 insertions(+), 2 deletions(-) create mode 100644 experiments/20261104-58-signed-manifest/ENV.json create mode 100644 experiments/20261104-58-signed-manifest/RESULTS.md create mode 100644 experiments/20261104-58-signed-manifest/numbers.json create mode 100644 research/security/DESIGN-VAST-ABLITERATED.md create mode 100644 tools/manifest/README.md diff --git a/experiments/20261104-58-signed-manifest/ENV.json b/experiments/20261104-58-signed-manifest/ENV.json new file mode 100644 index 00000000..1f935db7 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/ENV.json @@ -0,0 +1,8 @@ +{ + "commits": {"base": "eb6e6ea379db87b3bd34c6377414422ed6a236dd", "measured_at": "95b126083555f90c3e2dd1fd64d179c75d01cb1e", "verifier_build_digest": "ea191b014747c2c6c5c3c4622a06fa51bbda1c2591b314678c717f351663bd66"}, + "toolchain": {"python": "3.12.3", "gxx": "g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", "pytest": "9.1.1", "cryptography": "41.0.7 (cross-check only; tools/manifest uses the standard library)"}, + "hardware": {"key": "cpu-epyc-rome-kvm-16", "cpu": "AMD EPYC-Rome KVM, 16 vCPUs", "ram_gib": 30, "os": "Ubuntu 24.04", "shared": true, "gpu": "none"}, + "seeds": [1104], + "inputs": "examples/accepted-proof (stored synthetic proof, ID 79a5a2bc...22183); fixture tokenizer/template/system prompt written by tools/manifest/tamper.py", + "commands": ["verifier/build.sh", "python3 -m pytest -q -p no:cacheprovider tools/manifest", "python3 experiments/20261104-58-signed-manifest/run.py"] +} diff --git a/experiments/20261104-58-signed-manifest/PREREG.md b/experiments/20261104-58-signed-manifest/PREREG.md index 0ea849e7..3b678e6e 100644 --- a/experiments/20261104-58-signed-manifest/PREREG.md +++ b/experiments/20261104-58-signed-manifest/PREREG.md @@ -31,4 +31,5 @@ Untampered verify: n=10, same inputs, deterministic (repeat counts a determinism What is new: nothing cryptographically; it is the missing glue between a FlashProve commitment ID and an upstream checkpoint + serving template, with tamper tests. ## Amendments -(empty at registration) + +- 2026-11-04 23:20 UTC, after code. Added a third tamper arm not named at registration: each field changed and **re-signed by the trusted signer key itself**. It has no threshold. It sorts fields into those the client can check against its own files or cp_verify's output ("bound") and those that are only the signer's word; the test suite asserts that set exactly, so a new unbound field fails a test. The registered PASS rule (100% of tampers refused) applies to the registered arms only: original signature kept, attacker key, attacker key written into `signer.public_key`, file substitutions, signature bit flips, non-canonical JSON. Reason: a signed manifest cannot stop its own signer from lying about fields nobody can rehash; saying which fields those are is the useful output. diff --git a/experiments/20261104-58-signed-manifest/RESULTS.md b/experiments/20261104-58-signed-manifest/RESULTS.md new file mode 100644 index 00000000..90246e00 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/RESULTS.md @@ -0,0 +1,53 @@ +# RESULTS: 20261104-58-signed-manifest + +Issue #58. **Verdict: PASS** against the registered threshold. Measured, unreplicated, CPU only, +synthetic stored example. Raw data: `numbers.json` (every row with exit code and the refusal +line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (31 passed). + +| registered metric | result | threshold | | +|---|---|---|---| +| tampers refused, registered arms | **137 / 137** | 100% | PASS | +| untampered accepted (`cp_verify` exit 0, `YES`, manifest ACCEPTED) | **10 / 10** | 10 / 10, 0 false refusals | PASS | +| RFC 8032 section 7.1 vectors 1-3 | **3 / 3** | all | PASS | +| cross-check vs `cryptography` 41.0.7, 64 random messages, seed 1104 | **64 / 64** identical signatures | (not registered as a threshold) | | +| verify wall time, whole call incl. one `cp_verify` | median **1.44 s**, SD 0.10, min 1.40, max 1.75 (n = 10) | none | info | + +The 137 registered tampers: 37 manifest fields (every JSON leaf, enumerated mechanically) x 3 +arms (original signature kept; re-signed by an attacker key; attacker key also written into +`signer.public_key`) = 111, plus 15 file substitutions (3 configuration files, 5 proof-side files, +tokenizer file, extra tokenizer file, chat template, system prompt, recipe script, wrong trusted +key, end tokens), 10 signature bit flips, 1 non-canonical JSON. The n = 10 untampered runs repeat a +deterministic check; they are not independent trials. + +Who refused the proof-side swaps: `cp_verify` itself (`NO: proof-refused` for proof and request, +`NO: token-range` for y, `NO: key-refused`, `NO: entry-refused`); the manifest layer then refuses on +its non-zero exit. A changed `commitment.id` re-signed by the trusted key is refused by `cp_verify` +(`NO: model-statement`), which is where the manifest's ID meets the proof. + +## Amendment arm: changed field re-signed by the trusted key (no threshold) + +37 fields: 27 refused because the client holds something to check them against, 10 accepted +because only the signer's word stands behind them in this fixture: +`signer.name`, `upstream.generator`, `upstream.seed`, `conversion.script` (path label), +`conversion.repo_commit`, `conversion.version`, `conversion.params.{M,config,weights}`, +`verifier.source_commit`. + +This is expected and is the point: a signature authenticates the signer, not the truth of what it +signed. Those fields become checkable only by rederivation (rerun the recipe, get the same ID), +which needs `cp_commit` on an H100 and a `-public-weights` commitment. For a real model, +`upstream.files`, `conversion.committed_files` and the public seed digest are rehashed when the +client passes `--upstream-dir`, `--committed-dir`, `--public-weights-seed` (unit-tested on +synthetic files, not on a real checkpoint). + +## Not shown + +- No real model, no real tokenizer or chat template (fixture files stand in); Maverick manifest + not built (would need its 232 GB of weights to hash, out of scope). +- Binding is client-side and detached: the manifest digest is not inside the configuration or the + proof (threat model M1 says that needs a parser change, untested). +- `ed25519.py` signing is not constant time; it is for tests. Verification works on public data. +- No independent replication yet. + +Proposed claim (for the reviewer, not added to `research/results.yaml` or `loop/claims.json`): +"tools/manifest refuses 137 of 137 registered tampers and accepts 10 of 10 untampered verifies of +the stored small example; 10 of 37 fields are the signer's word only." diff --git a/experiments/20261104-58-signed-manifest/numbers.json b/experiments/20261104-58-signed-manifest/numbers.json new file mode 100644 index 00000000..5d8617f7 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/numbers.json @@ -0,0 +1,1151 @@ +{ + "rows": [ + { + "field": "commitment.id", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "commitment.id", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "commitment.id", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: cp_verify: exit 1, last line ['NO: model-statement']" + }, + { + "field": "commitment.id", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "commitment.kind", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "commitment.kind", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "commitment.kind", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: commitment.kind: not public-weights or hiding" + }, + { + "field": "commitment.kind", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.KL.bin", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.KL.bin", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.KL.bin", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: configuration.KL.bin: file digest differs" + }, + { + "field": "configuration.KL.bin", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.layout.txt", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.layout.txt", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.layout.txt", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: configuration.layout.txt: file digest differs" + }, + { + "field": "configuration.layout.txt", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.policy.txt", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.policy.txt", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "configuration.policy.txt", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: configuration.policy.txt: file digest differs" + }, + { + "field": "configuration.policy.txt", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.committed_files", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.committed_files", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.committed_files", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: conversion.committed_files[0]: not an object" + }, + { + "field": "conversion.committed_files", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.M", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.M", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.M", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "conversion.params.M", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.config", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.config", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.config", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "conversion.params.config", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.weights", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.weights", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.params.weights", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "conversion.params.weights", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.public_weights_seed_sha256", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.public_weights_seed_sha256", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.public_weights_seed_sha256", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: conversion.public_weights_seed_sha256: must be null for a hiding commitment" + }, + { + "field": "conversion.public_weights_seed_sha256", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.repo_commit", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.repo_commit", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.repo_commit", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "conversion.repo_commit", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.script", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.script", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.script", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "conversion.script", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.script_sha256", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.script_sha256", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.script_sha256", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: conversion.script_sha256: recipe file digest differs" + }, + { + "field": "conversion.script_sha256", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.version", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.version", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "conversion.version", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "conversion.version", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.C", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.C", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.C", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.kind: does not match its limits" + }, + { + "field": "decode_policy.C", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.F", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.F", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.F", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.F: differs from policy.txt" + }, + { + "field": "decode_policy.F", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.cap", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.cap", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.cap", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.kind: does not match its limits" + }, + { + "field": "decode_policy.cap", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.kind", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.kind", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.kind", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.kind: does not match its limits" + }, + { + "field": "decode_policy.kind", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.s", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.s", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.s", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.s: differs from policy.txt" + }, + { + "field": "decode_policy.s", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.sT", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.sT", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.sT", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.kind: does not match its limits" + }, + { + "field": "decode_policy.sT", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.sh", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.sh", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.sh", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.sh: differs from policy.txt" + }, + { + "field": "decode_policy.sh", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.theta", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.theta", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "decode_policy.theta", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: decode_policy.kind: does not match its limits" + }, + { + "field": "decode_policy.theta", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "format", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "format", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "format", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: format: not flashprove-manifest-v1" + }, + { + "field": "format", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.chat_template.sha256", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.chat_template.sha256", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.chat_template.sha256", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.chat_template: file digest differs" + }, + { + "field": "serving.chat_template.sha256", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.end_tokens", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.end_tokens", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.end_tokens", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.end_tokens: differ from --end-tokens" + }, + { + "field": "serving.end_tokens", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.system_prompt.sha256", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.system_prompt.sha256", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.system_prompt.sha256", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.system_prompt: file digest differs" + }, + { + "field": "serving.system_prompt.sha256", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.0.path", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.0.path", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.0.path", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: tokenizer.jsonx missing" + }, + { + "field": "serving.tokenizer.files.0.path", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.0.sha256", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.0.sha256", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.0.sha256", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: tokenizer.json digest differs" + }, + { + "field": "serving.tokenizer.files.0.sha256", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.1.path", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.1.path", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.1.path", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: tokenizer_config.jsonx missing" + }, + { + "field": "serving.tokenizer.files.1.path", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.1.sha256", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.1.sha256", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "serving.tokenizer.files.1.sha256", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: tokenizer_config.json digest differs" + }, + { + "field": "serving.tokenizer.files.1.sha256", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signer.name", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signer.name", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signer.name", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)x" + }, + { + "field": "signer.name", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signer.public_key", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signer.public_key", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signer.public_key", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: signer.public_key: not the trusted signer key" + }, + { + "field": "signer.public_key", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.generator", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.generator", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.generator", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "upstream.generator", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.kind", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.kind", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.kind", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: upstream.kind: not huggingface or synthetic" + }, + { + "field": "upstream.kind", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.seed", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.seed", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "upstream.seed", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "upstream.seed", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "verifier.build_digest", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "verifier.build_digest", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "verifier.build_digest", + "kind": "trusted-sig", + "exit": 1, + "last": "manifest: REFUSED: verifier.build_digest: differs from cp_verify's build=" + }, + { + "field": "verifier.build_digest", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "verifier.source_commit", + "kind": "orig-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "verifier.source_commit", + "kind": "attacker-sig", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "verifier.source_commit", + "kind": "trusted-sig", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "field": "verifier.source_commit", + "kind": "attacker-sig+key", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "config/policy.txt", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: configuration.policy.txt: file digest differs" + }, + { + "field": "config/KL.bin", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: configuration.KL.bin: file digest differs" + }, + { + "field": "config/layout.txt", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: configuration.layout.txt: file digest differs" + }, + { + "field": "example.proof", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: cp_verify: exit 1, last line ['NO: proof-refused']" + }, + { + "field": "example.req", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: cp_verify: exit 1, last line ['NO: proof-refused']" + }, + { + "field": "example.y", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: cp_verify: exit 1, last line ['NO: token-range']" + }, + { + "field": "ivk/key.bin", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: cp_verify: exit 1, last line ['NO: key-refused']" + }, + { + "field": "ivk/leaf/0.bin", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: cp_verify: exit 1, last line ['NO: entry-refused']" + }, + { + "field": "tokenizer.json", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: tokenizer.json digest differs" + }, + { + "field": "chat_template", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: serving.chat_template: file digest differs" + }, + { + "field": "system_prompt", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: serving.system_prompt: file digest differs" + }, + { + "field": "recipe", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: conversion.script_sha256: recipe file digest differs" + }, + { + "field": "tokenizer extra file", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: added_tokens.json is present but not listed" + }, + { + "field": "wrong trusted key", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "end tokens differ", + "kind": "file-substitution", + "exit": 1, + "last": "manifest: REFUSED: serving.end_tokens: differ from --end-tokens" + }, + { + "field": "signature byte 0", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 7", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 14", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 21", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 28", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 35", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 42", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 49", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 56", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "signature byte 63", + "kind": "sig-bitflip", + "exit": 1, + "last": "manifest: REFUSED: signature: does not verify under the trusted signer key" + }, + { + "field": "non-canonical JSON", + "kind": "encoding", + "exit": 1, + "last": "manifest: REFUSED: manifest: not in canonical form" + } + ], + "untampered": [ + { + "i": 0, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4514925815165043 + }, + { + "i": 1, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.472631748765707 + }, + { + "i": 2, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4811270497739315 + }, + { + "i": 3, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4288384728133678 + }, + { + "i": 4, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.746917575597763 + }, + { + "i": 5, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.472553450614214 + }, + { + "i": 6, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4135536700487137 + }, + { + "i": 7, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.419287234544754 + }, + { + "i": 8, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.39988424628973 + }, + { + "i": 9, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.410835389047861 + } + ], + "rfc8032": { + "matched": 3, + "total": 3 + }, + "cross_check": { + "agree": 64, + "total": 64, + "cryptography": "41.0.7" + }, + "summary": { + "untampered_accepted": 10, + "untampered_n": 10, + "verify_wall_s": { + "median": 1.440165527164936, + "sd": 0.10167006071503702, + "min": 1.39988424628973, + "max": 1.746917575597763, + "n": 10, + "note": "whole verify call: manifest checks plus one cp_verify process on the stored small example" + }, + "fields": 37, + "must_refuse_total": 137, + "must_refuse_refused": 137, + "trusted_resign_refused": 27, + "trusted_resign_accepted_fields": [ + "conversion.params.M", + "conversion.params.config", + "conversion.params.weights", + "conversion.repo_commit", + "conversion.script", + "conversion.version", + "signer.name", + "upstream.generator", + "upstream.seed", + "verifier.source_commit" + ] + }, + "host": { + "python": "3.12.3", + "machine": "x86_64" + } +} diff --git a/experiments/20261104-58-signed-manifest/run.py b/experiments/20261104-58-signed-manifest/run.py index f3bcaa41..23269f96 100644 --- a/experiments/20261104-58-signed-manifest/run.py +++ b/experiments/20261104-58-signed-manifest/run.py @@ -150,7 +150,7 @@ def main(): "untampered_n": len(out["untampered"]), "verify_wall_s": {"median": statistics.median(walls), "sd": statistics.stdev(walls), "min": min(walls), "max": max(walls), "n": len(walls), - "note": "includes one cp_verify process (about 1.5 s of it)"}, + "note": "whole verify call: manifest checks plus one cp_verify process on the stored small example"}, "fields": len(trusted), "must_refuse_total": len(must_refuse), "must_refuse_refused": sum(r["exit"] == 1 for r in must_refuse), diff --git a/research/security/DESIGN-VAST-ABLITERATED.md b/research/security/DESIGN-VAST-ABLITERATED.md new file mode 100644 index 00000000..5bd63947 --- /dev/null +++ b/research/security/DESIGN-VAST-ABLITERATED.md @@ -0,0 +1,292 @@ +# Design note: proven abliterated models on vast hosts, compared with amiller/dsmack + +Issue #58 (part of #6, #36, #9). Builds on the threat model of PR #54 (open, unmerged; cited by +path `research/security/threat-model/` on its branch) and the signed manifest prototype in +`tools/manifest/` (this PR). Design only. No GPU was rented and nothing in this note is a result; +nothing goes into `research/results.yaml`. + +Labels used below: **measured** (a receipt in this repository), **cited** (an upstream or external +source, linked), **inferred** (arithmetic or reasoning on those, not run). + +The question (Shadow, 2026-11-04): "can we do something like amiller/dsmack with vast nodes running +proven abliterated models and everything is attested?" + +Short answer: **partly.** Integrity of *which weights produced the reply* survives on an untrusted +vast host with FlashProve alone, and a signed manifest plus reproducible derivation can make +"which weights" mean "this published base model, abliterated by this published recipe". Prompt +privacy does not survive: a vast host sees every prompt in plaintext, and nothing in FlashProve +changes that. "Everything is attested" in the dsmack sense (hardware-rooted, which program runs, +sealed inputs) is not available on vast today. See section 1. + +## 1. What each system gives, and what survives on vast + +### 1.1 dsmack (read from its repository, 2026-11-04) + +Sources: [README](https://github.com/amiller/dsmack), +[docs/architecture.md](https://github.com/amiller/dsmack/blob/main/docs/architecture.md), +[contracts/DEPLOY.md](https://github.com/amiller/dsmack/blob/main/contracts/DEPLOY.md), +[applications/private-agent-web](https://github.com/amiller/dsmack/blob/main/applications/private-agent-web/README.md), +[applications/relay-broker](https://github.com/amiller/dsmack/blob/main/applications/relay-broker/README.md). + +- **Which program is running, hardware-rooted (cited).** Apple App Attest binds each assertion to a + key that only the enrolled, signed Mac app can use. `CDRegistry` on Base Sepolia approves one + runner build (CDHash, masked page 0, entitlements) and admits re-signed copies of it; + `AppleAttestRegistryV1` admits App Attest keys whose CDHash resolves. The runner launches a Linux + VM with a measured kernel/initramfs and a verity-protected rootfs, then an OCI workload whose + digest, compose manifest and profile are bound into a launch assertion and a result assertion. +- **What it rests on (cited).** Apple's root of trust, SIP and the hardened runtime ("no process on + the Mac, root included, can read the runner process"). Physical memory probing and kernel + exploits are out of its model. **Private inputs are staged as plaintext disk files on the host** + (architecture.md, "Trust"), so dsmack's Mac layer is integrity + program identity, not + confidentiality against a determined Mac owner. +- **Model inference is not on the Macs (cited).** In private-agent-web, "model requests and + responses remain encrypted between the browser and NEAR AI Cloud's model TEE; the broker + forwards ciphertext and meters usage". The relay broker itself runs in a dstack CVM (Intel TDX), + and email recovery checks a TDX quote. The prompt privacy in dsmack comes from a **TDX + GPU TEE at + NEAR**, not from App Attest. +- **Marketplace pieces (cited).** Node enrollment (`enroll.py`, App Attest chain), signed + hb_signals with ownership links (EIP-712 `MacLink`), Ed25519 byte/time grants, a metering and + settlement loop on Base Sepolia (`applications/payments`), bounty escrow (`work-escrow/`), and a + forgery canary contract that pays out on a forged assertion. + +### 1.2 FlashProve (this repository) + +- **What it proves (cited, `guide/what-is-proved.md`, `docs/limits.md`).** For a request file the + client builds, the checked tokens are those that the *committed integer model* ranks first + (exact match) or that pass the clipped-gap limits, under the commitment ID that the client + names. It needs no hardware trust: an untrusted GPU host produces the proof, any CPU checks it + (about 1.4 s for the small example, **measured** below; about 21 s single-thread at full size, + **cited** `guide/performance.md`). The binding rests on stated assumptions (BLAKE2b as a random + oracle, the codes theorem taken as given, a not-public paper argument), listed in + `examples/accepted-proof/README.md`. +- **What it does not give.** Which *program* ran (irrelevant to the proof, by design). Which + *checkpoint* the ID stands for (the ID is the provider's own commitment unless a manifest pins + it: threat model M1). What *template, system prompt, tokenizer* wrapped the client's message + (M2). Prompt confidentiality: the prover must read the prompt to prove the reply. + +### 1.3 On an untrusted vast host + +Vast hosts are a marketplace of independent machines running "an unprivileged Docker container", +with Docker-level tenant isolation and a "Secure Cloud" tier that adds datacenter agreements and +DPAs (**cited**, ). Our own receipts are two on-demand vast H100 rentals, +one "unverified listing" and one "provider-verified listing" (**measured**, `research/gpu/README.md`, +`research/gpu/replication-1/README.md`). + +Confidential computing on vast (**cited**): vast's own article on NVIDIA CC (2024-03-15) ends "some +hosts are experimenting with NVIDIA Confidential Computing. Complete support for this feature is +not yet available" +(). The compliance page +lists SOC 2, HIPAA on Secure Cloud and Docker isolation, and does not mention confidential +computing or hardware attestation. A third-party comparison page says the same +(, a competitor's page, weak source). We found +no vast offer filter, API field or document for H100 CC mode, TDX or SEV-SNP. **Conclusion +(inferred from those sources):** as of 2026-11-04 a vast renter cannot select an attested +CC-on H100 inside a TDX/SEV-SNP CVM, and the host operator has root on the machine that sees the +prompt. H100 CC requires a CPU TEE (TDX or SEV-SNP) on the host +(); that is a host +configuration, not something a container tenant can turn on. + +| Guarantee | FlashProve alone on vast | Needs a TEE (CC-on H100 in TDX/SNP CVM) | dsmack today | +|---|---|---|---| +| Reply came from committed weights W (integrity of the computation) | **yes**, cryptographic, no hardware trust | also yes, under hardware trust | no (no model on the Macs; NEAR TEE attests its own image) | +| W is the published base model + published recipe | **yes with manifest + rederivation** (section 2) | TEE attests image, not weights, unless the image pins a weight hash | n/a | +| Template / system prompt / tokenizer are the published ones | **yes with manifest M2** if the client rebuilds the window itself | yes if the attested image pins them | workload digest pins them, for its workloads | +| Which program ran on the node | no (not needed for the proof) | yes | **yes** (App Attest + CDRegistry) | +| Prompt and reply hidden from the host | **no**, host sees plaintext | **yes**, within the CC threat model | Mac layer: no (plaintext on host disk); model layer: yes (NEAR TEE) | +| Host cannot withhold, delay or selectively refuse | no | no | no | +| Payment only for proved work | **yes**: settle on a verified proof | yes, on attestation | yes, on assertions | +| Safety / no backdoor in W | no | no | no | + +So on vast, FlashProve alone gives a *public, hardware-free* integrity guarantee that dsmack does +not have for inference, and none of the confidentiality. A privacy-preserving version needs a TEE +provider (Phala, NEAR AI, Azure CC H100, or a vast host that some day ships CC); then the proof and +the attestation are complementary, as the README already says ("signatures authenticate messages, +TEE evidence authenticates measured execution under hardware trust, and cryptographic proofs +establish a specified relation"). + +## 2. "Proven abliterated": reproducible derivation + +Abliteration (Arditi et al., ) computes a refusal direction r +from mean activations on harmful vs harmless prompt sets at a chosen layer, then orthogonalizes +every matrix that writes to the residual stream (embedding, attention out, MLP down; for an MoE +every expert's down projection) against r: `W' = W - r r^T W`. Given base weights, r and the +recipe, it is deterministic arithmetic. + +### 2.1 Derivation pipeline that a third party can rerun + +``` +base checkpoint (HF repo @ 40-hex revision, file sha256s) <- manifest.upstream + + direction r (published tensor, sha256) <- conversion.params / committed file + + abliterate script (sha256, pinned torch/numpy, fp32 math, fixed op order) + -> abliterated fp16/bf16 safetensors (sha256s) + + quantize/convert (pinned GGUF quantizer version, no imatrix or a published one) + -> integer-model weights + + scales/ fit (pinned calibration set, deterministic) -> policy.txt, KL.bin, layout.txt + + cp_commit -public-weights + -> commitment ID <- manifest.commitment.id +``` + +Every arrow is a pinned program on pinned inputs. A manifest (this PR's `tools/manifest/`) records +each input digest and the recipe; the signer signs it; anyone who reruns the chain and gets the same +ID has checked the signer's claim rather than trusted it. Today that rerun is possible only for +`-public-weights` commitments (hiding ones mix in a secret seed, `prover/common/public_weights.h`), +and the last step needs an H100 and about 330 GB of host memory for Maverick (**cited**, +`examples/maverick/figures.txt`: commit 43 minutes, peak 310 GB). The Maverick example already +does the last three arrows for the unmodified base (`examples/maverick/run.sh`, ID +`c7982f8a...71f46` in `expected.txt`); abliteration adds two arrows in front. + +Determinism of each arrow is **not tested** in this lane. Known risks (inferred): GPU matmul +nondeterminism (do the projection on CPU in fp32, or in integer arithmetic on the already-quantized +weights, see 2.3); GGUF quantizers differ across llama.cpp versions; importance-matrix +quantization depends on calibration text; `scales/calibrate.py` must be shown to give identical +`KL.bin` on two machines. Threat model M3 costs this at 1 to 2 weeks per model (inferred, #54). + +### 2.2 What this proves, and what it does not + +Proves (given a matching rederivation and a valid proof): +- the reply was computed by *exactly* the integer model obtained from that base, that direction and + that recipe, under the stated decode policy; +- nobody swapped in a different fine-tune, an adapter or a "lightly edited" variant after vetting + (threat model A2, A3); +- the template, system prompt and tokenizer around the message are the manifest's (M2), if the + client rebuilds the window from its own message. + +Does **not** prove: +- **that refusals are gone.** Abliteration is a behavioral intervention; whether it worked is an + eval claim about the integer model, not a property of the proof. Run the refusal evals on the + integer model (the CPU integer-model package can), and publish them as evals, not as proofs. +- **that nothing else was inserted.** The direction r is a published tensor: a malicious publisher + can choose an r that does more than ablate refusal, and the base checkpoint itself can carry a + sleeper (threat model `SLEEPER.md` on PR #54's branch). Rederivation shows the recipe was + followed, not that the recipe is benign. Publishing how r was computed (prompt sets, layer, + script) lets others recompute r too, which narrows this to "the base and the prompt sets". +- **that quantization did not activate something.** #54 cites quantization-activated backdoors + (Egashira et al., "Mind the Gap", ICML 2025, arXiv 2505.23786): a full-precision model that behaves well can + be built to misbehave only after a given quantizer. Abliterating in full precision and then + quantizing inherits exactly that risk. Mitigation: vet and eval the *integer* artifact (M3), and + prefer exact match (M4) for anything safety-relevant, because clipped gap lets a provider pick a + second-ranked token (#50, #51: one swap proved and accepted 15/16 and 13/16 under the shipped + limits, measured, unreplicated, honest-prover only). +- that the base publisher's weights are what they claim to be about training data or provenance. + +### 2.3 A cheaper variant worth testing + +Instead of abliterating in bf16 and requantizing, project r out of the **already committed +integer weights** with an exact integer projection (the model-identity lane did this for a +synthetic coordinate direction: `experiments/20261103-model-identity/PREREG.md`, "projection of +coordinate direction e_0 ... exact integer projection"). That removes the quantizer from the +abliterated arrow, so the derivation is base integer artifact (already reproducible for Maverick) ++ r + a short integer script. Cost: it is not the same model as a bf16 abliteration followed by +quantization, and a general (non-coordinate) r needs a rounding rule that must itself be pinned. +Untested at real scale. + +## 3. Marketplace shape, mirroring dsmack + +``` + Base (L2) off-chain + +---------------------------+ +------------------------------------+ + | ModelRegistry | read | client / gateway | + | manifestHash -> {id, |<--------| 1. pick model, fetch manifest + | + | signer, status} | | signature (IPFS / HTTPS) | + | approve / revoke by | | 2. tools/manifest verify (keys | + | curator (multisig) | | from registry, not from node) | + +---------------------------+ | 3. build own request window | + | NodeRegistry | | from manifest template | + | node key, stake, served | | 4. send prompt to node | + | manifestHashes | | 5. get reply + proof | + +---------------------------+ | 6. cp_verify + manifest verify | + | Escrow / Settlement |<--------| 7. sign "verified" receipt | + | per-request reservation, | settle +------------------------------------+ + | release on client-signed | ^ | prompt (plaintext) + | receipt or verifier | | v + | attestor quorum; slash | +------------------------------------+ + | on a signed bad proof | | vast node: cp_commit once, serve, | + +---------------------------+ | cp_prove per reply, publish proof | + +------------------------------------+ +``` + +- **Model registry (mirrors CDRegistry).** Stores approved manifest hashes and their commitment + IDs, with the signer key. Approval is a curator decision, like `setBuild`; anyone can register a + rederived manifest that matches an approved recipe, like `registerBuild`. Revocation voids it. + The contract does not verify proofs. +- **Node enrollment.** Without App Attest there is no hardware identity, so enrollment is a staked + key plus the list of manifest hashes it serves. It is economic, not attested: the node can lie + about its hardware but cannot fake proofs. +- **Per request.** The node proves each reply. The client (or a verifier service it trusts, or a + quorum of them) runs `cp_verify` and `tools/manifest verify` off-chain. Verifying on-chain is out + of reach: an 18.78 MB proof and about 21 s of CPU per full-size check (**cited**, + `guide/performance.md`). +- **Settlement on Base.** The client's signed "verified" receipt releases the reservation, as in + dsmack's payments loop. A node that signs a proof that fails verification can be slashed by + anyone who posts it to a verifier quorum (an optimistic scheme; the dispute game is not designed + here). A node that just withholds is not slashable by proof, only by timeout. +- **Forgery canary (mirrors dsmack's Canary).** A pot that pays anyone who presents an accepted + proof under a registered ID for a reply that the committed integer model does not produce. + Worth having: it prices the soundness assumptions publicly. + +### 3.1 Cost per request + +| quantity | value | label | +|---|---|---| +| vast H100 on-demand price, our two rentals | $2.40/h and $2.16/h | measured, `research/gpu/README.md`, `research/gpu/replication-1/README.md` | +| small synthetic example, whole session all in | $1.19 and $0.92 | measured, same files | +| full-size Maverick prefill, 1,024 tokens, batch 24, steady | 9.206 +/- 0.077 s per request on one H100 | cited, `guide/performance.md` (upstream, not reproduced) | +| proving vs stock vLLM prefill, per GPU | 93.0x at the highest load tried (92.2 to 94.1), 71.4x at 24 requests | cited, `guide/performance.md`; calculated upstream, FP8 engine on 8 H100s vs 4-bit integer prover on 1 | +| whole replies, clipped gap, batch 24 | 39.1 +/- 1.0 reply tokens/s on one H100 | cited, same | +| commit once per model | 43 min, 310 GB host peak | cited, `examples/maverick/figures.txt` (other programs, not the released ones) | +| client verify, full size | about 21 s on one thread, up to 3.53 GB | cited, `guide/performance.md` | +| **GPU cost per proved 1,024-token prefill request** | 9.206 s x $2.16 to 2.40/h = **$0.0055 to $0.0061** | inferred | +| **GPU cost per proved 500-token reply** (whole-reply rate) | 500 / 39.1 = 12.8 s = **$0.0077 to $0.0085** | inferred | +| commit per model per node | 43 min = **$1.55 to $1.72** | inferred, and only on hosts with about 330 GB RAM | + +Caveats (inferred): these use upstream's best-case steady rates (start-up and commit excluded); +the host must have about 330 GB of RAM for Maverick, which most single-H100 vast offers do not +(our rentals were not checked for that); the 93x compares against an FP8 engine on other hardware, +so "93x the price of unproved inference" is a rough ceiling, not a measured price ratio on vast. +Client-side verification at about 21 CPU-seconds per proof is a real cost for a gateway and is +not in the GPU figure. + +### 3.2 Which models are provable today + +- **Llama 4 Maverick: yes, end to end** (cited upstream: public 4-bit files, commitment recomputed + and compared, proof checked; `examples/maverick/`). This is the only real model. It is also the + only candidate for "proven abliterated" now. +- **Qwen3.8 27B: no.** Feasibility scoped (#28, committed 26.9 B parameters, 7 new operator + families); DeltaNet head M1 proved one head but FAILED its range bound (#47, merged, negative); + M1b (#52, open) KILLED as registered. No layer, no block, no model. +- **GLM: no.** KDA slice and model-scale sweep only (#42, open, pre-standards, synthetic). No + model. +- Everything else: not ported. + +## 4. Verdict and smallest demo path + +**Verdict.** "Vast nodes running proven abliterated models" is buildable with integrity, not with +privacy. FlashProve on vast gives what dsmack's Mac layer cannot give for inference: a public, +hardware-free check that a reply came from committed weights, and a signed manifest ties those +weights to "base + direction + recipe". It does **not** make anything "attested" in dsmack's +sense, does not hide prompts from the host, does not prove the model is uncensored or clean, and +today only one model (Maverick) works, at about 330 GB of host RAM, with a reproducible +abliteration chain that nobody has run yet. "Everything is attested" needs a TEE host, which vast +does not offer (section 1.3). + +**Smallest demo path** (each step is a lane with its own PREREG; costs inferred): + +1. **CPU, now (this PR).** Signed manifest + client verify on the stored synthetic example, tamper + tests. Done here. +2. **CPU, 1 to 2 days.** Abliterate the *synthetic* small model with an integer projection + (2.3) using a published direction, sign its manifest, show two independent reruns produce + byte-identical weights and config files. No commitment yet (CPU cannot run `cp_commit`). +3. **One H100, about $1 to $2.** `cp_commit -public-weights` on the abliterated synthetic model on + two separate rentals: same ID twice, then prove + verify through `tools/manifest`. This is the + first "proven abliterated" receipt, synthetic. Needs Shadow's GPU approval. +4. **Contracts, Base Sepolia, a few days.** Registry + escrow with client-signed receipts, + mirroring dsmack's CDRegistry/payments. No proof on-chain. +5. **Maverick abliterated, one large-RAM H100 host, hours and tens of dollars.** Integer + projection of a published refusal direction on the committed Maverick artifact, rederive, + commit, prove, plus refusal evals on the integer model published as evals. Only after 2 and 3 + pass. +6. **Privacy, separate track.** Run the same prover inside a CC-on H100 CVM at a TEE provider + and bind the TEE quote and the proof to the same request. Not on vast. + +Proposed claims: none. Every number above is measured elsewhere (linked) or cited; nothing here is +a new result. diff --git a/tools/manifest/README.md b/tools/manifest/README.md new file mode 100644 index 00000000..fb51b965 --- /dev/null +++ b/tools/manifest/README.md @@ -0,0 +1,53 @@ +# tools/manifest: signed model manifest for a commitment + +Threat model mitigations M1 and M2 (PR #54) as a detached, ed25519-signed JSON file, and a client +check that runs next to `cp_verify`. The prover is unchanged. Standard library only. + +## What a manifest binds + +| field | what | checked by `verify` against | +|---|---|---| +| `commitment.id`, `.kind` | the FlashProve commitment ID; `public-weights` or `hiding` | `cp_verify -id` (and its `id=`) | +| `upstream` | `huggingface`: repo, 40-hex revision, sha256 of every file; or `synthetic`: generator + seed | `--upstream-dir` (rehash, refuse unlisted files), else signer's word | +| `conversion` | script path + sha256, repo commit, version, params, sha256 of committed files, public-weights seed digest | `--recipe`, `--committed-dir`, `--public-weights-seed`; params/version/commit: signer's word, checkable by rederivation | +| `configuration` | sha256 of `policy.txt`, `KL.bin`, `layout.txt` | the `--config` files | +| `serving` | tokenizer files, chat template, system prompt (each a digest or `{"none": true}`), end tokens | `--tokenizer-dir`, `--chat-template`, `--system-prompt`, `--no-*`, `--end-tokens` | +| `decode_policy` | `exact` or `clipped-gap` + C, theta, cap, s, sh, sT, F | `policy.txt` and `cp_verify`'s `result-v1` | +| `verifier.build_digest` | the `build=` that `cp_verify` prints | `cp_verify`'s `result-v1` | +| `signer` | name, public key | the key the CALLER passes with `--signer-key` (never the manifest's own) | + +Canonical form: `json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True)` plus a +newline. Signature: ed25519 over `b"flashprove-manifest-v1 signature\n" + manifest bytes`, 64 raw +bytes in `.sig`. Unknown or missing keys are refused. A field declared `none` must be +acknowledged with `--no-`, so a client cannot skip a check by omission. + +## Use + +```sh +python3 tools/manifest/manifest.py keygen signer.secret signer.pub +python3 tools/manifest/manifest.py sign draft.json signer.secret manifest.json # writes manifest.json.sig +python3 tools/manifest/manifest.py verify manifest.json manifest.json.sig \ + --signer-key signer.pub --cp-verify bin/cp_verify \ + --config CONFIG --ivk IVK --request REQ --y Y --proof PROOF \ + --tokenizer-dir TOK --chat-template TEMPLATE --system-prompt PROMPT --end-tokens 2 \ + [--recipe SCRIPT] [--committed-dir DIR] [--upstream-dir DIR] [--public-weights-seed FILE] +``` + +Exit 0 and `manifest: ACCEPTED` only if every check passes and `cp_verify` says `YES`; else exit 1 +and `manifest: REFUSED: : `. `--end-tokens none` means the empty list. + +The signer key must come from a source you trust (a registry, a pinned file), not from the server. +`ed25519.py` signs in non-constant time: sign real manifests with OpenSSL, libsodium or the +`cryptography` package (same bytes, RFC 8032 is deterministic). + +## Tests + +`python3 -m pytest -q tools/manifest` (needs `bin/cp_verify`, from `verifier/build.sh`). Every +manifest field is tampered three ways; `SIGNERS_WORD` in the test lists exactly which fields a +trusted signer can change undetected. Results: `experiments/20261104-58-signed-manifest/RESULTS.md`. + +## Limits + +Detached: the manifest is not inside the configuration or the proof. It does not prove the +signer told the truth about what the client cannot rehash; rederiving the commitment ID from the +manifest's recipe (public-weights commitments only, needs an H100) is what checks that. From 3bfec22544e7d69da7865657c2139646fd176923 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:27:29 +0000 Subject: [PATCH 04/12] tools/manifest: refuse small-order ed25519 keys and R, require --end-tokens and --recipe/--no-recipe, coordinated trusted re-sign arm, PREREG amendment 2 and time correction (#58) --- .../20261104-58-signed-manifest/PREREG.md | 26 +++ .../20261104-58-signed-manifest/run.py | 74 ++++++++- research/security/DESIGN-VAST-ABLITERATED.md | 11 +- tools/manifest/README.md | 30 +++- tools/manifest/ed25519.py | 15 +- tools/manifest/manifest.py | 21 ++- tools/manifest/tamper.py | 69 ++++++++ tools/manifest/test_manifest.py | 149 +++++++++++++++++- 8 files changed, 366 insertions(+), 29 deletions(-) diff --git a/experiments/20261104-58-signed-manifest/PREREG.md b/experiments/20261104-58-signed-manifest/PREREG.md index 3b678e6e..a9078bec 100644 --- a/experiments/20261104-58-signed-manifest/PREREG.md +++ b/experiments/20261104-58-signed-manifest/PREREG.md @@ -33,3 +33,29 @@ What is new: nothing cryptographically; it is the missing glue between a FlashPr ## Amendments - 2026-11-04 23:20 UTC, after code. Added a third tamper arm not named at registration: each field changed and **re-signed by the trusted signer key itself**. It has no threshold. It sorts fields into those the client can check against its own files or cp_verify's output ("bound") and those that are only the signer's word; the test suite asserts that set exactly, so a new unbound field fails a test. The registered PASS rule (100% of tampers refused) applies to the registered arms only: original signature kept, attacker key, attacker key written into `signer.public_key`, file substitutions, signature bit flips, non-canonical JSON. Reason: a signed manifest cannot stop its own signer from lying about fields nobody can rehash; saying which fields those are is the useful output. + +- 2026-11-05 (UTC), after results and after the independent review of PR #59 (head 078d7429), + correction of times only. The registration line says "2026-11-04 22:25"; the registration commit + `7930e5b0` is dated 2026-11-04 22:16:43Z. The amendment above says "2026-11-04 23:20 UTC"; the + commit that contains it, `078d7429`, is dated 2026-11-04 22:31:55Z. Both written times were + wrong; the commit times are the record. The registration line is left as written. + +- 2026-11-05 (UTC), amendment 2, after results and after the independent review of PR #59 (head + 078d7429). The review found three gaps. Code and tests changed, so every number is re-measured + at the new code commit and the old `numbers.json` stays in git history at `078d7429`. + 1. `ed25519.verify` accepted a small-order public key: with the identity as key, R = [r]B, S = r + verified for any message. It now refuses any public key A or commitment R with [8]A or [8]R + the identity (the 8 small-order points). Verification stays cofactorless. + 2. The trusted re-sign arm counted schema refusals of nonsense values as "bound". The runner + now reports which of its refusals the schema alone causes, and adds a coordinated arm: + schema-valid alternates (commitment.kind hiding to public-weights with a made-up seed digest; + a full made-up huggingface upstream with made-up committed files; committed files under the + synthetic upstream; decode policy and serving alternates), each re-signed by the trusted key, + without and with the flag that checks it. No threshold, like the first amendment arm. + 3. `--end-tokens` and `--recipe` were opt-in: leaving either out skipped its check. + `--end-tokens` is now required, and the caller must pass `--recipe FILE` or an explicit + `--no-recipe` (which leaves the recipe digest the signer's word). The runner adds omission + rows (no threshold). + The registered threshold, the 137 registered tampers and n = 10 are unchanged. Two registered + file substitutions ("end tokens differ", "recipe") used to refuse only because the harness + passed those flags; they now run with flags the tool requires. diff --git a/experiments/20261104-58-signed-manifest/run.py b/experiments/20261104-58-signed-manifest/run.py index 23269f96..2f331236 100644 --- a/experiments/20261104-58-signed-manifest/run.py +++ b/experiments/20261104-58-signed-manifest/run.py @@ -41,11 +41,13 @@ def run(tmp, m, recipe, secret=t.TRUSTED_SECRET, overrides=None, raw=None, sig=N def main(): - out = {"rows": [], "untampered": [], "rfc8032": None, "cross_check": None} + out = {"rows": [], "untampered": [], "coordinated": [], "omission": [], "rfc8032": None, + "cross_check": None, "small_order": None} build = t.build_digest() with tempfile.TemporaryDirectory() as d: tmp = pathlib.Path(d) m, recipe = t.make_fixture(tmp, build) + base_m = json.loads(json.dumps(m)) # untampered, n = 10 for i in range(N_UNTAMPERED): @@ -107,6 +109,30 @@ def main(): code, last = run(tmp, m, recipe, overrides=over) out["rows"].append({"field": name, "kind": "file-substitution", "exit": code, "last": last}) + # amendment 2 arms (no threshold): coordinated schema-valid alternates re-signed by the + # trusted key, each without and (where one exists) with the flag that checks it; and + # leaving out each now-required flag + (tmp / "coord").mkdir() + for name, covers, bad, flags in t.coordinated(m, tmp / "coord"): + mf.check_schema(bad) + code, last = run(tmp, bad, recipe) + out["coordinated"].append({"alternate": name, "covers": covers, "flag": None, + "exit": code, "last": last}) + if flags is not None: + code, last = run(tmp, bad, recipe, overrides=flags) + out["coordinated"].append({"alternate": name, "covers": covers, + "flag": sorted(flags), "exit": code, "last": last}) + for name, over, bad in ( + ("--end-tokens left out", {"--end-tokens": None}, m), + ("--recipe left out, no --no-recipe", {"--recipe": None}, m), + ("both --recipe and --no-recipe", {"--no-recipe": True}, m), + ("--no-recipe, honest manifest", {"--recipe": None, "--no-recipe": True}, m), + ("--no-recipe, script_sha256 changed + trusted re-sign", + {"--recipe": None, "--no-recipe": True}, + dict(m, conversion=dict(m["conversion"], script_sha256="ab" * 32)))): + code, last = run(tmp, bad, recipe, overrides=over) + out["omission"].append({"case": name, "exit": code, "last": last}) + for i in range(0, 64, 7): sig = bytearray(good_sig) sig[i] ^= 0x80 @@ -127,6 +153,26 @@ def main(): sk, pk, msg, sig = map(bytes.fromhex, (sk, pk, msg, sig)) ok += ed25519.public_key(sk) == pk and ed25519.sign(sk, msg) == sig and ed25519.verify(pk, msg, sig) out["rfc8032"] = {"matched": ok, "total": len(tm.RFC8032)} + # small-order keys and R (amendment 2): forgeries that the bare cofactorless equation accepts + ident = bytes.fromhex(tm.SMALL_ORDER[0]) + forged = ed25519._compress(ed25519._mul(123456789, ed25519.G)) + (123456789).to_bytes(32, "little") + sk = bytes(range(32)) + pub = ed25519.public_key(sk) + a, _ = ed25519._secret_expand(sk) + small_a, small_r = 0, 0 + for enc in tm.SMALL_ORDER: + a_enc = bytes.fromhex(enc) + t_pt = ed25519._decompress(a_enc) + small_a += all(not ed25519.verify(a_enc, b"m", ed25519._compress( + ed25519._add(ed25519._mul(7, ed25519.G), ed25519._decompress(bytes.fromhex(t2)))) + + (7).to_bytes(32, "little")) for t2 in tm.SMALL_ORDER) + k = int.from_bytes(ed25519._sha512(a_enc + pub + b"m"), "little") % ed25519.L + small_r += not ed25519.verify(pub, b"m", a_enc + ((k * a) % ed25519.L).to_bytes(32, "little")) + assert ed25519.is_small_order(t_pt) + out["small_order"] = { + "identity_key_forgery_refused": not ed25519.verify(ident, b"any message at all", forged), + "small_order_keys_all_forgeries_refused": small_a, "small_order_r_refused": small_r, + "total": len(tm.SMALL_ORDER)} try: from cryptography.hazmat.primitives.asymmetric import ed25519 as ref import cryptography @@ -145,6 +191,15 @@ def main(): must_refuse = [r for r in rows if r["kind"] != "trusted-sig"] trusted = [r for r in rows if r["kind"] == "trusted-sig"] walls = [u["wall_s"] for u in out["untampered"]] + schema_only = set() + by_name = {t.path_name(p): p for p in t.leaves(base_m)} + for r in trusted: + if r["exit"] == 1: + path = by_name[r["field"]] + try: + mf.check_schema(t.mutated(base_m, path)) + except mf.Refusal: + schema_only.add(r["field"]) out["summary"] = { "untampered_accepted": sum(u["exit"] == 0 for u in out["untampered"]), "untampered_n": len(out["untampered"]), @@ -156,11 +211,26 @@ def main(): "must_refuse_refused": sum(r["exit"] == 1 for r in must_refuse), "trusted_resign_refused": sum(r["exit"] == 1 for r in trusted), "trusted_resign_accepted_fields": sorted(r["field"] for r in trusted if r["exit"] == 0), + "trusted_resign_refused_schema_only": sorted(schema_only), + "trusted_resign_refused_bound": sorted(r["field"] for r in trusted if r["exit"] == 1 + and r["field"] not in schema_only), + "coordinated_alternates": len({c["alternate"] for c in out["coordinated"]}), + "coordinated_accepted_without_flag": sorted(c["alternate"] for c in out["coordinated"] + if c["flag"] is None and c["exit"] == 0), + "coordinated_refused_with_flag": sorted(c["alternate"] for c in out["coordinated"] + if c["flag"] is not None and c["exit"] == 1), + "coordinated_accepted_with_flag": sorted(c["alternate"] for c in out["coordinated"] + if c["flag"] is not None and c["exit"] == 0), + "signers_word_unless_flag_fields": sorted({f for c in out["coordinated"] + if c["flag"] is None and c["exit"] == 0 + for f in c["covers"]}), + "omission": {c["case"]: c["exit"] for c in out["omission"]}, } out["host"] = {"python": platform.python_version(), "machine": platform.machine()} (HERE / "numbers.json").write_text(json.dumps(out, indent=1) + "\n") print(json.dumps(out["summary"], indent=1)) - print(json.dumps({"rfc8032": out["rfc8032"], "cross_check": out["cross_check"]})) + print(json.dumps({"rfc8032": out["rfc8032"], "cross_check": out["cross_check"], + "small_order": out["small_order"]})) if __name__ == "__main__": diff --git a/research/security/DESIGN-VAST-ABLITERATED.md b/research/security/DESIGN-VAST-ABLITERATED.md index 5bd63947..a0ce41d5 100644 --- a/research/security/DESIGN-VAST-ABLITERATED.md +++ b/research/security/DESIGN-VAST-ABLITERATED.md @@ -55,7 +55,8 @@ Sources: [README](https://github.com/amiller/dsmack), client builds, the checked tokens are those that the *committed integer model* ranks first (exact match) or that pass the clipped-gap limits, under the commitment ID that the client names. It needs no hardware trust: an untrusted GPU host produces the proof, any CPU checks it - (about 1.4 s for the small example, **measured** below; about 21 s single-thread at full size, + (about 1.4 s for the small example, **measured** in + `experiments/20261104-58-signed-manifest/RESULTS.md`; about 21 s single-thread at full size, **cited** `guide/performance.md`). The binding rests on stated assumptions (BLAKE2b as a random oracle, the codes theorem taken as given, a not-public paper argument), listed in `examples/accepted-proof/README.md`. @@ -128,8 +129,12 @@ base checkpoint (HF repo @ 40-hex revision, file sha256s) <- manifest.u Every arrow is a pinned program on pinned inputs. A manifest (this PR's `tools/manifest/`) records each input digest and the recipe; the signer signs it; anyone who reruns the chain and gets the same -ID has checked the signer's claim rather than trusted it. Today that rerun is possible only for -`-public-weights` commitments (hiding ones mix in a secret seed, `prover/common/public_weights.h`), +ID has checked the signer's claim rather than trusted it. Without that rerun, the client can +only rehash what it holds: `commitment.kind`, `upstream.*`, `conversion.committed_files` and the +public-weights seed digest are the signer's word unless it passes `--public-weights-seed`, +`--upstream-dir` and `--committed-dir` (measured in the manifest experiment's amendment arm). +Today that rerun is possible only for `-public-weights` commitments (hiding ones mix in a secret +seed, `prover/common/public_weights.h`), and the last step needs an H100 and about 330 GB of host memory for Maverick (**cited**, `examples/maverick/figures.txt`: commit 43 minutes, peak 310 GB). The Maverick example already does the last three arrows for the unmodified base (`examples/maverick/run.sh`, ID diff --git a/tools/manifest/README.md b/tools/manifest/README.md index fb51b965..49650ea3 100644 --- a/tools/manifest/README.md +++ b/tools/manifest/README.md @@ -7,11 +7,12 @@ check that runs next to `cp_verify`. The prover is unchanged. Standard library o | field | what | checked by `verify` against | |---|---|---| -| `commitment.id`, `.kind` | the FlashProve commitment ID; `public-weights` or `hiding` | `cp_verify -id` (and its `id=`) | -| `upstream` | `huggingface`: repo, 40-hex revision, sha256 of every file; or `synthetic`: generator + seed | `--upstream-dir` (rehash, refuse unlisted files), else signer's word | -| `conversion` | script path + sha256, repo commit, version, params, sha256 of committed files, public-weights seed digest | `--recipe`, `--committed-dir`, `--public-weights-seed`; params/version/commit: signer's word, checkable by rederivation | +| `commitment.id` | the FlashProve commitment ID | `cp_verify -id` (and its `id=`) | +| `commitment.kind` | `public-weights` or `hiding` | signer's word; `--public-weights-seed` checks the seed digest a `public-weights` kind needs, nothing checks a claimed `hiding` | +| `upstream` | `huggingface`: repo, 40-hex revision, sha256 of every file; or `synthetic`: generator + seed | `--upstream-dir` (rehash, refuse unlisted files), else signer's word (kind, repo, revision, files) | +| `conversion` | script path + sha256, repo commit, version, params, sha256 of committed files, public-weights seed digest | `--recipe` (or explicit `--no-recipe`: digest is then the signer's word), `--committed-dir`, `--public-weights-seed`, else signer's word; params/version/commit: signer's word, checkable by rederivation | | `configuration` | sha256 of `policy.txt`, `KL.bin`, `layout.txt` | the `--config` files | -| `serving` | tokenizer files, chat template, system prompt (each a digest or `{"none": true}`), end tokens | `--tokenizer-dir`, `--chat-template`, `--system-prompt`, `--no-*`, `--end-tokens` | +| `serving` | tokenizer files, chat template, system prompt (each a digest or `{"none": true}`), end tokens | `--tokenizer-dir`, `--chat-template`, `--system-prompt`, `--no-*`, `--end-tokens` (all required) | | `decode_policy` | `exact` or `clipped-gap` + C, theta, cap, s, sh, sT, F | `policy.txt` and `cp_verify`'s `result-v1` | | `verifier.build_digest` | the `build=` that `cp_verify` prints | `cp_verify`'s `result-v1` | | `signer` | name, public key | the key the CALLER passes with `--signer-key` (never the manifest's own) | @@ -19,7 +20,17 @@ check that runs next to `cp_verify`. The prover is unchanged. Standard library o Canonical form: `json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True)` plus a newline. Signature: ed25519 over `b"flashprove-manifest-v1 signature\n" + manifest bytes`, 64 raw bytes in `.sig`. Unknown or missing keys are refused. A field declared `none` must be -acknowledged with `--no-`, so a client cannot skip a check by omission. +acknowledged with `--no-`, `--end-tokens` is required, and the recipe needs `--recipe FILE` +or an explicit `--no-recipe`, so leaving a serving or recipe flag out refuses instead of skipping +the check. The rehash flags `--upstream-dir`, `--committed-dir` and `--public-weights-seed` stay +optional (the caller may not hold a 232 GB checkpoint): without them `commitment.kind`, +`upstream.*`, `conversion.committed_files` and `conversion.public_weights_seed_sha256` are the +signer's word, and a trusted signer can change them to other schema-valid values undetected +(measured, see Tests). + +Verification refuses a public key or an R of small order ([8]P the identity) and otherwise uses the +cofactorless equation [S]B = R + [k]A. A cofactored verifier (some libsodium modes) can disagree +with it on edge-case signatures; honest RFC 8032 signatures verify under both. ## Use @@ -30,7 +41,7 @@ python3 tools/manifest/manifest.py verify manifest.json manifest.json.sig \ --signer-key signer.pub --cp-verify bin/cp_verify \ --config CONFIG --ivk IVK --request REQ --y Y --proof PROOF \ --tokenizer-dir TOK --chat-template TEMPLATE --system-prompt PROMPT --end-tokens 2 \ - [--recipe SCRIPT] [--committed-dir DIR] [--upstream-dir DIR] [--public-weights-seed FILE] + (--recipe SCRIPT | --no-recipe) [--committed-dir DIR] [--upstream-dir DIR] [--public-weights-seed FILE] ``` Exit 0 and `manifest: ACCEPTED` only if every check passes and `cp_verify` says `YES`; else exit 1 @@ -43,8 +54,11 @@ The signer key must come from a source you trust (a registry, a pinned file), no ## Tests `python3 -m pytest -q tools/manifest` (needs `bin/cp_verify`, from `verifier/build.sh`). Every -manifest field is tampered three ways; `SIGNERS_WORD` in the test lists exactly which fields a -trusted signer can change undetected. Results: `experiments/20261104-58-signed-manifest/RESULTS.md`. +manifest field is tampered three ways. In the test, `SIGNERS_WORD_SINGLE` lists exactly which +one-field changes a trusted signer can make undetected, `SCHEMA_ONLY` which one-field refusals +came from the schema alone, and `SIGNERS_WORD_UNLESS_FLAG` which fields a schema-valid coordinated +change (`tamper.coordinated`) gets past unless the matching rehash flag is passed. Small-order keys +and R, and leaving out a required flag, are tested too. Results: `experiments/20261104-58-signed-manifest/RESULTS.md`. ## Limits diff --git a/tools/manifest/ed25519.py b/tools/manifest/ed25519.py index eeb47669..23cb7375 100644 --- a/tools/manifest/ed25519.py +++ b/tools/manifest/ed25519.py @@ -111,19 +111,28 @@ def sign(secret, message): return big_r + int.to_bytes(s, 32, "little") +def is_small_order(p): + """True iff [8]p is the identity, i.e. p is one of the 8 points of order 1, 2, 4 or 8.""" + q = _mul(8, p) + return q[0] % P == 0 and (q[1] - q[2]) % P == 0 + + def verify(public, message, signature): """Return True iff `signature` is a valid Ed25519 signature of `message` under `public`. Strict on encodings: 32-byte key, 64-byte signature, S below L, both points decodable. - Uses the cofactorless check [S]B = R + [k]A, as the RFC's reference code does. + Refuses a public key A or a commitment point R of small order ([8]A or [8]R the identity): + with the identity as A, R = [r]B, S = r verifies for any message under the bare equation. + Uses the cofactorless check [S]B = R + [k]A, as the RFC's reference code does; a cofactored + verifier ([8][S]B = [8]R + [8][k]A) can disagree with this one on edge-case signatures. """ if len(public) != 32 or len(signature) != 64: return False big_a = _decompress(public) - if big_a is None: + if big_a is None or is_small_order(big_a): return False big_r = _decompress(signature[:32]) - if big_r is None: + if big_r is None or is_small_order(big_r): return False s = int.from_bytes(signature[32:], "little") if s >= L: diff --git a/tools/manifest/manifest.py b/tools/manifest/manifest.py index fd29205c..d8be5db0 100644 --- a/tools/manifest/manifest.py +++ b/tools/manifest/manifest.py @@ -19,9 +19,11 @@ names (never one taken from the manifest), the files the caller holds and what cp_verify prints. It does not change and does not replace cp_verify: it runs it, with the manifest's ID. -What it cannot check: whether the signer told the truth about fields the caller has no files for -(upstream files, conversion inputs). Those are the signer's word unless the caller passes ---upstream-dir / --committed-dir / --recipe so they are rehashed. See README.md. +What it cannot check: whether the signer told the truth about fields the caller has no files for. +commitment.kind, upstream.* (kind, repo, revision, files, or generator and seed), +conversion.committed_files and conversion.public_weights_seed_sha256 are the signer's word unless +the caller passes --upstream-dir / --committed-dir / --public-weights-seed so they are rehashed; +conversion.script_sha256 is the signer's word under --no-recipe. See README.md. Standard library only (ed25519.py next to this file). """ @@ -286,12 +288,17 @@ def check_serving(m, args): raise Refusal("serving.%s: pass the file you used" % field) if sha256_file(path_arg) != want["sha256"]: raise Refusal("serving.%s: file digest differs" % field) - if args.end_tokens is not None and args.end_tokens != s["end_tokens"]: + if args.end_tokens is None: + raise Refusal("serving.end_tokens: pass --end-tokens (comma list, or none for the empty list)") + if args.end_tokens != s["end_tokens"]: raise Refusal("serving.end_tokens: differ from --end-tokens") def check_recipe(m, args): v = m["conversion"] + if bool(args.recipe) == bool(args.no_recipe): + raise Refusal("conversion.script_sha256: pass exactly one of --recipe FILE or --no-recipe " + "(--no-recipe leaves the recipe digest the signer's word)") if args.recipe and sha256_file(args.recipe) != v["script_sha256"]: raise Refusal("conversion.script_sha256: recipe file digest differs") if args.committed_dir: @@ -382,8 +389,10 @@ def add_verify_args(p): p.add_argument("--no-chat-template", action="store_true") p.add_argument("--system-prompt") p.add_argument("--no-system-prompt", action="store_true") - p.add_argument("--end-tokens", type=lambda t: [int(x) for x in t.split(",") if x] if t != "none" else []) - p.add_argument("--recipe") + p.add_argument("--end-tokens", help="required: comma list of end token ids, or none", type=lambda t: [int(x) for x in t.split(",") if x] if t != "none" else []) + p.add_argument("--recipe", help="the conversion script; required unless --no-recipe") + p.add_argument("--no-recipe", action="store_true", + help="do not rehash the recipe; conversion.script_sha256 is then the signer's word") p.add_argument("--committed-dir") p.add_argument("--upstream-dir") p.add_argument("--public-weights-seed") diff --git a/tools/manifest/tamper.py b/tools/manifest/tamper.py index e97d80d9..1f62142c 100644 --- a/tools/manifest/tamper.py +++ b/tools/manifest/tamper.py @@ -168,3 +168,72 @@ def flip_byte(path, offset=None): def dump(obj): return json.dumps(obj, indent=1, sort_keys=True) + + +# ---- coordinated, schema-valid alternates re-signed by the trusted key (amendment 2) ---- + +# Single-field mutations of these fields are refused by the schema (a nonsense value), which said +# nothing about whether a VALID alternate is caught. coordinated() gives each one a schema-valid +# alternate. Fields listed under "covers" are what each alternate tests. +FAKE_HF_FILES = ("config.json", "model-00001-of-00001.safetensors") + + +def coordinated(m, tmp): + """Yield (name, covers, manifest, flag_overrides) for schema-valid trusted re-signs. + + flag_overrides is what a caller passes to check the changed field against its own files + (None when no flag can check it on CPU). Every alternate passes check_schema. + """ + tmp = pathlib.Path(tmp) + seed = tmp / "client-public-weights-seed.bin" # stand-in: the seed file a caller holds + seed.write_bytes(b"client seed file stand-in\n") + up = tmp / "client-upstream" # stand-in: what a caller downloaded from the named repo + up.mkdir(exist_ok=True) + for n in FAKE_HF_FILES: + (up / n).write_bytes(b"caller's download of " + n.encode() + b"\n") + committed = tmp / "client-committed" # the honest committed set of this fixture is empty + committed.mkdir(exist_ok=True) + + a = copy.deepcopy(m) + a["commitment"]["kind"] = "public-weights" + a["conversion"]["public_weights_seed_sha256"] = "5e" * 32 + yield ("kind hiding->public-weights + made-up seed digest", + ["commitment.kind", "conversion.public_weights_seed_sha256"], a, + {"--public-weights-seed": str(seed)}) + + b = copy.deepcopy(m) + b["upstream"] = {"kind": "huggingface", "repo": "meta-llama/Llama-4-Maverick-17B-128E-Instruct", + "revision": "0" * 40, + "files": [{"path": n, "sha256": "%02x" % i * 32} + for i, n in enumerate(FAKE_HF_FILES, 1)]} + b["conversion"]["committed_files"] = [{"path": "weights.bin", "sha256": "c0" * 32}] + yield ("upstream synthetic->full fake huggingface + made-up committed_files", + ["upstream.kind", "conversion.committed_files"], b, + {"--upstream-dir": str(up), "--committed-dir": str(committed)}) + + b2 = copy.deepcopy(b) + yield ("same, caller passes --upstream-dir only", ["upstream.kind"], b2, + {"--upstream-dir": str(up)}) + + c = copy.deepcopy(m) + c["conversion"]["committed_files"] = [{"path": "weights.bin", "sha256": "c0" * 32}] + yield ("committed_files []->one made-up entry (synthetic upstream)", + ["conversion.committed_files"], c, {"--committed-dir": str(committed)}) + + for field in ("C", "theta", "cap", "sT"): + d = copy.deepcopy(m) + d["decode_policy"][field] += 1 + d["decode_policy"]["kind"] = "clipped-gap" + yield ("decode_policy.%s+1 with kind clipped-gap" % field, + ["decode_policy.%s" % field], d, None) + e = copy.deepcopy(m) + e["decode_policy"].update({"kind": "clipped-gap", "cap": 1}) + yield ("decode_policy.kind exact->clipped-gap with cap 1", ["decode_policy.kind"], e, None) + + for field in ("chat_template", "system_prompt"): + f = copy.deepcopy(m) + f["serving"][field] = {"none": True} + yield ("serving.%s digest->none" % field, ["serving.%s" % field], f, None) + g = copy.deepcopy(m) + g["serving"]["tokenizer"] = {"none": True} + yield ("serving.tokenizer files->none", ["serving.tokenizer"], g, None) diff --git a/tools/manifest/test_manifest.py b/tools/manifest/test_manifest.py index 50f25650..6e773d1f 100644 --- a/tools/manifest/test_manifest.py +++ b/tools/manifest/test_manifest.py @@ -6,8 +6,10 @@ 2. field changed, re-signed by an attacker key -> must refuse (signer key is the caller's) 3. field changed, re-signed by the TRUSTED key -> must refuse iff the field is bound to something the caller holds (a file, cp_verify's output); otherwise the field is the signer's - word, and the test asserts exactly which fields those are (SIGNERS_WORD), so a new unbound - field cannot slip in unnoticed. + word, and the test asserts exactly which fields those are (SIGNERS_WORD_SINGLE), so a new + unbound field cannot slip in unnoticed. It also asserts which refusals were the schema's + alone (SCHEMA_ONLY), and test_coordinated_trusted_resign re-signs schema-valid alternates of + those (SIGNERS_WORD_UNLESS_FLAG: signer's word unless the matching flag is passed). Plus: every bound file swapped for a one-byte-changed copy, non-canonical JSON, signature bit flip, wrong trusted key, RFC 8032 vectors, cross-check against `cryptography` if installed. """ @@ -27,10 +29,12 @@ needs_verifier = pytest.mark.skipif(not t.CP_VERIFY.is_file(), reason="bin/cp_verify is not built (run verifier/build.sh)") -# Fields that no caller-side file or cp_verify output pins, in the fixture's configuration. With -# --upstream-dir / --committed-dir / --public-weights-seed on a real model, upstream.files, -# conversion.committed_files and the seed digest move out of this set (tested below). -SIGNERS_WORD = { +# What the trusted signer can change undetected, in the fixture's configuration (synthetic upstream, +# hiding commitment, no committed files), when the caller passes the required flags +# (--end-tokens, --recipe) but none of --upstream-dir / --committed-dir / --public-weights-seed. +# +# SIGNERS_WORD_SINGLE: a one-field mutation re-signed by the trusted key is accepted. +SIGNERS_WORD_SINGLE = { "signer.name", "upstream.generator", "upstream.seed", @@ -42,6 +46,24 @@ "conversion.params.weights", "verifier.source_commit", } +# SIGNERS_WORD_UNLESS_FLAG: the one-field mutation is refused only by the schema (a nonsense value), +# but a coordinated, schema-valid alternate re-signed by the trusted key is accepted unless the +# caller passes the matching flag (tamper.coordinated): commitment.kind and the seed digest +# (--public-weights-seed), upstream.* (--upstream-dir), conversion.committed_files +# (--committed-dir). conversion.script_sha256 joins this set when the caller passes --no-recipe. +SIGNERS_WORD_UNLESS_FLAG = { + "commitment.kind", + "conversion.public_weights_seed_sha256", + "upstream.kind", + "conversion.committed_files", +} +SIGNERS_WORD = SIGNERS_WORD_SINGLE | SIGNERS_WORD_UNLESS_FLAG +# One-field mutations that check_schema alone refuses (no file or cp_verify output involved). +SCHEMA_ONLY = { + "format", "commitment.kind", "upstream.kind", "conversion.committed_files", + "conversion.public_weights_seed_sha256", "decode_policy.kind", "decode_policy.C", + "decode_policy.theta", "decode_policy.cap", "decode_policy.sT", +} @pytest.fixture(scope="module") @@ -108,6 +130,82 @@ def test_signature_s_not_reduced_is_refused(): assert not ed25519.verify(ed25519.public_key(sk), b"m", sig[:32] + s.to_bytes(32, "little")) +# The 8 points of small order (orders 1, 2, 4, 8), canonical encodings. [8]P is the identity. +SMALL_ORDER = [ + "0100000000000000000000000000000000000000000000000000000000000000", # identity, order 1 + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", # (0, -1), order 2 + "0000000000000000000000000000000000000000000000000000000000000000", # order 4 + "0000000000000000000000000000000000000000000000000000000000000080", # order 4 + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", # order 8 + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", # order 8 + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", # order 8 + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", # order 8 +] + + +def test_small_order_points_are_small_order(): + for enc in SMALL_ORDER: + pt = ed25519._decompress(bytes.fromhex(enc)) + assert pt is not None and ed25519.is_small_order(pt), enc + assert not ed25519.is_small_order(ed25519.G) + + +def test_identity_key_forgery_refused(): + # Under the bare cofactorless equation, A = identity, R = [r]B, S = r verifies for ANY message. + identity = bytes.fromhex(SMALL_ORDER[0]) + r = 123456789 + forged = ed25519._compress(ed25519._mul(r, ed25519.G)) + r.to_bytes(32, "little") + for msg in (b"any message at all", b"", mf.signed_bytes(b"{}\n")): + assert not ed25519.verify(identity, msg, forged) + + +@pytest.mark.parametrize("enc", SMALL_ORDER) +def test_small_order_public_key_refused(enc): + # For a small-order A, [k]A cycles through at most 8 points: with R = [r]B - [k]A and S = r the + # equation holds whenever we pick that R. Build such a forgery and check it is refused. + a_enc = bytes.fromhex(enc) + a_pt = ed25519._decompress(a_enc) + msg = b"forged under a small-order key" + r = 987654321 + # find R = [r]B + T for torsion T such that k(R) makes [k]A = T; try all 8 candidates + refused = 0 + for t_enc in SMALL_ORDER: + t_pt = ed25519._decompress(bytes.fromhex(t_enc)) + big_r = ed25519._compress(ed25519._add(ed25519._mul(r, ed25519.G), t_pt)) + sig = big_r + r.to_bytes(32, "little") + refused += not ed25519.verify(a_enc, msg, sig) + assert refused == len(SMALL_ORDER) + assert ed25519.is_small_order(a_pt) + + +@pytest.mark.parametrize("enc", SMALL_ORDER) +def test_small_order_r_refused(enc): + # A real key, a signature whose R is a small-order point and S = 0: [0]B = identity and + # R + [k]A is not, unless A's multiple cancels, so also check a hand-made equation-satisfying + # case: with A = [a]B, R = T (small order), [S]B = T + [k][a]B has no solution for T != 0, + # but R = identity, S = k*a mod L does. Both must be refused. + sk = bytes(range(32)) + pub = ed25519.public_key(sk) + a, _ = ed25519._secret_expand(sk) + msg = b"small-order R" + r_enc = bytes.fromhex(enc) + k = int.from_bytes(ed25519._sha512(r_enc + pub + msg), "little") % ed25519.L + sig = r_enc + ((k * a) % ed25519.L).to_bytes(32, "little") + if enc == SMALL_ORDER[0]: # identity R: the bare equation holds; must still refuse + assert ed25519._equal(ed25519._mul((k * a) % ed25519.L, ed25519.G), + ed25519._add(ed25519._decompress(r_enc), + ed25519._mul(k, ed25519._decompress(pub)))) + assert not ed25519.verify(pub, msg, sig) + + +def test_small_order_key_refused_by_manifest_load(tmp_path): + identity = bytes.fromhex(SMALL_ORDER[0]) + r = 42 + forged = ed25519._compress(ed25519._mul(r, ed25519.G)) + r.to_bytes(32, "little") + with pytest.raises(mf.Refusal, match="signature"): + mf.load(b"{}\n", forged, identity) + + # ---- the honest path ---- @@ -142,6 +240,7 @@ def test_every_field_tamper(tmp_path, build): paths = list(t.leaves(m)) assert len(paths) >= 25 accepted_by_trusted = set() + schema_only = set() for path in paths: name = t.path_name(path) bad = t.mutated(m, path) @@ -157,7 +256,43 @@ def test_every_field_tamper(tmp_path, build): # 3. the trusted signer itself signs the changed field if run(tmp_path, bad, recipe) == 0: accepted_by_trusted.add(name) - assert accepted_by_trusted == SIGNERS_WORD + else: + try: + mf.check_schema(bad) + except mf.Refusal: + schema_only.add(name) + assert accepted_by_trusted == SIGNERS_WORD_SINGLE + assert schema_only == SCHEMA_ONLY + + +@needs_verifier +def test_coordinated_trusted_resign(fx): + """Schema-valid alternates re-signed by the trusted key: accepted without the matching flag + (signer's word), refused with it. Fields with no such flag must refuse without one.""" + tmp, m, recipe = fx + unless_flag = set() + for name, covers, bad, flags in t.coordinated(m, tmp): + mf.check_schema(bad) # really schema-valid + code = run(tmp, bad, recipe) + if flags is None: + assert code == 1, name + else: + assert code == 0, name # nothing the caller holds pins it + assert run(tmp, bad, recipe, overrides=flags) == 1, name + unless_flag.update(covers) + assert unless_flag == SIGNERS_WORD_UNLESS_FLAG + + +@needs_verifier +def test_omitting_end_tokens_or_recipe_refused(fx): + tmp, m, recipe = fx + assert run(tmp, m, recipe, overrides={"--end-tokens": None}) == 1 + assert run(tmp, m, recipe, overrides={"--recipe": None}) == 1 + assert run(tmp, m, recipe, overrides={"--no-recipe": True}) == 1 # both given + assert run(tmp, m, recipe, overrides={"--recipe": None, "--no-recipe": True}) == 0 + m["conversion"]["script_sha256"] = "ab" * 32 + assert run(tmp, m, recipe, overrides={"--recipe": None, "--no-recipe": True}) == 0 # disclosed + assert run(tmp, m, recipe) == 1 @needs_verifier From 2699b0a04634222bdc5ae39e1abf709697271532 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:29:44 +0000 Subject: [PATCH 05/12] experiments: signed manifest re-measured after amendment 2 (PASS 137/137, small-order refused, 14 of 37 fields signer's word) (#58) --- .../20261104-58-signed-manifest/ENV.json | 40 ++- .../20261104-58-signed-manifest/RESULTS.md | 89 ++++-- .../20261104-58-signed-manifest/numbers.json | 280 +++++++++++++++++- 3 files changed, 370 insertions(+), 39 deletions(-) diff --git a/experiments/20261104-58-signed-manifest/ENV.json b/experiments/20261104-58-signed-manifest/ENV.json index 1f935db7..d604f1c5 100644 --- a/experiments/20261104-58-signed-manifest/ENV.json +++ b/experiments/20261104-58-signed-manifest/ENV.json @@ -1,8 +1,36 @@ { - "commits": {"base": "eb6e6ea379db87b3bd34c6377414422ed6a236dd", "measured_at": "95b126083555f90c3e2dd1fd64d179c75d01cb1e", "verifier_build_digest": "ea191b014747c2c6c5c3c4622a06fa51bbda1c2591b314678c717f351663bd66"}, - "toolchain": {"python": "3.12.3", "gxx": "g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", "pytest": "9.1.1", "cryptography": "41.0.7 (cross-check only; tools/manifest uses the standard library)"}, - "hardware": {"key": "cpu-epyc-rome-kvm-16", "cpu": "AMD EPYC-Rome KVM, 16 vCPUs", "ram_gib": 30, "os": "Ubuntu 24.04", "shared": true, "gpu": "none"}, - "seeds": [1104], - "inputs": "examples/accepted-proof (stored synthetic proof, ID 79a5a2bc...22183); fixture tokenizer/template/system prompt written by tools/manifest/tamper.py", - "commands": ["verifier/build.sh", "python3 -m pytest -q -p no:cacheprovider tools/manifest", "python3 experiments/20261104-58-signed-manifest/run.py"] + "commits": { + "base": "eb6e6ea379db87b3bd34c6377414422ed6a236dd", + "measured_at": "3bfec22544e7d69da7865657c2139646fd176923", + "verifier_build_digest": "ea191b014747c2c6c5c3c4622a06fa51bbda1c2591b314678c717f351663bd66", + "first_measurement": { + "measured_at": "95b126083555f90c3e2dd1fd64d179c75d01cb1e", + "numbers_json_at": "078d7429ede8d319d0c3c06ac525558340ace93a", + "note": "superseded by amendment 2; kept in git history" + } + }, + "toolchain": { + "python": "3.12.3", + "gxx": "g++ (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", + "pytest": "9.1.1", + "cryptography": "41.0.7 (cross-check only; tools/manifest uses the standard library)" + }, + "hardware": { + "key": "cpu-epyc-rome-kvm-16", + "cpu": "AMD EPYC-Rome KVM, 16 vCPUs", + "ram_gib": 30, + "os": "Ubuntu 24.04", + "shared": true, + "gpu": "none" + }, + "seeds": [ + 1104 + ], + "inputs": "examples/accepted-proof (stored synthetic proof, ID 79a5a2bc...22183); fixture tokenizer/template/system prompt written by tools/manifest/tamper.py", + "commands": [ + "verifier/build.sh", + "python3 -m pytest -q -p no:cacheprovider tools/manifest", + "python3 experiments/20261104-58-signed-manifest/run.py" + ], + "note": "re-measured after PREREG amendment 2 (2026-11-05), same host class, nice -n 19" } diff --git a/experiments/20261104-58-signed-manifest/RESULTS.md b/experiments/20261104-58-signed-manifest/RESULTS.md index 90246e00..ee9f537f 100644 --- a/experiments/20261104-58-signed-manifest/RESULTS.md +++ b/experiments/20261104-58-signed-manifest/RESULTS.md @@ -2,7 +2,14 @@ Issue #58. **Verdict: PASS** against the registered threshold. Measured, unreplicated, CPU only, synthetic stored example. Raw data: `numbers.json` (every row with exit code and the refusal -line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (31 passed). +line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (52 passed). + +**Re-measured after PREREG amendment 2** (2026-11-05, after the independent review of PR #59): the +code now refuses small-order ed25519 keys and R, requires `--end-tokens` and `--recipe` or +`--no-recipe`, and the runner adds a coordinated trusted re-sign arm and omission rows. All numbers +below are from the re-run at code commit `3bfec225`. The first measurement (`numbers.json` at +`078d7429`) gave the same registered counts (137/137, 10/10, 3/3, 64/64) and verify median 1.44 s +(SD 0.10, min 1.40, max 1.75, n = 10); it stays in git history. | registered metric | result | threshold | | |---|---|---|---| @@ -10,7 +17,7 @@ line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (31 passed). | untampered accepted (`cp_verify` exit 0, `YES`, manifest ACCEPTED) | **10 / 10** | 10 / 10, 0 false refusals | PASS | | RFC 8032 section 7.1 vectors 1-3 | **3 / 3** | all | PASS | | cross-check vs `cryptography` 41.0.7, 64 random messages, seed 1104 | **64 / 64** identical signatures | (not registered as a threshold) | | -| verify wall time, whole call incl. one `cp_verify` | median **1.44 s**, SD 0.10, min 1.40, max 1.75 (n = 10) | none | info | +| verify wall time, whole call incl. one `cp_verify` | median **1.41 s**, SD 0.03, min 1.38, max 1.45 (n = 10) | none | info | The 137 registered tampers: 37 manifest fields (every JSON leaf, enumerated mechanically) x 3 arms (original signature kept; re-signed by an attacker key; attacker key also written into @@ -24,20 +31,64 @@ Who refused the proof-side swaps: `cp_verify` itself (`NO: proof-refused` for pr its non-zero exit. A changed `commitment.id` re-signed by the trusted key is refused by `cp_verify` (`NO: model-statement`), which is where the manifest's ID meets the proof. -## Amendment arm: changed field re-signed by the trusted key (no threshold) +## ed25519: small-order points (amendment 2, no threshold) + +Before amendment 2, `ed25519.verify` accepted the identity as a public key with R = [r]B, S = r for +any message (found by the independent review). It now refuses a public key A or a commitment R +with [8]A or [8]R the identity. Measured: the identity-key forgery is refused; for each of the 8 +small-order encodings as A, all 8 forgeries R = [r]B + T, S = r are refused (8 / 8 keys); for each +of the 8 as R under a real key, S = k*a is refused (8 / 8; with R the identity the bare equation +holds, so this case was accepted before). RFC 8032 vectors and the `cryptography` cross-check still +match. Verification is **cofactorless** ([S]B = R + [k]A), as in the RFC's reference code. A +cofactored verifier (e.g. some libsodium modes) can disagree with it on edge-case signatures; +honest signatures verify under both. + +## Amendment arms: changed fields re-signed by the trusted key (no threshold) + +**One-field mutations (first amendment).** 37 fields: 27 refused, 10 accepted. But 10 of the 27 +refusals come from the schema alone (the mutation is a nonsense value, e.g. kind "hidingx"): +`format`, `commitment.kind`, `upstream.kind`, `conversion.committed_files`, +`conversion.public_weights_seed_sha256`, `decode_policy.{kind,C,theta,cap,sT}`. The other 17 are +refused against something the client holds (configuration files, serving files, recipe, end +tokens, the trusted key) or by `cp_verify` (`commitment.id`, `verifier.build_digest`). The 10 +accepted, signer's word only: `signer.name`, `upstream.generator`, `upstream.seed`, +`conversion.script` (path label), `conversion.repo_commit`, `conversion.version`, +`conversion.params.{M,config,weights}`, `verifier.source_commit`. + +**Coordinated, schema-valid alternates (amendment 2).** 12 alternates, each re-signed by the +trusted key, run with the required flags and without, then with, the rehash flag that checks it: + +| alternate | without the rehash flag | with it | +|---|---|---| +| `commitment.kind` hiding to public-weights + made-up seed digest | **ACCEPTED** (`cp_verify` YES) | refused with `--public-weights-seed` | +| upstream synthetic to a full made-up huggingface upstream + made-up `committed_files` | **ACCEPTED** | refused with `--upstream-dir --committed-dir` | +| same, caller passes `--upstream-dir` only | **ACCEPTED** | refused with `--upstream-dir` | +| `committed_files` empty to one made-up entry (synthetic upstream) | **ACCEPTED** | refused with `--committed-dir` | +| decode policy C, theta, cap, sT each +1 with kind clipped-gap; kind to clipped-gap with cap 1 | refused (`policy.txt`) | (no flag needed) | +| chat template, system prompt, tokenizer to `none` | refused (needs `--no-*`) | (no flag needed) | + +So the decode-policy fields are bound (to `policy.txt` and `cp_verify`'s output), but +**`commitment.kind`, `upstream.*`, `conversion.committed_files` and +`conversion.public_weights_seed_sha256` are the signer's word unless the caller passes +`--public-weights-seed`, `--upstream-dir`, `--committed-dir`.** On this fixture that makes 14 +fields the signer's word (the 10 above plus these 4). `commitment.kind` matters most: it decides +whether rederivation is possible at all, and a claimed `hiding` is checked by nothing. + +This is expected: a signature authenticates the signer, not the truth of what it signed. The +signer's word fields become checkable only by rehashing files the client holds, or by rederivation +(rerun the recipe, get the same ID), which needs `cp_commit` on an H100 and a `-public-weights` +commitment. The rehash flags are unit-tested on synthetic files, not on a real checkpoint. -37 fields: 27 refused because the client holds something to check them against, 10 accepted -because only the signer's word stands behind them in this fixture: -`signer.name`, `upstream.generator`, `upstream.seed`, `conversion.script` (path label), -`conversion.repo_commit`, `conversion.version`, `conversion.params.{M,config,weights}`, -`verifier.source_commit`. +## Omission (amendment 2, no threshold) -This is expected and is the point: a signature authenticates the signer, not the truth of what it -signed. Those fields become checkable only by rederivation (rerun the recipe, get the same ID), -which needs `cp_commit` on an H100 and a `-public-weights` commitment. For a real model, -`upstream.files`, `conversion.committed_files` and the public seed digest are rehashed when the -client passes `--upstream-dir`, `--committed-dir`, `--public-weights-seed` (unit-tested on -synthetic files, not on a real checkpoint). +Before amendment 2, leaving out `--end-tokens` or `--recipe` skipped that check (ACCEPTED), and two +registered file substitutions ("end tokens differ", "recipe") refused only because the harness +passed the flag. Now: `--end-tokens` left out: refused. `--recipe` left out without `--no-recipe`: +refused. Both given: refused. `--no-recipe` with the honest manifest: accepted, and with +`conversion.script_sha256` changed and re-signed by the trusted key: also accepted, as disclosed +(under `--no-recipe` the recipe digest is the signer's word). The rehash flags `--upstream-dir`, +`--committed-dir`, `--public-weights-seed` stay optional, so omitting them is not refused; their +fields are listed as the signer's word above. ## Not shown @@ -46,8 +97,10 @@ synthetic files, not on a real checkpoint). - Binding is client-side and detached: the manifest digest is not inside the configuration or the proof (threat model M1 says that needs a parser change, untested). - `ed25519.py` signing is not constant time; it is for tests. Verification works on public data. -- No independent replication yet. +- No independent replication yet. The amendment 2 changes were made after results and after + review; the registered threshold did not change. -Proposed claim (for the reviewer, not added to `research/results.yaml` or `loop/claims.json`): -"tools/manifest refuses 137 of 137 registered tampers and accepts 10 of 10 untampered verifies of -the stored small example; 10 of 37 fields are the signer's word only." +Proposed claim (for the reviewer; the ledger entry is `signed-manifest` in `research/results.yaml`, +nothing added to `loop/claims.json`): "tools/manifest refuses 137 of 137 registered tampers and +accepts 10 of 10 untampered verifies of the stored small example; on that fixture 14 of 37 fields +are the signer's word (10 always, 4 unless the caller passes the matching rehash flag)." diff --git a/experiments/20261104-58-signed-manifest/numbers.json b/experiments/20261104-58-signed-manifest/numbers.json index 5d8617f7..6f48b265 100644 --- a/experiments/20261104-58-signed-manifest/numbers.json +++ b/experiments/20261104-58-signed-manifest/numbers.json @@ -1050,61 +1050,247 @@ "i": 0, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4514925815165043 + "wall_s": 1.3852572813630104 }, { "i": 1, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.472631748765707 + "wall_s": 1.451579537242651 }, { "i": 2, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4811270497739315 + "wall_s": 1.4440598413348198 }, { "i": 3, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4288384728133678 + "wall_s": 1.42983827739954 }, { "i": 4, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.746917575597763 + "wall_s": 1.3830635249614716 }, { "i": 5, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.472553450614214 + "wall_s": 1.409959513694048 }, { "i": 6, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4135536700487137 + "wall_s": 1.3925894498825073 }, { "i": 7, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.419287234544754 + "wall_s": 1.4475439377129078 }, { "i": 8, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.39988424628973 + "wall_s": 1.4045466259121895 }, { "i": 9, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.410835389047861 + "wall_s": 1.417960662394762 + } + ], + "coordinated": [ + { + "alternate": "kind hiding->public-weights + made-up seed digest", + "covers": [ + "commitment.kind", + "conversion.public_weights_seed_sha256" + ], + "flag": null, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "alternate": "kind hiding->public-weights + made-up seed digest", + "covers": [ + "commitment.kind", + "conversion.public_weights_seed_sha256" + ], + "flag": [ + "--public-weights-seed" + ], + "exit": 1, + "last": "manifest: REFUSED: conversion.public_weights_seed_sha256: seed file digest differs" + }, + { + "alternate": "upstream synthetic->full fake huggingface + made-up committed_files", + "covers": [ + "upstream.kind", + "conversion.committed_files" + ], + "flag": null, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "alternate": "upstream synthetic->full fake huggingface + made-up committed_files", + "covers": [ + "upstream.kind", + "conversion.committed_files" + ], + "flag": [ + "--committed-dir", + "--upstream-dir" + ], + "exit": 1, + "last": "manifest: REFUSED: conversion.committed_files: weights.bin missing" + }, + { + "alternate": "same, caller passes --upstream-dir only", + "covers": [ + "upstream.kind" + ], + "flag": null, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "alternate": "same, caller passes --upstream-dir only", + "covers": [ + "upstream.kind" + ], + "flag": [ + "--upstream-dir" + ], + "exit": 1, + "last": "manifest: REFUSED: upstream.files: config.json digest differs" + }, + { + "alternate": "committed_files []->one made-up entry (synthetic upstream)", + "covers": [ + "conversion.committed_files" + ], + "flag": null, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "alternate": "committed_files []->one made-up entry (synthetic upstream)", + "covers": [ + "conversion.committed_files" + ], + "flag": [ + "--committed-dir" + ], + "exit": 1, + "last": "manifest: REFUSED: conversion.committed_files: weights.bin missing" + }, + { + "alternate": "decode_policy.C+1 with kind clipped-gap", + "covers": [ + "decode_policy.C" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: decode_policy.C: differs from policy.txt" + }, + { + "alternate": "decode_policy.theta+1 with kind clipped-gap", + "covers": [ + "decode_policy.theta" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: decode_policy.theta: differs from policy.txt" + }, + { + "alternate": "decode_policy.cap+1 with kind clipped-gap", + "covers": [ + "decode_policy.cap" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: decode_policy.cap: differs from policy.txt" + }, + { + "alternate": "decode_policy.sT+1 with kind clipped-gap", + "covers": [ + "decode_policy.sT" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: decode_policy.sT: differs from policy.txt" + }, + { + "alternate": "decode_policy.kind exact->clipped-gap with cap 1", + "covers": [ + "decode_policy.kind" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: decode_policy.cap: differs from policy.txt" + }, + { + "alternate": "serving.chat_template digest->none", + "covers": [ + "serving.chat_template" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: serving.chat_template: manifest says none; pass --no-chat-template and no file" + }, + { + "alternate": "serving.system_prompt digest->none", + "covers": [ + "serving.system_prompt" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: serving.system_prompt: manifest says none; pass --no-system-prompt and no file" + }, + { + "alternate": "serving.tokenizer files->none", + "covers": [ + "serving.tokenizer" + ], + "flag": null, + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer: manifest says none; pass --no-tokenizer to accept" + } + ], + "omission": [ + { + "case": "--end-tokens left out", + "exit": 1, + "last": "manifest: REFUSED: serving.end_tokens: pass --end-tokens (comma list, or none for the empty list)" + }, + { + "case": "--recipe left out, no --no-recipe", + "exit": 1, + "last": "manifest: REFUSED: conversion.script_sha256: pass exactly one of --recipe FILE or --no-recipe (--no-recipe leaves the recipe digest the signer's word)" + }, + { + "case": "both --recipe and --no-recipe", + "exit": 1, + "last": "manifest: REFUSED: conversion.script_sha256: pass exactly one of --recipe FILE or --no-recipe (--no-recipe leaves the recipe digest the signer's word)" + }, + { + "case": "--no-recipe, honest manifest", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "case": "--no-recipe, script_sha256 changed + trusted re-sign", + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" } ], "rfc8032": { @@ -1116,14 +1302,20 @@ "total": 64, "cryptography": "41.0.7" }, + "small_order": { + "identity_key_forgery_refused": true, + "small_order_keys_all_forgeries_refused": 8, + "small_order_r_refused": 8, + "total": 8 + }, "summary": { "untampered_accepted": 10, "untampered_n": 10, "verify_wall_s": { - "median": 1.440165527164936, - "sd": 0.10167006071503702, - "min": 1.39988424628973, - "max": 1.746917575597763, + "median": 1.413960088044405, + "sd": 0.02578796718355745, + "min": 1.3830635249614716, + "max": 1.451579537242651, "n": 10, "note": "whole verify call: manifest checks plus one cp_verify process on the stored small example" }, @@ -1142,7 +1334,65 @@ "upstream.generator", "upstream.seed", "verifier.source_commit" - ] + ], + "trusted_resign_refused_schema_only": [ + "commitment.kind", + "conversion.committed_files", + "conversion.public_weights_seed_sha256", + "decode_policy.C", + "decode_policy.cap", + "decode_policy.kind", + "decode_policy.sT", + "decode_policy.theta", + "format", + "upstream.kind" + ], + "trusted_resign_refused_bound": [ + "commitment.id", + "configuration.KL.bin", + "configuration.layout.txt", + "configuration.policy.txt", + "conversion.script_sha256", + "decode_policy.F", + "decode_policy.s", + "decode_policy.sh", + "serving.chat_template.sha256", + "serving.end_tokens", + "serving.system_prompt.sha256", + "serving.tokenizer.files.0.path", + "serving.tokenizer.files.0.sha256", + "serving.tokenizer.files.1.path", + "serving.tokenizer.files.1.sha256", + "signer.public_key", + "verifier.build_digest" + ], + "coordinated_alternates": 12, + "coordinated_accepted_without_flag": [ + "committed_files []->one made-up entry (synthetic upstream)", + "kind hiding->public-weights + made-up seed digest", + "same, caller passes --upstream-dir only", + "upstream synthetic->full fake huggingface + made-up committed_files" + ], + "coordinated_refused_with_flag": [ + "committed_files []->one made-up entry (synthetic upstream)", + "kind hiding->public-weights + made-up seed digest", + "same, caller passes --upstream-dir only", + "upstream synthetic->full fake huggingface + made-up committed_files" + ], + "coordinated_accepted_with_flag": [], + "signers_word_unless_flag_fields": [ + "commitment.kind", + "conversion.committed_files", + "conversion.public_weights_seed_sha256", + "upstream.kind" + ], + "omission": { + "--end-tokens left out": 1, + "--recipe left out, no --no-recipe": 1, + "both --recipe and --no-recipe": 1, + "--no-recipe, honest manifest": 0, + "--no-recipe, script_sha256 changed + trusted re-sign": 0 + } }, "host": { "python": "3.12.3", From aeb2364688c6c9e3a2879e1ea83c67245efb0d5d Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:30:25 +0000 Subject: [PATCH 06/12] experiments: fix amendment dates (2026-11-04 UTC, not 11-05) in signed manifest PREREG/RESULTS/ENV (#58) --- .../20261104-58-signed-manifest/ENV.json | 2 +- .../20261104-58-signed-manifest/PREREG.md | 20 ++++++++++--------- .../20261104-58-signed-manifest/RESULTS.md | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/experiments/20261104-58-signed-manifest/ENV.json b/experiments/20261104-58-signed-manifest/ENV.json index d604f1c5..49041f48 100644 --- a/experiments/20261104-58-signed-manifest/ENV.json +++ b/experiments/20261104-58-signed-manifest/ENV.json @@ -32,5 +32,5 @@ "python3 -m pytest -q -p no:cacheprovider tools/manifest", "python3 experiments/20261104-58-signed-manifest/run.py" ], - "note": "re-measured after PREREG amendment 2 (2026-11-05), same host class, nice -n 19" + "note": "re-measured after PREREG amendment 2 (2026-11-04 UTC), same host class, nice -n 19" } diff --git a/experiments/20261104-58-signed-manifest/PREREG.md b/experiments/20261104-58-signed-manifest/PREREG.md index a9078bec..822e97cc 100644 --- a/experiments/20261104-58-signed-manifest/PREREG.md +++ b/experiments/20261104-58-signed-manifest/PREREG.md @@ -34,15 +34,17 @@ What is new: nothing cryptographically; it is the missing glue between a FlashPr - 2026-11-04 23:20 UTC, after code. Added a third tamper arm not named at registration: each field changed and **re-signed by the trusted signer key itself**. It has no threshold. It sorts fields into those the client can check against its own files or cp_verify's output ("bound") and those that are only the signer's word; the test suite asserts that set exactly, so a new unbound field fails a test. The registered PASS rule (100% of tampers refused) applies to the registered arms only: original signature kept, attacker key, attacker key written into `signer.public_key`, file substitutions, signature bit flips, non-canonical JSON. Reason: a signed manifest cannot stop its own signer from lying about fields nobody can rehash; saying which fields those are is the useful output. -- 2026-11-05 (UTC), after results and after the independent review of PR #59 (head 078d7429), - correction of times only. The registration line says "2026-11-04 22:25"; the registration commit - `7930e5b0` is dated 2026-11-04 22:16:43Z. The amendment above says "2026-11-04 23:20 UTC"; the - commit that contains it, `078d7429`, is dated 2026-11-04 22:31:55Z. Both written times were - wrong; the commit times are the record. The registration line is left as written. - -- 2026-11-05 (UTC), amendment 2, after results and after the independent review of PR #59 (head - 078d7429). The review found three gaps. Code and tests changed, so every number is re-measured - at the new code commit and the old `numbers.json` stays in git history at `078d7429`. +- 2026-11-04, first committed in `3bfec225` at 23:27:29Z (that commit wrote "2026-11-05" by mistake; + corrected in the next commit). After results and after the independent review of PR #59 (head + 078d7429). Correction of times only. The registration line says "2026-11-04 22:25"; the + registration commit `7930e5b0` is dated 2026-11-04 22:16:43Z. The amendment above says "2026-11-04 + 23:20 UTC"; the commit that contains it, `078d7429`, is dated 2026-11-04 22:31:55Z. Both written + times were wrong; the commit times are the record. The registration line is left as written. + +- 2026-11-04, amendment 2, first committed in `3bfec225` at 23:27:29Z (same date slip, same fix), + before the re-run; after results and after the independent review of PR #59 (head 078d7429). The + review found three gaps. Code and tests changed, so every number is re-measured at the new code + commit and the old `numbers.json` stays in git history at `078d7429`. 1. `ed25519.verify` accepted a small-order public key: with the identity as key, R = [r]B, S = r verified for any message. It now refuses any public key A or commitment R with [8]A or [8]R the identity (the 8 small-order points). Verification stays cofactorless. diff --git a/experiments/20261104-58-signed-manifest/RESULTS.md b/experiments/20261104-58-signed-manifest/RESULTS.md index ee9f537f..c7ec4ad6 100644 --- a/experiments/20261104-58-signed-manifest/RESULTS.md +++ b/experiments/20261104-58-signed-manifest/RESULTS.md @@ -4,7 +4,7 @@ Issue #58. **Verdict: PASS** against the registered threshold. Measured, unrepli synthetic stored example. Raw data: `numbers.json` (every row with exit code and the refusal line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (52 passed). -**Re-measured after PREREG amendment 2** (2026-11-05, after the independent review of PR #59): the +**Re-measured after PREREG amendment 2** (2026-11-04 UTC, after the independent review of PR #59): the code now refuses small-order ed25519 keys and R, requires `--end-tokens` and `--recipe` or `--no-recipe`, and the runner adds a coordinated trusted re-sign arm and omission rows. All numbers below are from the re-run at code commit `3bfec225`. The first measurement (`numbers.json` at From 949667653bddd8bd92283b41b2755d1db6452372 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:31:14 +0000 Subject: [PATCH 07/12] research: results.yaml entry for the signed manifest (measured, unreplicated), regenerate README block and CLAIMS.md (#58) --- CLAIMS.md | 1 + README.md | 3 ++- research/results.yaml | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CLAIMS.md b/CLAIMS.md index 97068b24..df66cc32 100644 --- a/CLAIMS.md +++ b/CLAIMS.md @@ -29,4 +29,5 @@ tracked file, which both scripts resolve (they check that it exists, not what it | `steering-real-proofs` | Runtime steering under real cp_prove / cp_verify, small synthetic model (PR #50): exact match: 0 of 57 changed steered replies accepted (all refused by the prover), 13 of 13 unchanged accepted; shipped clipped-gap: 5 of 54 changed replies accepted `WITHIN LIMITS`, all at dose 0.05, passing on the theta mean bound (above-clip 0); Python score agreed with the real decision on 140 of 140 | measured, unreplicated (strict PASS, lenient half holds) | unreplicated | [experiments/20261104-36-steering-real-proofs/RESULTS.md](experiments/20261104-36-steering-real-proofs/RESULTS.md) | #50 | | `qwen-deltanet-m1` | Qwen Gated DeltaNet head at real shape, M1 (PR #47): FAIL (NO-GO for M2 as registered): 10 of 10 honest proofs accepted (prove median 51.4 s, verify 11.6 s, 50.4 MB) and 40 of 40 hostile proofs rejected, but under heavy-tailed values delta reaches 422,167 > 2^18 at F = 12 (0 of 5 seeds fit) | negative, unreplicated | unreplicated | [research/operators/qwen_deltanet/README.md](research/operators/qwen_deltanet/README.md), [experiments/20261104-11-qwen-deltanet-m1/RESULTS.md](experiments/20261104-11-qwen-deltanet-m1/RESULTS.md) | #47 | | `swap-at-verifier` | #45 attack classes through the honest cp_prove + cp_verify, small synthetic model, one H100 (PR #51): honest-prover path: Python score matched all 256 decisions (89 cp_verify accepts, 167 honest-prover refusals, 0 cp_verify rejections); one swap then greedy continuation proved and accepted 15/16 (fp2nd, 95% CI 0.70-1.00) and 13/16 (rand, 0.54-0.96) under the shipped limits; 4 swaps and random replies 0/16; cp_verify's limit check was not exercised on a forged proof | measured, unreplicated | unreplicated | [experiments/20261104-38-swap-at-verifier/RESULTS.md](experiments/20261104-38-swap-at-verifier/RESULTS.md) | #51 | +| `signed-manifest` | Signed model manifest checked next to cp_verify, stored small example (PR #59): 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; a trusted signer's re-sign is accepted for 10 of 37 fields as a one-field change and for 4 more (commitment.kind, upstream, committed files, seed digest) as a schema-valid change unless the caller passes the rehash flag; small-order ed25519 keys and R refused | measured, unreplicated | unreplicated | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | #59 | | `open-studies` | Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44): none yet | in progress | n/a | [docs/research-roadmap.md](docs/research-roadmap.md) | - | diff --git a/README.md b/README.md index 4dacf429..c0d2812e 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ Every number below comes from a file on `main` (the one exception, a reviewer fi | Runtime steering under real cp_prove / cp_verify, small synthetic model (PR #50) | measured, unreplicated (strict PASS, lenient half holds) | exact match: 0 of 57 changed steered replies accepted (all refused by the prover), 13 of 13 unchanged accepted; shipped clipped-gap: 5 of 54 changed replies accepted `WITHIN LIMITS`, all at dose 0.05, passing on the theta mean bound (above-clip 0); Python score agreed with the real decision on 140 of 140 | one neuron, synthetic 8-layer model, 5 prompt seeds x 7 doses x 2 reply lengths x 2 policies, one H100 of an unverified host; honest prover only, no cheating prover tested; S and G replies are not paired (different scales) | [experiments/20261104-36-steering-real-proofs/RESULTS.md](experiments/20261104-36-steering-real-proofs/RESULTS.md) | | Qwen Gated DeltaNet head at real shape, M1 (PR #47) | negative, unreplicated | FAIL (NO-GO for M2 as registered): 10 of 10 honest proofs accepted (prove median 51.4 s, verify 11.6 s, 50.4 MB) and 40 of 40 hostile proofs rejected, but under heavy-tailed values delta reaches 422,167 > 2^18 at F = 12 (0 of 5 seeds fit) | one head, 128 x 128 state, T = 4, F = 12, synthetic inputs, research test security settings (target 10 bits), CPU, shared loaded VM; range study 1,024 steps x 4 distributions x 5 seeds, integer reference only | [research/operators/qwen_deltanet/](research/operators/qwen_deltanet/README.md) | | #45 attack classes through the honest cp_prove + cp_verify, small synthetic model, one H100 (PR #51) | measured, unreplicated | honest-prover path: Python score matched all 256 decisions (89 cp_verify accepts, 167 honest-prover refusals, 0 cp_verify rejections); one swap then greedy continuation proved and accepted 15/16 (fp2nd, 95% CI 0.70-1.00) and 13/16 (rand, 0.54-0.96) under the shipped limits; 4 swaps and random replies 0/16; cp_verify's limit check was not exercised on a forged proof | target_small_f2_gap, shipped limits and #45's fit point (both cap 1), one fresh commitment each; #45's seed 2026 held-out replies, 16 per class, one draw; synthetic weights; soundness flag vacuous for a cheating prover | [experiments/20261104-38-swap-at-verifier/RESULTS.md](experiments/20261104-38-swap-at-verifier/RESULTS.md) | +| Signed model manifest checked next to cp_verify, stored small example (PR #59) | measured, unreplicated | 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; a trusted signer's re-sign is accepted for 10 of 37 fields as a one-field change and for 4 more (commitment.kind, upstream, committed files, seed digest) as a schema-valid change unless the caller passes the rehash flag; small-order ed25519 keys and R refused | stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 2 | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | | Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44) | in progress | none yet | open PRs, issues and lanes, not merged | [docs/research-roadmap.md](docs/research-roadmap.md) | -_Last regenerated from `research/results.yaml` on 2026-11-04 (UTC), against main `c46d21ee`._ +_Last regenerated from `research/results.yaml` on 2026-11-04 (UTC), against main `eb6e6ea3`._ What this table does not show: no full-size (Maverick) run, no real model weights on GPU, no serving, no TEE, no production use, and no comparison with other verifiable-inference systems. The GPU rows are upstream's small example on made-up weights. diff --git a/research/results.yaml b/research/results.yaml index 07615de3..a552180f 100644 --- a/research/results.yaml +++ b/research/results.yaml @@ -359,6 +359,21 @@ results: - {name: rand1_redecode_accepted_shipped, series: swap-at-verifier-accept, arm: "rand-1-redecode, shipped", stat: count, value: 13, total: 16, unit: count, n: 16, spread: null, hardware: gpu-h100-rental-c, date: 2026-11-04, commit: 124c558bbb3598662ab1ad21da9d85f9de670f2f, source: experiments/20261104-38-swap-at-verifier/numbers.json, note: "95% Clopper-Pearson [0.54, 0.96] (intervals.json); one seed, one draw"} - {name: rand1_redecode_accepted_fit, series: swap-at-verifier-accept, arm: "rand-1-redecode, fit", stat: count, value: 13, total: 16, unit: count, n: 16, spread: null, hardware: gpu-h100-rental-c, date: 2026-11-04, commit: 124c558bbb3598662ab1ad21da9d85f9de670f2f, source: experiments/20261104-38-swap-at-verifier/numbers.json, note: "95% Clopper-Pearson [0.54, 0.96] (intervals.json); one seed, one draw"} + - id: signed-manifest + title: "Signed model manifest checked next to cp_verify, stored small example (PR #59)" + status: measured + replication: unreplicated + pr: 59 + summary: "137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; a trusted signer's re-sign is accepted for 10 of 37 fields as a one-field change and for 4 more (commitment.kind, upstream, committed files, seed digest) as a schema-valid change unless the caller passes the rehash flag; small-order ed25519 keys and R refused" + conditions: "stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 2" + evidence: [experiments/20261104-58-signed-manifest/RESULTS.md] + metrics: + - {name: registered_tampers_refused, stat: count, value: 137, total: 137, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "enumerated tamper set, each deterministic; registered threshold 100%"} + - {name: untampered_accepted, stat: count, value: 10, total: 10, unit: count, n: 10, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "repeats of one deterministic check, not independent trials"} + - {name: trusted_resign_accepted_fields, stat: count, value: 10, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "one-field change re-signed by the trusted key, accepted; no threshold"} + - {name: trusted_resign_accepted_unless_flag_fields, stat: count, value: 4, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "schema-valid coordinated change re-signed by the trusted key, accepted without the matching rehash flag, refused with it; no threshold"} + - {name: verify_wall, stat: median, value: 1.414, unit: s, n: 10, spread: {sd: 0.026, min: 1.383, max: 1.452}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "whole verify call incl. one cp_verify on the stored small example; shared VM; no threshold"} + - id: open-studies title: "Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44)" status: in progress From c0d161250b04b602a358a72579db1313d3a9b0f6 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:23:29 +0000 Subject: [PATCH 08/12] tools/manifest: commitment.kind is the signer's word even with --public-weights-seed (11 always + 3 unless a flag), matching-seed coordinated row, refuse symlinks in rehashed dirs, one encoding for none, refuse deep nesting, PREREG amendment 3 (#58) --- .../20261104-58-signed-manifest/PREREG.md | 23 ++++ .../20261104-58-signed-manifest/run.py | 50 +++++++- research/security/DESIGN-VAST-ABLITERATED.md | 10 +- tools/manifest/README.md | 21 ++-- tools/manifest/manifest.py | 46 +++++-- tools/manifest/tamper.py | 35 ++++-- tools/manifest/test_manifest.py | 113 ++++++++++++++++-- 7 files changed, 251 insertions(+), 47 deletions(-) diff --git a/experiments/20261104-58-signed-manifest/PREREG.md b/experiments/20261104-58-signed-manifest/PREREG.md index 822e97cc..56905082 100644 --- a/experiments/20261104-58-signed-manifest/PREREG.md +++ b/experiments/20261104-58-signed-manifest/PREREG.md @@ -61,3 +61,26 @@ What is new: nothing cryptographically; it is the missing glue between a FlashPr The registered threshold, the 137 registered tampers and n = 10 are unchanged. Two registered file substitutions ("end tokens differ", "recipe") used to refuse only because the harness passed those flags; they now run with flags the tool requires. + +- 2026-11-05 00:23 UTC, amendment 3, before the re-run; after results and after the second + independent review of PR #59 (head 94966765). Wording, tests and three hardening fixes. The + registered threshold, the 137 registered tampers and n = 10 are unchanged. + 1. `commitment.kind` was listed as the signer's word "unless --public-weights-seed is passed". + That was wrong: the flag rehashes a seed file against `conversion.public_weights_seed_sha256` + and nothing compares that with what the commitment is. The review re-signed the hiding example + as `public-weights` with the digest of a seed file the signer also ships, passed the flag with + that file, and got ACCEPTED (`cp_verify` YES). The amendment 2 alternate used a made-up digest + that did not match the caller's file, so it showed that a mismatched digest is refused, not + that the kind is checked. `commitment.kind` moves to the always-signer's-word set: 11 always + (the 10 one-field fields plus `commitment.kind`) and 3 unless a flag + (`conversion.public_weights_seed_sha256`, `upstream.*`, `conversion.committed_files`). The + total stays 14 of 37. The coordinated arm adds a matching-seed alternate whose expected result + with the flag is ACCEPTED, so the test pins the signer's-word status; only rederivation checks + the kind. + 2. Hardening, each with a test and a runner row (no threshold): a value meaning "none" now has one + encoding (`{"none": true}`; `{"none": 1}` and `{"none": 1.0}` used to compare equal to it); a + rehashed directory with any symlink in it is refused (a symlinked subdirectory was not walked, + so an extra tokenizer file in it was accepted); a trusted-signed manifest nested too deeply to + parse is refused instead of raising an uncaught `RecursionError`. + Code and tests changed, so every number is re-measured at the new code commit; the previous + `numbers.json` stays in git history at `94966765`. diff --git a/experiments/20261104-58-signed-manifest/run.py b/experiments/20261104-58-signed-manifest/run.py index 2f331236..0b999198 100644 --- a/experiments/20261104-58-signed-manifest/run.py +++ b/experiments/20261104-58-signed-manifest/run.py @@ -113,7 +113,8 @@ def main(): # trusted key, each without and (where one exists) with the flag that checks it; and # leaving out each now-required flag (tmp / "coord").mkdir() - for name, covers, bad, flags in t.coordinated(m, tmp / "coord"): + # (amendment 3 adds a matching-seed alternate whose expected exit with the flag is 0) + for name, covers, bad, flags, with_flag in t.coordinated(m, tmp / "coord"): mf.check_schema(bad) code, last = run(tmp, bad, recipe) out["coordinated"].append({"alternate": name, "covers": covers, "flag": None, @@ -121,7 +122,8 @@ def main(): if flags is not None: code, last = run(tmp, bad, recipe, overrides=flags) out["coordinated"].append({"alternate": name, "covers": covers, - "flag": sorted(flags), "exit": code, "last": last}) + "flag": sorted(flags), "exit": code, "last": last, + "expected_exit": with_flag}) for name, over, bad in ( ("--end-tokens left out", {"--end-tokens": None}, m), ("--recipe left out, no --no-recipe", {"--recipe": None}, m), @@ -133,6 +135,40 @@ def main(): code, last = run(tmp, bad, recipe, overrides=over) out["omission"].append({"case": name, "exit": code, "last": last}) + # amendment 3 hardening rows (no threshold): symlinked dir with an extra tokenizer file, + # deeply nested trusted-signed manifest, {"none": 1} / {"none": 1.0} re-signed by the + # trusted key + hard = [] + hidden = tmp / "h-hidden" + hidden.mkdir() + (hidden / "added_tokens.json").write_text("{}\n") + symtok = tmp / "h-symtok" + shutil.copytree(tmp / "tokenizer", symtok) + (symtok / "sub").symlink_to(hidden, target_is_directory=True) + code, last = run(tmp, m, recipe, overrides={"--tokenizer-dir": str(symtok)}) + hard.append({"case": "extra tokenizer file in a symlinked subdir", "exit": code, + "last": last}) + deep = b"[" * 100000 + b"]" * 100000 + b"\n" + code, last = run(tmp, m, recipe, raw=deep, + sig=ed25519.sign(t.TRUSTED_SECRET, mf.signed_bytes(deep))) + hard.append({"case": "trusted-signed manifest nested 100000 deep", "exit": code, + "last": last}) + for v in (1, 1.0): + for field, over in (("system_prompt", {"--system-prompt": None, + "--no-system-prompt": True}), + ("chat_template", {"--chat-template": None, + "--no-chat-template": True})): + nm = json.loads(json.dumps(m)) + nm["serving"][field] = {"none": v} + data = json.dumps(nm, sort_keys=True, separators=(",", ":"), + ensure_ascii=True).encode() + b"\n" + code, last = run(tmp, m, recipe, overrides=over, raw=data, + sig=ed25519.sign(t.TRUSTED_SECRET, mf.signed_bytes(data))) + hard.append({"case": "serving.%s {\"none\": %r} trusted re-sign" % (field, v), + "exit": code, "last": last}) + out["hardening"] = {h["case"]: h["exit"] for h in hard} + out["hardening_rows"] = hard + for i in range(0, 64, 7): sig = bytearray(good_sig) sig[i] ^= 0x80 @@ -222,9 +258,17 @@ def main(): "coordinated_accepted_with_flag": sorted(c["alternate"] for c in out["coordinated"] if c["flag"] is not None and c["exit"] == 0), "signers_word_unless_flag_fields": sorted({f for c in out["coordinated"] - if c["flag"] is None and c["exit"] == 0 + if c["flag"] is not None and c["exit"] == 1 for f in c["covers"]}), + # amendment 3: accepted even with the rehash flag (the flag checks a digest, not the kind) + "signers_word_even_with_flag_fields": sorted({f for c in out["coordinated"] + if c["flag"] is not None and c["exit"] == 0 + for f in c["covers"]}), + "coordinated_with_flag_unexpected": sorted(c["alternate"] for c in out["coordinated"] + if c["flag"] is not None + and c["exit"] != c["expected_exit"]), "omission": {c["case"]: c["exit"] for c in out["omission"]}, + "hardening": out["hardening"], } out["host"] = {"python": platform.python_version(), "machine": platform.machine()} (HERE / "numbers.json").write_text(json.dumps(out, indent=1) + "\n") diff --git a/research/security/DESIGN-VAST-ABLITERATED.md b/research/security/DESIGN-VAST-ABLITERATED.md index a0ce41d5..50c53b73 100644 --- a/research/security/DESIGN-VAST-ABLITERATED.md +++ b/research/security/DESIGN-VAST-ABLITERATED.md @@ -78,7 +78,7 @@ hosts are experimenting with NVIDIA Confidential Computing. Complete support for not yet available" (). The compliance page lists SOC 2, HIPAA on Secure Cloud and Docker isolation, and does not mention confidential -computing or hardware attestation. A third-party comparison page says the same +computing or hardware attestation. One comparison page from another GPU provider says the same (, a competitor's page, weak source). We found no vast offer filter, API field or document for H100 CC mode, TDX or SEV-SNP. **Conclusion (inferred from those sources):** as of 2026-11-04 a vast renter cannot select an attested @@ -130,9 +130,11 @@ base checkpoint (HF repo @ 40-hex revision, file sha256s) <- manifest.u Every arrow is a pinned program on pinned inputs. A manifest (this PR's `tools/manifest/`) records each input digest and the recipe; the signer signs it; anyone who reruns the chain and gets the same ID has checked the signer's claim rather than trusted it. Without that rerun, the client can -only rehash what it holds: `commitment.kind`, `upstream.*`, `conversion.committed_files` and the -public-weights seed digest are the signer's word unless it passes `--public-weights-seed`, -`--upstream-dir` and `--committed-dir` (measured in the manifest experiment's amendment arm). +only rehash what it holds: `upstream.*`, `conversion.committed_files` and the public-weights seed +digest are the signer's word unless it passes `--upstream-dir`, `--committed-dir` and +`--public-weights-seed`, and `commitment.kind` is the signer's word even then, because the seed +flag checks a file against the digest, not the kind against the commitment (measured in the +manifest experiment's amendment arms). Today that rerun is possible only for `-public-weights` commitments (hiding ones mix in a secret seed, `prover/common/public_weights.h`), and the last step needs an H100 and about 330 GB of host memory for Maverick (**cited**, diff --git a/tools/manifest/README.md b/tools/manifest/README.md index 49650ea3..2c444b60 100644 --- a/tools/manifest/README.md +++ b/tools/manifest/README.md @@ -8,7 +8,7 @@ check that runs next to `cp_verify`. The prover is unchanged. Standard library o | field | what | checked by `verify` against | |---|---|---| | `commitment.id` | the FlashProve commitment ID | `cp_verify -id` (and its `id=`) | -| `commitment.kind` | `public-weights` or `hiding` | signer's word; `--public-weights-seed` checks the seed digest a `public-weights` kind needs, nothing checks a claimed `hiding` | +| `commitment.kind` | `public-weights` or `hiding` | signer's word, even with `--public-weights-seed`: that flag checks the seed file against the digest, not the kind against the commitment, so a false `public-weights` with a seed file the signer ships is accepted, and nothing checks a claimed `hiding`; only rederivation checks the kind | | `upstream` | `huggingface`: repo, 40-hex revision, sha256 of every file; or `synthetic`: generator + seed | `--upstream-dir` (rehash, refuse unlisted files), else signer's word (kind, repo, revision, files) | | `conversion` | script path + sha256, repo commit, version, params, sha256 of committed files, public-weights seed digest | `--recipe` (or explicit `--no-recipe`: digest is then the signer's word), `--committed-dir`, `--public-weights-seed`, else signer's word; params/version/commit: signer's word, checkable by rederivation | | `configuration` | sha256 of `policy.txt`, `KL.bin`, `layout.txt` | the `--config` files | @@ -23,10 +23,14 @@ bytes in `.sig`. Unknown or missing keys are refused. A field declared acknowledged with `--no-`, `--end-tokens` is required, and the recipe needs `--recipe FILE` or an explicit `--no-recipe`, so leaving a serving or recipe flag out refuses instead of skipping the check. The rehash flags `--upstream-dir`, `--committed-dir` and `--public-weights-seed` stay -optional (the caller may not hold a 232 GB checkpoint): without them `commitment.kind`, -`upstream.*`, `conversion.committed_files` and `conversion.public_weights_seed_sha256` are the -signer's word, and a trusted signer can change them to other schema-valid values undetected -(measured, see Tests). +optional (the caller may not hold a 232 GB checkpoint): without them `upstream.*`, +`conversion.committed_files` and `conversion.public_weights_seed_sha256` are the signer's word, and +a trusted signer can change them to other schema-valid values undetected. `commitment.kind` is the +signer's word with or without the flags (measured, see Tests). + +A rehashed directory (`--tokenizer-dir`, `--upstream-dir`, `--committed-dir`) must hold only plain +files and directories: any symlink in it is refused. A field meaning none has the one encoding +`{"none": true}`. A manifest nested too deeply to parse is refused. Verification refuses a public key or an R of small order ([8]P the identity) and otherwise uses the cofactorless equation [S]B = R + [k]A. A cofactored verifier (some libsodium modes) can disagree @@ -57,8 +61,11 @@ The signer key must come from a source you trust (a registry, a pinned file), no manifest field is tampered three ways. In the test, `SIGNERS_WORD_SINGLE` lists exactly which one-field changes a trusted signer can make undetected, `SCHEMA_ONLY` which one-field refusals came from the schema alone, and `SIGNERS_WORD_UNLESS_FLAG` which fields a schema-valid coordinated -change (`tamper.coordinated`) gets past unless the matching rehash flag is passed. Small-order keys -and R, and leaving out a required flag, are tested too. Results: `experiments/20261104-58-signed-manifest/RESULTS.md`. +change (`tamper.coordinated`) gets past unless the matching rehash flag is passed (3 fields), and +`SIGNERS_WORD_COORDINATED_ALWAYS` the one it gets past even with the flag (`commitment.kind`, with +a seed file the signer ships): 11 fields always the signer's word plus 3 unless a flag, 14 of 37. +Small-order keys and R, leaving out a required flag, symlinks in a rehashed directory, a deeply +nested manifest and `{"none": 1}` are tested too. Results: `experiments/20261104-58-signed-manifest/RESULTS.md`. ## Limits diff --git a/tools/manifest/manifest.py b/tools/manifest/manifest.py index d8be5db0..3d5455bb 100644 --- a/tools/manifest/manifest.py +++ b/tools/manifest/manifest.py @@ -20,10 +20,13 @@ It does not change and does not replace cp_verify: it runs it, with the manifest's ID. What it cannot check: whether the signer told the truth about fields the caller has no files for. -commitment.kind, upstream.* (kind, repo, revision, files, or generator and seed), -conversion.committed_files and conversion.public_weights_seed_sha256 are the signer's word unless -the caller passes --upstream-dir / --committed-dir / --public-weights-seed so they are rehashed; -conversion.script_sha256 is the signer's word under --no-recipe. See README.md. +commitment.kind is always the signer's word: --public-weights-seed rehashes a seed file against +conversion.public_weights_seed_sha256, but nothing compares that with what the commitment is, so a +signer who claims public-weights and ships a matching seed file is accepted; only rederivation +(rerun the recipe, get the same ID) checks the kind. upstream.* (kind, repo, revision, files, or +generator and seed), conversion.committed_files and conversion.public_weights_seed_sha256 are the +signer's word unless the caller passes --upstream-dir / --committed-dir / --public-weights-seed so +they are rehashed; conversion.script_sha256 is the signer's word under --no-recipe. See README.md. Standard library only (ed25519.py next to this file). """ @@ -117,8 +120,13 @@ def _file_list(files, where, allow_empty=False): raise Refusal("%s: paths not sorted" % where) +def is_none(obj): + """True only for {"none": true}: one encoding (1 and 1.0 compare equal to True, so no ==).""" + return isinstance(obj, dict) and list(obj) == ["none"] and obj["none"] is True + + def _digest_or_none(obj, where): - if obj == {"none": True}: + if is_none(obj): return _keys(obj, where, ("sha256",)) _hex64(obj["sha256"], where + ".sha256") @@ -177,7 +185,7 @@ def check_schema(m): s = m["serving"] _keys(s, "serving", ("tokenizer", "chat_template", "system_prompt", "end_tokens")) - if s["tokenizer"] != {"none": True}: + if not is_none(s["tokenizer"]): _keys(s["tokenizer"], "serving.tokenizer", ("files",)) _file_list(s["tokenizer"]["files"], "serving.tokenizer.files") _digest_or_none(s["chat_template"], "serving.chat_template") @@ -218,9 +226,12 @@ def load(manifest_bytes, signature, trusted_key): raise Refusal("signature: does not verify under the trusted signer key") try: m = json.loads(manifest_bytes.decode("ascii")) + same = canonical(m) == manifest_bytes except (UnicodeDecodeError, ValueError) as e: raise Refusal("manifest: not ASCII JSON (%s)" % e) - if canonical(m) != manifest_bytes: + except RecursionError: + raise Refusal("manifest: nested too deeply to parse") + if not same: raise Refusal("manifest: not in canonical form") check_schema(m) if bytes.fromhex(m["signer"]["public_key"]) != trusted_key: @@ -253,15 +264,28 @@ def check_config(m, config_dir): def check_files(entries, root, where): + """Rehash a directory: every listed file matches, nothing else is there. Symlinks and anything + other than plain files and directories inside root are refused (a walk that does not follow a + symlinked directory would miss what is in it; one that follows it can loop or leave root).""" root = pathlib.Path(root) listed = {e["path"] for e in entries} + present = set() + for top, dirs, files in os.walk(root, followlinks=False): + for name in dirs + files: + p = pathlib.Path(top, name) + rel = p.relative_to(root).as_posix() + if p.is_symlink(): + raise Refusal("%s: %s is a symlink" % (where, rel)) + if name in files: + if not p.is_file(): + raise Refusal("%s: %s is not a plain file" % (where, rel)) + present.add(rel) for e in entries: p = root / e["path"] - if not p.is_file(): + if e["path"] not in present: raise Refusal("%s: %s missing" % (where, e["path"])) if sha256_file(p) != e["sha256"]: raise Refusal("%s: %s digest differs" % (where, e["path"])) - present = {str(p.relative_to(root)) for p in root.rglob("*") if p.is_file()} extra = sorted(present - listed) if extra: raise Refusal("%s: %s is present but not listed" % (where, extra[0])) @@ -269,7 +293,7 @@ def check_files(entries, root, where): def check_serving(m, args): s = m["serving"] - if s["tokenizer"] == {"none": True}: + if is_none(s["tokenizer"]): if not args.no_tokenizer: raise Refusal("serving.tokenizer: manifest says none; pass --no-tokenizer to accept") else: @@ -279,7 +303,7 @@ def check_serving(m, args): for field, path_arg, none_arg in (("chat_template", args.chat_template, args.no_chat_template), ("system_prompt", args.system_prompt, args.no_system_prompt)): want = s[field] - if want == {"none": True}: + if is_none(want): if path_arg or not none_arg: raise Refusal("serving.%s: manifest says none; pass --no-%s and no file" % (field, field.replace("_", "-"))) diff --git a/tools/manifest/tamper.py b/tools/manifest/tamper.py index 1f62142c..0edb4861 100644 --- a/tools/manifest/tamper.py +++ b/tools/manifest/tamper.py @@ -179,10 +179,13 @@ def dump(obj): def coordinated(m, tmp): - """Yield (name, covers, manifest, flag_overrides) for schema-valid trusted re-signs. + """Yield (name, covers, manifest, flag_overrides, with_flag_exit) for schema-valid trusted + re-signs. flag_overrides is what a caller passes to check the changed field against its own files - (None when no flag can check it on CPU). Every alternate passes check_schema. + (None when no flag can check it on CPU). with_flag_exit is the expected exit code with that + flag: 1 (the flag catches it) or 0 (the flag passes and the field stays the signer's word, + amendment 3). Every alternate passes check_schema. """ tmp = pathlib.Path(tmp) seed = tmp / "client-public-weights-seed.bin" # stand-in: the seed file a caller holds @@ -198,8 +201,18 @@ def coordinated(m, tmp): a["commitment"]["kind"] = "public-weights" a["conversion"]["public_weights_seed_sha256"] = "5e" * 32 yield ("kind hiding->public-weights + made-up seed digest", - ["commitment.kind", "conversion.public_weights_seed_sha256"], a, - {"--public-weights-seed": str(seed)}) + ["conversion.public_weights_seed_sha256"], a, + {"--public-weights-seed": str(seed)}, 1) + + # amendment 3: the signer ships a seed file and signs its digest. --public-weights-seed checks + # the file against the digest, not the kind against the commitment, so this is accepted. + shipped = tmp / "signer-shipped-public-weights-seed.bin" + shipped.write_bytes(b"seed file the signer ships with its false public-weights claim\n") + a2 = copy.deepcopy(m) + a2["commitment"]["kind"] = "public-weights" + a2["conversion"]["public_weights_seed_sha256"] = mf.sha256_file(shipped) + yield ("kind hiding->public-weights + digest of a seed file the signer ships", + ["commitment.kind"], a2, {"--public-weights-seed": str(shipped)}, 0) b = copy.deepcopy(m) b["upstream"] = {"kind": "huggingface", "repo": "meta-llama/Llama-4-Maverick-17B-128E-Instruct", @@ -209,31 +222,31 @@ def coordinated(m, tmp): b["conversion"]["committed_files"] = [{"path": "weights.bin", "sha256": "c0" * 32}] yield ("upstream synthetic->full fake huggingface + made-up committed_files", ["upstream.kind", "conversion.committed_files"], b, - {"--upstream-dir": str(up), "--committed-dir": str(committed)}) + {"--upstream-dir": str(up), "--committed-dir": str(committed)}, 1) b2 = copy.deepcopy(b) yield ("same, caller passes --upstream-dir only", ["upstream.kind"], b2, - {"--upstream-dir": str(up)}) + {"--upstream-dir": str(up)}, 1) c = copy.deepcopy(m) c["conversion"]["committed_files"] = [{"path": "weights.bin", "sha256": "c0" * 32}] yield ("committed_files []->one made-up entry (synthetic upstream)", - ["conversion.committed_files"], c, {"--committed-dir": str(committed)}) + ["conversion.committed_files"], c, {"--committed-dir": str(committed)}, 1) for field in ("C", "theta", "cap", "sT"): d = copy.deepcopy(m) d["decode_policy"][field] += 1 d["decode_policy"]["kind"] = "clipped-gap" yield ("decode_policy.%s+1 with kind clipped-gap" % field, - ["decode_policy.%s" % field], d, None) + ["decode_policy.%s" % field], d, None, None) e = copy.deepcopy(m) e["decode_policy"].update({"kind": "clipped-gap", "cap": 1}) - yield ("decode_policy.kind exact->clipped-gap with cap 1", ["decode_policy.kind"], e, None) + yield ("decode_policy.kind exact->clipped-gap with cap 1", ["decode_policy.kind"], e, None, None) for field in ("chat_template", "system_prompt"): f = copy.deepcopy(m) f["serving"][field] = {"none": True} - yield ("serving.%s digest->none" % field, ["serving.%s" % field], f, None) + yield ("serving.%s digest->none" % field, ["serving.%s" % field], f, None, None) g = copy.deepcopy(m) g["serving"]["tokenizer"] = {"none": True} - yield ("serving.tokenizer files->none", ["serving.tokenizer"], g, None) + yield ("serving.tokenizer files->none", ["serving.tokenizer"], g, None, None) diff --git a/tools/manifest/test_manifest.py b/tools/manifest/test_manifest.py index 6e773d1f..a7ae0490 100644 --- a/tools/manifest/test_manifest.py +++ b/tools/manifest/test_manifest.py @@ -9,7 +9,8 @@ word, and the test asserts exactly which fields those are (SIGNERS_WORD_SINGLE), so a new unbound field cannot slip in unnoticed. It also asserts which refusals were the schema's alone (SCHEMA_ONLY), and test_coordinated_trusted_resign re-signs schema-valid alternates of - those (SIGNERS_WORD_UNLESS_FLAG: signer's word unless the matching flag is passed). + those (SIGNERS_WORD_UNLESS_FLAG: signer's word unless the matching flag is passed; + SIGNERS_WORD_COORDINATED_ALWAYS: accepted even with the flag, amendment 3). Plus: every bound file swapped for a one-byte-changed copy, non-canonical JSON, signature bit flip, wrong trusted key, RFC 8032 vectors, cross-check against `cryptography` if installed. """ @@ -46,18 +47,27 @@ "conversion.params.weights", "verifier.source_commit", } +# SIGNERS_WORD_COORDINATED_ALWAYS: the one-field mutation is refused only by the schema, and a +# coordinated, schema-valid alternate re-signed by the trusted key is accepted EVEN WITH the flag +# (amendment 3): commitment.kind hiding->public-weights with the digest of a seed file the signer +# ships passes --public-weights-seed, which checks the file against the digest, not the kind +# against the commitment. Only rederivation checks the kind. +SIGNERS_WORD_COORDINATED_ALWAYS = { + "commitment.kind", +} +# SIGNERS_WORD_ALWAYS: no flag the caller can pass on CPU catches a trusted re-sign. 11 fields. +SIGNERS_WORD_ALWAYS = SIGNERS_WORD_SINGLE | SIGNERS_WORD_COORDINATED_ALWAYS # SIGNERS_WORD_UNLESS_FLAG: the one-field mutation is refused only by the schema (a nonsense value), # but a coordinated, schema-valid alternate re-signed by the trusted key is accepted unless the -# caller passes the matching flag (tamper.coordinated): commitment.kind and the seed digest -# (--public-weights-seed), upstream.* (--upstream-dir), conversion.committed_files -# (--committed-dir). conversion.script_sha256 joins this set when the caller passes --no-recipe. +# caller passes the matching flag (tamper.coordinated): the seed digest (--public-weights-seed), +# upstream.* (--upstream-dir), conversion.committed_files (--committed-dir). 3 fields. +# conversion.script_sha256 joins this set when the caller passes --no-recipe. SIGNERS_WORD_UNLESS_FLAG = { - "commitment.kind", "conversion.public_weights_seed_sha256", "upstream.kind", "conversion.committed_files", } -SIGNERS_WORD = SIGNERS_WORD_SINGLE | SIGNERS_WORD_UNLESS_FLAG +SIGNERS_WORD = SIGNERS_WORD_ALWAYS | SIGNERS_WORD_UNLESS_FLAG # 14 of 37 # One-field mutations that check_schema alone refuses (no file or cp_verify output involved). SCHEMA_ONLY = { "format", "commitment.kind", "upstream.kind", "conversion.committed_files", @@ -268,19 +278,40 @@ def test_every_field_tamper(tmp_path, build): @needs_verifier def test_coordinated_trusted_resign(fx): """Schema-valid alternates re-signed by the trusted key: accepted without the matching flag - (signer's word), refused with it. Fields with no such flag must refuse without one.""" + (signer's word), refused with it, or (amendment 3) accepted with it too. Fields with no such + flag must refuse without one.""" tmp, m, recipe = fx - unless_flag = set() - for name, covers, bad, flags in t.coordinated(m, tmp): + unless_flag, always = set(), set() + for name, covers, bad, flags, with_flag in t.coordinated(m, tmp): mf.check_schema(bad) # really schema-valid code = run(tmp, bad, recipe) if flags is None: assert code == 1, name else: assert code == 0, name # nothing the caller holds pins it - assert run(tmp, bad, recipe, overrides=flags) == 1, name - unless_flag.update(covers) + assert run(tmp, bad, recipe, overrides=flags) == with_flag, name + (always if with_flag == 0 else unless_flag).update(covers) assert unless_flag == SIGNERS_WORD_UNLESS_FLAG + assert always == SIGNERS_WORD_COORDINATED_ALWAYS + assert len(SIGNERS_WORD_ALWAYS) == 11 and len(SIGNERS_WORD_UNLESS_FLAG) == 3 + assert len(SIGNERS_WORD) == 14 + + +@needs_verifier +def test_public_weights_kind_with_matching_seed_accepted(fx): + """Amendment 3: the flag checks the seed digest, not the kind. A trusted re-sign of + hiding->public-weights with the digest of a seed file the signer ships is ACCEPTED with + --public-weights-seed. Documented as the signer's word; pinned here so it cannot drift.""" + tmp, m, recipe = fx + seed = tmp / "shipped-seed.bin" + seed.write_bytes(b"signer-shipped seed\n") + bad = json.loads(json.dumps(m)) + bad["commitment"]["kind"] = "public-weights" + bad["conversion"]["public_weights_seed_sha256"] = mf.sha256_file(seed) + assert run(tmp, bad, recipe, overrides={"--public-weights-seed": str(seed)}) == 0 + other = tmp / "other-seed.bin" + other.write_bytes(b"a different seed\n") + assert run(tmp, bad, recipe, overrides={"--public-weights-seed": str(other)}) == 1 @needs_verifier @@ -327,6 +358,66 @@ def test_extra_tokenizer_file_refused(fx): assert run(tmp, m, recipe) == 1 +def test_symlinks_in_rehashed_dir_refused(tmp_path): + """rglob does not descend into a symlinked directory, so an extra file there was missed. Any + symlink inside a rehashed directory is now refused, file or directory.""" + tok = tmp_path / "tok" + tok.mkdir() + (tok / "tokenizer.json").write_text("{}\n") + entries = [{"path": "tokenizer.json", "sha256": mf.sha256_file(tok / "tokenizer.json")}] + mf.check_files(entries, tok, "serving.tokenizer.files") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "added_tokens.json").write_text("{}\n") + (tok / "sub").symlink_to(outside, target_is_directory=True) + with pytest.raises(mf.Refusal, match="sub is a symlink"): + mf.check_files(entries, tok, "serving.tokenizer.files") + (tok / "sub").unlink() + (tok / "link.json").symlink_to(tok / "tokenizer.json") + with pytest.raises(mf.Refusal, match="link.json is a symlink"): + mf.check_files(entries, tok, "serving.tokenizer.files") + (tok / "link.json").unlink() + (tok / "tokenizer.json").rename(tmp_path / "real.json") + (tok / "tokenizer.json").symlink_to(tmp_path / "real.json") # a listed file as a symlink + with pytest.raises(mf.Refusal, match="tokenizer.json is a symlink"): + mf.check_files(entries, tok, "serving.tokenizer.files") + + +@needs_verifier +def test_extra_tokenizer_file_in_symlinked_dir_refused(fx): + tmp, m, recipe = fx + hidden = tmp / "hidden" + hidden.mkdir() + (hidden / "added_tokens.json").write_text("{}\n") + (tmp / "tokenizer" / "sub").symlink_to(hidden, target_is_directory=True) + assert run(tmp, m, recipe) == 1 + + +def test_deeply_nested_manifest_refused_cleanly(tmp_path): + """A trusted-signed manifest nested 100k deep raised an uncaught RecursionError.""" + data = b"[" * 100000 + b"]" * 100000 + b"\n" + sig = ed25519.sign(t.TRUSTED_SECRET, mf.signed_bytes(data)) + with pytest.raises(mf.Refusal, match="nested too deeply"): + mf.load(data, sig, ed25519.public_key(t.TRUSTED_SECRET)) + deep = b'{"a":' * 100000 + b"1" + b"}" * 100000 + b"\n" + sig = ed25519.sign(t.TRUSTED_SECRET, mf.signed_bytes(deep)) + with pytest.raises(mf.Refusal, match="nested too deeply"): + mf.load(deep, sig, ed25519.public_key(t.TRUSTED_SECRET)) + + +@pytest.mark.parametrize("value", [1, 1.0, False, "true", None]) +def test_none_has_one_encoding(tmp_path, build, value): + """{"none": 1} and {"none": 1.0} compared equal to {"none": True}. Only true means none.""" + m, _ = t.make_fixture(tmp_path, build) + for field in ("chat_template", "system_prompt", "tokenizer"): + bad = json.loads(json.dumps(m)) + bad["serving"][field] = {"none": value} + with pytest.raises(mf.Refusal): + mf.check_schema(bad) + assert mf.is_none({"none": True}) + assert not mf.is_none({"none": value}) + + @needs_verifier def test_missing_serving_files_refused(fx): tmp, m, recipe = fx From 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:25:45 +0000 Subject: [PATCH 09/12] experiments: signed manifest re-measured after amendment 3 (PASS 137/137, 14 of 37 fields signer's word: 11 always incl. commitment.kind, 3 unless a flag; hardening rows refused) (#58) --- .../20261104-58-signed-manifest/ENV.json | 9 +- .../20261104-58-signed-manifest/RESULTS.md | 65 ++++++--- .../20261104-58-signed-manifest/numbers.json | 123 ++++++++++++++---- 3 files changed, 154 insertions(+), 43 deletions(-) diff --git a/experiments/20261104-58-signed-manifest/ENV.json b/experiments/20261104-58-signed-manifest/ENV.json index 49041f48..2f990093 100644 --- a/experiments/20261104-58-signed-manifest/ENV.json +++ b/experiments/20261104-58-signed-manifest/ENV.json @@ -1,8 +1,13 @@ { "commits": { "base": "eb6e6ea379db87b3bd34c6377414422ed6a236dd", - "measured_at": "3bfec22544e7d69da7865657c2139646fd176923", + "measured_at": "c0d161250b04b602a358a72579db1313d3a9b0f6", "verifier_build_digest": "ea191b014747c2c6c5c3c4622a06fa51bbda1c2591b314678c717f351663bd66", + "amendment_2_measurement": { + "measured_at": "3bfec22544e7d69da7865657c2139646fd176923", + "numbers_json_at": "2699b0a04634222bdc5ae39e1abf709697271532", + "note": "amendment 2 run, superseded by amendment 3; kept in git history" + }, "first_measurement": { "measured_at": "95b126083555f90c3e2dd1fd64d179c75d01cb1e", "numbers_json_at": "078d7429ede8d319d0c3c06ac525558340ace93a", @@ -32,5 +37,5 @@ "python3 -m pytest -q -p no:cacheprovider tools/manifest", "python3 experiments/20261104-58-signed-manifest/run.py" ], - "note": "re-measured after PREREG amendment 2 (2026-11-04 UTC), same host class, nice -n 19" + "note": "re-measured after PREREG amendment 3 (2026-11-05 UTC), same host class, nice -n 19" } diff --git a/experiments/20261104-58-signed-manifest/RESULTS.md b/experiments/20261104-58-signed-manifest/RESULTS.md index c7ec4ad6..8b7e6fb2 100644 --- a/experiments/20261104-58-signed-manifest/RESULTS.md +++ b/experiments/20261104-58-signed-manifest/RESULTS.md @@ -2,14 +2,19 @@ Issue #58. **Verdict: PASS** against the registered threshold. Measured, unreplicated, CPU only, synthetic stored example. Raw data: `numbers.json` (every row with exit code and the refusal -line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (52 passed). +line). Runner: `run.py`. Tests: `tools/manifest/test_manifest.py` (61 passed). -**Re-measured after PREREG amendment 2** (2026-11-04 UTC, after the independent review of PR #59): the -code now refuses small-order ed25519 keys and R, requires `--end-tokens` and `--recipe` or -`--no-recipe`, and the runner adds a coordinated trusted re-sign arm and omission rows. All numbers -below are from the re-run at code commit `3bfec225`. The first measurement (`numbers.json` at -`078d7429`) gave the same registered counts (137/137, 10/10, 3/3, 64/64) and verify median 1.44 s -(SD 0.10, min 1.40, max 1.75, n = 10); it stays in git history. +**Re-measured after PREREG amendment 3** (2026-11-05 UTC, after the second independent review of +PR #59 at `94966765`): `commitment.kind` is now counted as the signer's word even with +`--public-weights-seed` (a matching-seed coordinated row pins it), and the code refuses symlinks in +rehashed directories, `{"none": 1}` / `{"none": 1.0}` and too-deeply nested manifests. All numbers +below are from the re-run at code commit `c0d16125`. Every registered row (174 rows, exit code and +refusal line) is identical to the amendment 2 run. + +Earlier runs, kept in git history: amendment 2 (`numbers.json` at `2699b0a0`, code `3bfec225`): +same registered counts, verify median 1.414 s (SD 0.026, min 1.383, max 1.452, n = 10). First +measurement (`numbers.json` at `078d7429`): same registered counts, verify median 1.44 s (SD 0.10, +min 1.40, max 1.75, n = 10). | registered metric | result | threshold | | |---|---|---|---| @@ -17,7 +22,7 @@ below are from the re-run at code commit `3bfec225`. The first measurement (`num | untampered accepted (`cp_verify` exit 0, `YES`, manifest ACCEPTED) | **10 / 10** | 10 / 10, 0 false refusals | PASS | | RFC 8032 section 7.1 vectors 1-3 | **3 / 3** | all | PASS | | cross-check vs `cryptography` 41.0.7, 64 random messages, seed 1104 | **64 / 64** identical signatures | (not registered as a threshold) | | -| verify wall time, whole call incl. one `cp_verify` | median **1.41 s**, SD 0.03, min 1.38, max 1.45 (n = 10) | none | info | +| verify wall time, whole call incl. one `cp_verify` | median **1.407 s**, SD 0.027, min 1.380, max 1.471 (n = 10) | none | info | The 137 registered tampers: 37 manifest fields (every JSON leaf, enumerated mechanically) x 3 arms (original signature kept; re-signed by an attacker key; attacker key also written into @@ -55,12 +60,14 @@ accepted, signer's word only: `signer.name`, `upstream.generator`, `upstream.see `conversion.script` (path label), `conversion.repo_commit`, `conversion.version`, `conversion.params.{M,config,weights}`, `verifier.source_commit`. -**Coordinated, schema-valid alternates (amendment 2).** 12 alternates, each re-signed by the -trusted key, run with the required flags and without, then with, the rehash flag that checks it: +**Coordinated, schema-valid alternates (amendments 2 and 3).** 13 alternates (12 from amendment 2, +plus the matching-seed row of amendment 3), each re-signed by the trusted key, run with the required +flags and without, then with, the rehash flag that checks it: | alternate | without the rehash flag | with it | |---|---|---| -| `commitment.kind` hiding to public-weights + made-up seed digest | **ACCEPTED** (`cp_verify` YES) | refused with `--public-weights-seed` | +| `commitment.kind` hiding to public-weights + made-up seed digest | **ACCEPTED** (`cp_verify` YES) | refused with `--public-weights-seed` (the digest does not match the caller's file) | +| `commitment.kind` hiding to public-weights + digest of a seed file the signer ships (amendment 3) | **ACCEPTED** | **ACCEPTED** with `--public-weights-seed` and that file (`cp_verify` YES) | | upstream synthetic to a full made-up huggingface upstream + made-up `committed_files` | **ACCEPTED** | refused with `--upstream-dir --committed-dir` | | same, caller passes `--upstream-dir` only | **ACCEPTED** | refused with `--upstream-dir` | | `committed_files` empty to one made-up entry (synthetic upstream) | **ACCEPTED** | refused with `--committed-dir` | @@ -68,11 +75,17 @@ trusted key, run with the required flags and without, then with, the rehash flag | chat template, system prompt, tokenizer to `none` | refused (needs `--no-*`) | (no flag needed) | So the decode-policy fields are bound (to `policy.txt` and `cp_verify`'s output), but -**`commitment.kind`, `upstream.*`, `conversion.committed_files` and -`conversion.public_weights_seed_sha256` are the signer's word unless the caller passes -`--public-weights-seed`, `--upstream-dir`, `--committed-dir`.** On this fixture that makes 14 -fields the signer's word (the 10 above plus these 4). `commitment.kind` matters most: it decides -whether rederivation is possible at all, and a claimed `hiding` is checked by nothing. +**`upstream.*`, `conversion.committed_files` and `conversion.public_weights_seed_sha256` are the +signer's word unless the caller passes `--upstream-dir`, `--committed-dir`, +`--public-weights-seed`, and `commitment.kind` is the signer's word even with those flags.** +`--public-weights-seed` rehashes a seed file against the signed digest; nothing compares that with +what the commitment is, so a signer who claims `public-weights` and ships a matching seed file is +accepted. Amendment 2 listed `commitment.kind` under "unless a flag"; that was wrong (its row used +a made-up digest, so it showed that a mismatched digest is refused, not that the kind is checked), +and amendment 3 corrects it. On this fixture that makes 14 fields the signer's word: **11 always** +(the 10 above plus `commitment.kind`) and **3 unless the caller passes the matching rehash flag**. +`commitment.kind` matters most: it decides whether rederivation is possible at all, and only +rederivation checks it. This is expected: a signature authenticates the signer, not the truth of what it signed. The signer's word fields become checkable only by rehashing files the client holds, or by rederivation @@ -90,6 +103,21 @@ refused. Both given: refused. `--no-recipe` with the honest manifest: accepted, `--committed-dir`, `--public-weights-seed` stay optional, so omitting them is not refused; their fields are listed as the signer's word above. +## Hardening (amendment 3, no threshold) + +Each was found by the second review and is now refused, with a unit test and a runner row +(`numbers.json` `hardening_rows`), all re-signed by the trusted key unless noted: + +| case | before (review at `94966765`) | now | +|---|---|---| +| extra `added_tokens.json` in a symlinked subdirectory of `--tokenizer-dir` | ACCEPTED (`rglob` does not descend into it) | refused: `sub is a symlink` | +| manifest nested 100000 deep | uncaught `RecursionError` (traceback, exit 1) | refused: `nested too deeply to parse` | +| `serving.system_prompt` / `serving.chat_template` set to `{"none": 1}` or `{"none": 1.0}` | accepted as none | refused (4 of 4 rows) | + +Any symlink inside a rehashed directory is refused, file or directory, listed or not: a caller who +keeps a tokenizer or checkpoint behind symlinks (as a Hugging Face cache does) must copy or +dereference it first. + ## Not shown - No real model, no real tokenizer or chat template (fixture files stand in); Maverick manifest @@ -97,10 +125,11 @@ fields are listed as the signer's word above. - Binding is client-side and detached: the manifest digest is not inside the configuration or the proof (threat model M1 says that needs a parser change, untested). - `ed25519.py` signing is not constant time; it is for tests. Verification works on public data. -- No independent replication yet. The amendment 2 changes were made after results and after +- No independent replication yet. The amendment 2 and 3 changes were made after results and after review; the registered threshold did not change. Proposed claim (for the reviewer; the ledger entry is `signed-manifest` in `research/results.yaml`, nothing added to `loop/claims.json`): "tools/manifest refuses 137 of 137 registered tampers and accepts 10 of 10 untampered verifies of the stored small example; on that fixture 14 of 37 fields -are the signer's word (10 always, 4 unless the caller passes the matching rehash flag)." +are the signer's word (11 always, including `commitment.kind`, and 3 unless the caller passes the +matching rehash flag)." diff --git a/experiments/20261104-58-signed-manifest/numbers.json b/experiments/20261104-58-signed-manifest/numbers.json index 6f48b265..cd6678d7 100644 --- a/experiments/20261104-58-signed-manifest/numbers.json +++ b/experiments/20261104-58-signed-manifest/numbers.json @@ -1050,68 +1050,67 @@ "i": 0, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.3852572813630104 + "wall_s": 1.4708434715867043 }, { "i": 1, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.451579537242651 + "wall_s": 1.4079530201852322 }, { "i": 2, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4440598413348198 + "wall_s": 1.3795696422457695 }, { "i": 3, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.42983827739954 + "wall_s": 1.41568049415946 }, { "i": 4, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.3830635249614716 + "wall_s": 1.4066016040742397 }, { "i": 5, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.409959513694048 + "wall_s": 1.3835452757775784 }, { "i": 6, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.3925894498825073 + "wall_s": 1.4110643826425076 }, { "i": 7, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4475439377129078 + "wall_s": 1.400022517889738 }, { "i": 8, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.4045466259121895 + "wall_s": 1.382567085325718 }, { "i": 9, "exit": 0, "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", - "wall_s": 1.417960662394762 + "wall_s": 1.4216599352657795 } ], "coordinated": [ { "alternate": "kind hiding->public-weights + made-up seed digest", "covers": [ - "commitment.kind", "conversion.public_weights_seed_sha256" ], "flag": null, @@ -1121,14 +1120,35 @@ { "alternate": "kind hiding->public-weights + made-up seed digest", "covers": [ - "commitment.kind", "conversion.public_weights_seed_sha256" ], "flag": [ "--public-weights-seed" ], "exit": 1, - "last": "manifest: REFUSED: conversion.public_weights_seed_sha256: seed file digest differs" + "last": "manifest: REFUSED: conversion.public_weights_seed_sha256: seed file digest differs", + "expected_exit": 1 + }, + { + "alternate": "kind hiding->public-weights + digest of a seed file the signer ships", + "covers": [ + "commitment.kind" + ], + "flag": null, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)" + }, + { + "alternate": "kind hiding->public-weights + digest of a seed file the signer ships", + "covers": [ + "commitment.kind" + ], + "flag": [ + "--public-weights-seed" + ], + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "expected_exit": 0 }, { "alternate": "upstream synthetic->full fake huggingface + made-up committed_files", @@ -1151,7 +1171,8 @@ "--upstream-dir" ], "exit": 1, - "last": "manifest: REFUSED: conversion.committed_files: weights.bin missing" + "last": "manifest: REFUSED: conversion.committed_files: weights.bin missing", + "expected_exit": 1 }, { "alternate": "same, caller passes --upstream-dir only", @@ -1171,7 +1192,8 @@ "--upstream-dir" ], "exit": 1, - "last": "manifest: REFUSED: upstream.files: config.json digest differs" + "last": "manifest: REFUSED: upstream.files: config.json digest differs", + "expected_exit": 1 }, { "alternate": "committed_files []->one made-up entry (synthetic upstream)", @@ -1191,7 +1213,8 @@ "--committed-dir" ], "exit": 1, - "last": "manifest: REFUSED: conversion.committed_files: weights.bin missing" + "last": "manifest: REFUSED: conversion.committed_files: weights.bin missing", + "expected_exit": 1 }, { "alternate": "decode_policy.C+1 with kind clipped-gap", @@ -1308,14 +1331,54 @@ "small_order_r_refused": 8, "total": 8 }, + "hardening": { + "extra tokenizer file in a symlinked subdir": 1, + "trusted-signed manifest nested 100000 deep": 1, + "serving.system_prompt {\"none\": 1} trusted re-sign": 1, + "serving.chat_template {\"none\": 1} trusted re-sign": 1, + "serving.system_prompt {\"none\": 1.0} trusted re-sign": 1, + "serving.chat_template {\"none\": 1.0} trusted re-sign": 1 + }, + "hardening_rows": [ + { + "case": "extra tokenizer file in a symlinked subdir", + "exit": 1, + "last": "manifest: REFUSED: serving.tokenizer.files: sub is a symlink" + }, + { + "case": "trusted-signed manifest nested 100000 deep", + "exit": 1, + "last": "manifest: REFUSED: manifest: nested too deeply to parse" + }, + { + "case": "serving.system_prompt {\"none\": 1} trusted re-sign", + "exit": 1, + "last": "manifest: REFUSED: serving.system_prompt: missing key sha256" + }, + { + "case": "serving.chat_template {\"none\": 1} trusted re-sign", + "exit": 1, + "last": "manifest: REFUSED: serving.chat_template: missing key sha256" + }, + { + "case": "serving.system_prompt {\"none\": 1.0} trusted re-sign", + "exit": 1, + "last": "manifest: REFUSED: serving.system_prompt: missing key sha256" + }, + { + "case": "serving.chat_template {\"none\": 1.0} trusted re-sign", + "exit": 1, + "last": "manifest: REFUSED: serving.chat_template: missing key sha256" + } + ], "summary": { "untampered_accepted": 10, "untampered_n": 10, "verify_wall_s": { - "median": 1.413960088044405, - "sd": 0.02578796718355745, - "min": 1.3830635249614716, - "max": 1.451579537242651, + "median": 1.407277312129736, + "sd": 0.026505768320182484, + "min": 1.3795696422457695, + "max": 1.4708434715867043, "n": 10, "note": "whole verify call: manifest checks plus one cp_verify process on the stored small example" }, @@ -1366,9 +1429,10 @@ "signer.public_key", "verifier.build_digest" ], - "coordinated_alternates": 12, + "coordinated_alternates": 13, "coordinated_accepted_without_flag": [ "committed_files []->one made-up entry (synthetic upstream)", + "kind hiding->public-weights + digest of a seed file the signer ships", "kind hiding->public-weights + made-up seed digest", "same, caller passes --upstream-dir only", "upstream synthetic->full fake huggingface + made-up committed_files" @@ -1379,19 +1443,32 @@ "same, caller passes --upstream-dir only", "upstream synthetic->full fake huggingface + made-up committed_files" ], - "coordinated_accepted_with_flag": [], + "coordinated_accepted_with_flag": [ + "kind hiding->public-weights + digest of a seed file the signer ships" + ], "signers_word_unless_flag_fields": [ - "commitment.kind", "conversion.committed_files", "conversion.public_weights_seed_sha256", "upstream.kind" ], + "signers_word_even_with_flag_fields": [ + "commitment.kind" + ], + "coordinated_with_flag_unexpected": [], "omission": { "--end-tokens left out": 1, "--recipe left out, no --no-recipe": 1, "both --recipe and --no-recipe": 1, "--no-recipe, honest manifest": 0, "--no-recipe, script_sha256 changed + trusted re-sign": 0 + }, + "hardening": { + "extra tokenizer file in a symlinked subdir": 1, + "trusted-signed manifest nested 100000 deep": 1, + "serving.system_prompt {\"none\": 1} trusted re-sign": 1, + "serving.chat_template {\"none\": 1} trusted re-sign": 1, + "serving.system_prompt {\"none\": 1.0} trusted re-sign": 1, + "serving.chat_template {\"none\": 1.0} trusted re-sign": 1 } }, "host": { From facd69cd0451583b323c4e88ea303e338a8fc9e1 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:26:24 +0000 Subject: [PATCH 10/12] research: signed-manifest results.yaml after amendment 3 (11 always + 3 unless a flag), regenerate README block and CLAIMS.md (#58) --- CLAIMS.md | 2 +- README.md | 4 ++-- research/results.yaml | 15 ++++++++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CLAIMS.md b/CLAIMS.md index df66cc32..91476bf1 100644 --- a/CLAIMS.md +++ b/CLAIMS.md @@ -29,5 +29,5 @@ tracked file, which both scripts resolve (they check that it exists, not what it | `steering-real-proofs` | Runtime steering under real cp_prove / cp_verify, small synthetic model (PR #50): exact match: 0 of 57 changed steered replies accepted (all refused by the prover), 13 of 13 unchanged accepted; shipped clipped-gap: 5 of 54 changed replies accepted `WITHIN LIMITS`, all at dose 0.05, passing on the theta mean bound (above-clip 0); Python score agreed with the real decision on 140 of 140 | measured, unreplicated (strict PASS, lenient half holds) | unreplicated | [experiments/20261104-36-steering-real-proofs/RESULTS.md](experiments/20261104-36-steering-real-proofs/RESULTS.md) | #50 | | `qwen-deltanet-m1` | Qwen Gated DeltaNet head at real shape, M1 (PR #47): FAIL (NO-GO for M2 as registered): 10 of 10 honest proofs accepted (prove median 51.4 s, verify 11.6 s, 50.4 MB) and 40 of 40 hostile proofs rejected, but under heavy-tailed values delta reaches 422,167 > 2^18 at F = 12 (0 of 5 seeds fit) | negative, unreplicated | unreplicated | [research/operators/qwen_deltanet/README.md](research/operators/qwen_deltanet/README.md), [experiments/20261104-11-qwen-deltanet-m1/RESULTS.md](experiments/20261104-11-qwen-deltanet-m1/RESULTS.md) | #47 | | `swap-at-verifier` | #45 attack classes through the honest cp_prove + cp_verify, small synthetic model, one H100 (PR #51): honest-prover path: Python score matched all 256 decisions (89 cp_verify accepts, 167 honest-prover refusals, 0 cp_verify rejections); one swap then greedy continuation proved and accepted 15/16 (fp2nd, 95% CI 0.70-1.00) and 13/16 (rand, 0.54-0.96) under the shipped limits; 4 swaps and random replies 0/16; cp_verify's limit check was not exercised on a forged proof | measured, unreplicated | unreplicated | [experiments/20261104-38-swap-at-verifier/RESULTS.md](experiments/20261104-38-swap-at-verifier/RESULTS.md) | #51 | -| `signed-manifest` | Signed model manifest checked next to cp_verify, stored small example (PR #59): 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; a trusted signer's re-sign is accepted for 10 of 37 fields as a one-field change and for 4 more (commitment.kind, upstream, committed files, seed digest) as a schema-valid change unless the caller passes the rehash flag; small-order ed25519 keys and R refused | measured, unreplicated | unreplicated | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | #59 | +| `signed-manifest` | Signed model manifest checked next to cp_verify, stored small example (PR #59): 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; 14 of 37 fields are the trusted signer's word: 11 always (10 as a one-field change, plus commitment.kind, which a matching seed file passes even with --public-weights-seed) and 3 (upstream, committed files, seed digest) unless the caller passes the rehash flag; small-order ed25519 keys and R refused; symlinks in rehashed dirs, deep nesting and non-true none refused | measured, unreplicated | unreplicated | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | #59 | | `open-studies` | Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44): none yet | in progress | n/a | [docs/research-roadmap.md](docs/research-roadmap.md) | - | diff --git a/README.md b/README.md index c0d2812e..3249b15b 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ Every number below comes from a file on `main` (the one exception, a reviewer fi | Runtime steering under real cp_prove / cp_verify, small synthetic model (PR #50) | measured, unreplicated (strict PASS, lenient half holds) | exact match: 0 of 57 changed steered replies accepted (all refused by the prover), 13 of 13 unchanged accepted; shipped clipped-gap: 5 of 54 changed replies accepted `WITHIN LIMITS`, all at dose 0.05, passing on the theta mean bound (above-clip 0); Python score agreed with the real decision on 140 of 140 | one neuron, synthetic 8-layer model, 5 prompt seeds x 7 doses x 2 reply lengths x 2 policies, one H100 of an unverified host; honest prover only, no cheating prover tested; S and G replies are not paired (different scales) | [experiments/20261104-36-steering-real-proofs/RESULTS.md](experiments/20261104-36-steering-real-proofs/RESULTS.md) | | Qwen Gated DeltaNet head at real shape, M1 (PR #47) | negative, unreplicated | FAIL (NO-GO for M2 as registered): 10 of 10 honest proofs accepted (prove median 51.4 s, verify 11.6 s, 50.4 MB) and 40 of 40 hostile proofs rejected, but under heavy-tailed values delta reaches 422,167 > 2^18 at F = 12 (0 of 5 seeds fit) | one head, 128 x 128 state, T = 4, F = 12, synthetic inputs, research test security settings (target 10 bits), CPU, shared loaded VM; range study 1,024 steps x 4 distributions x 5 seeds, integer reference only | [research/operators/qwen_deltanet/](research/operators/qwen_deltanet/README.md) | | #45 attack classes through the honest cp_prove + cp_verify, small synthetic model, one H100 (PR #51) | measured, unreplicated | honest-prover path: Python score matched all 256 decisions (89 cp_verify accepts, 167 honest-prover refusals, 0 cp_verify rejections); one swap then greedy continuation proved and accepted 15/16 (fp2nd, 95% CI 0.70-1.00) and 13/16 (rand, 0.54-0.96) under the shipped limits; 4 swaps and random replies 0/16; cp_verify's limit check was not exercised on a forged proof | target_small_f2_gap, shipped limits and #45's fit point (both cap 1), one fresh commitment each; #45's seed 2026 held-out replies, 16 per class, one draw; synthetic weights; soundness flag vacuous for a cheating prover | [experiments/20261104-38-swap-at-verifier/RESULTS.md](experiments/20261104-38-swap-at-verifier/RESULTS.md) | -| Signed model manifest checked next to cp_verify, stored small example (PR #59) | measured, unreplicated | 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; a trusted signer's re-sign is accepted for 10 of 37 fields as a one-field change and for 4 more (commitment.kind, upstream, committed files, seed digest) as a schema-valid change unless the caller passes the rehash flag; small-order ed25519 keys and R refused | stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 2 | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | +| Signed model manifest checked next to cp_verify, stored small example (PR #59) | measured, unreplicated | 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; 14 of 37 fields are the trusted signer's word: 11 always (10 as a one-field change, plus commitment.kind, which a matching seed file passes even with --public-weights-seed) and 3 (upstream, committed files, seed digest) unless the caller passes the rehash flag; small-order ed25519 keys and R refused; symlinks in rehashed dirs, deep nesting and non-true none refused | stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 3 | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | | Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44) | in progress | none yet | open PRs, issues and lanes, not merged | [docs/research-roadmap.md](docs/research-roadmap.md) | -_Last regenerated from `research/results.yaml` on 2026-11-04 (UTC), against main `eb6e6ea3`._ +_Last regenerated from `research/results.yaml` on 2026-11-05 (UTC), against main `eb6e6ea3`._ What this table does not show: no full-size (Maverick) run, no real model weights on GPU, no serving, no TEE, no production use, and no comparison with other verifiable-inference systems. The GPU rows are upstream's small example on made-up weights. diff --git a/research/results.yaml b/research/results.yaml index a552180f..58ac2be7 100644 --- a/research/results.yaml +++ b/research/results.yaml @@ -364,15 +364,16 @@ results: status: measured replication: unreplicated pr: 59 - summary: "137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; a trusted signer's re-sign is accepted for 10 of 37 fields as a one-field change and for 4 more (commitment.kind, upstream, committed files, seed digest) as a schema-valid change unless the caller passes the rehash flag; small-order ed25519 keys and R refused" - conditions: "stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 2" + summary: "137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; 14 of 37 fields are the trusted signer's word: 11 always (10 as a one-field change, plus commitment.kind, which a matching seed file passes even with --public-weights-seed) and 3 (upstream, committed files, seed digest) unless the caller passes the rehash flag; small-order ed25519 keys and R refused; symlinks in rehashed dirs, deep nesting and non-true none refused" + conditions: "stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 3" evidence: [experiments/20261104-58-signed-manifest/RESULTS.md] metrics: - - {name: registered_tampers_refused, stat: count, value: 137, total: 137, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "enumerated tamper set, each deterministic; registered threshold 100%"} - - {name: untampered_accepted, stat: count, value: 10, total: 10, unit: count, n: 10, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "repeats of one deterministic check, not independent trials"} - - {name: trusted_resign_accepted_fields, stat: count, value: 10, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "one-field change re-signed by the trusted key, accepted; no threshold"} - - {name: trusted_resign_accepted_unless_flag_fields, stat: count, value: 4, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "schema-valid coordinated change re-signed by the trusted key, accepted without the matching rehash flag, refused with it; no threshold"} - - {name: verify_wall, stat: median, value: 1.414, unit: s, n: 10, spread: {sd: 0.026, min: 1.383, max: 1.452}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-04, commit: 2699b0a04634222bdc5ae39e1abf709697271532, source: experiments/20261104-58-signed-manifest/numbers.json, note: "whole verify call incl. one cp_verify on the stored small example; shared VM; no threshold"} + - {name: registered_tampers_refused, stat: count, value: 137, total: 137, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92, source: experiments/20261104-58-signed-manifest/numbers.json, note: "enumerated tamper set, each deterministic; registered threshold 100%"} + - {name: untampered_accepted, stat: count, value: 10, total: 10, unit: count, n: 10, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92, source: experiments/20261104-58-signed-manifest/numbers.json, note: "repeats of one deterministic check, not independent trials"} + - {name: trusted_resign_accepted_fields, stat: count, value: 10, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92, source: experiments/20261104-58-signed-manifest/numbers.json, note: "one-field change re-signed by the trusted key, accepted; no threshold"} + - {name: trusted_resign_accepted_always_fields, stat: count, value: 11, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92, source: experiments/20261104-58-signed-manifest/numbers.json, note: "accepted when re-signed by the trusted key with every flag the caller can pass on CPU: the 10 one-field fields plus commitment.kind (matching seed file, amendment 3); no threshold"} + - {name: trusted_resign_accepted_unless_flag_fields, stat: count, value: 3, total: 37, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92, source: experiments/20261104-58-signed-manifest/numbers.json, note: "schema-valid coordinated change re-signed by the trusted key, accepted without the matching rehash flag, refused with it (upstream.kind, committed_files, seed digest; commitment.kind moved to always in amendment 3); no threshold"} + - {name: verify_wall, stat: median, value: 1.407, unit: s, n: 10, spread: {sd: 0.027, min: 1.380, max: 1.471}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: 0ac5fca15f69c3620dbcd3d3116c5d81d12b2c92, source: experiments/20261104-58-signed-manifest/numbers.json, note: "whole verify call incl. one cp_verify on the stored small example; shared VM; no threshold"} - id: open-studies title: "Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44)" From 26a3afd96012c29dc4e0cce35aacac64d345bbe5 Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:49:09 +0000 Subject: [PATCH 11/12] docs(manifest): review fixes: upstream repo/revision are the signer's word even with --upstream-dir, verify is exact-match only, NEAR TEE source, #54 merged, #51 cite, 93x and reply-cost caveats (#58) --- research/security/DESIGN-VAST-ABLITERATED.md | 32 +++++++++++++------- tools/manifest/README.md | 4 +-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/research/security/DESIGN-VAST-ABLITERATED.md b/research/security/DESIGN-VAST-ABLITERATED.md index 50c53b73..9067cb3c 100644 --- a/research/security/DESIGN-VAST-ABLITERATED.md +++ b/research/security/DESIGN-VAST-ABLITERATED.md @@ -1,7 +1,7 @@ # Design note: proven abliterated models on vast hosts, compared with amiller/dsmack -Issue #58 (part of #6, #36, #9). Builds on the threat model of PR #54 (open, unmerged; cited by -path `research/security/threat-model/` on its branch) and the signed manifest prototype in +Issue #58 (part of #6, #36, #9). Builds on the threat model of PR #54 (merged after this note was written; +`research/security/threat-model/`) and the signed manifest prototype in `tools/manifest/` (this PR). Design only. No GPU was rented and nothing in this note is a result; nothing goes into `research/results.yaml`. @@ -43,9 +43,12 @@ Sources: [README](https://github.com/amiller/dsmack), responses remain encrypted between the browser and NEAR AI Cloud's model TEE; the broker forwards ciphertext and meters usage". The relay broker itself runs in a dstack CVM (Intel TDX), and email recovery checks a TDX quote. The prompt privacy in dsmack comes from a **TDX + GPU TEE at - NEAR**, not from App Attest. + NEAR**, not from App Attest. (NEAR's own description: TEE-hosted models run inside Intel TDX + confidential VMs with NVIDIA H200 GPUs in confidential-computing mode, + , + ; dsmack itself only says "NEAR AI Cloud's model TEE".) - **Marketplace pieces (cited).** Node enrollment (`enroll.py`, App Attest chain), signed - hb_signals with ownership links (EIP-712 `MacLink`), Ed25519 byte/time grants, a metering and + heartbeats with ownership links (EIP-712 `MacLink`), Ed25519 byte/time grants, a metering and settlement loop on Base Sepolia (`applications/payments`), bounty escrow (`work-escrow/`), and a forgery canary contract that pays out on a forged assertion. @@ -130,9 +133,10 @@ base checkpoint (HF repo @ 40-hex revision, file sha256s) <- manifest.u Every arrow is a pinned program on pinned inputs. A manifest (this PR's `tools/manifest/`) records each input digest and the recipe; the signer signs it; anyone who reruns the chain and gets the same ID has checked the signer's claim rather than trusted it. Without that rerun, the client can -only rehash what it holds: `upstream.*`, `conversion.committed_files` and the public-weights seed -digest are the signer's word unless it passes `--upstream-dir`, `--committed-dir` and -`--public-weights-seed`, and `commitment.kind` is the signer's word even then, because the seed +only rehash what it holds: `upstream.files`, `conversion.committed_files` and the public-weights +seed digest are the signer's word unless it passes `--upstream-dir`, `--committed-dir` and +`--public-weights-seed`; `upstream.repo` and `upstream.revision` (the rehash checks file contents, +not where they came from) and `commitment.kind` are the signer's word even then, because the seed flag checks a file against the digest, not the kind against the commitment (measured in the manifest experiment's amendment arms). Today that rerun is possible only for `-public-weights` commitments (hiding ones mix in a secret @@ -164,7 +168,7 @@ Does **not** prove: integer model (the CPU integer-model package can), and publish them as evals, not as proofs. - **that nothing else was inserted.** The direction r is a published tensor: a malicious publisher can choose an r that does more than ablate refusal, and the base checkpoint itself can carry a - sleeper (threat model `SLEEPER.md` on PR #54's branch). Rederivation shows the recipe was + sleeper (threat model `research/security/threat-model/SLEEPER.md`, #54). Rederivation shows the recipe was followed, not that the recipe is benign. Publishing how r was computed (prompt sets, layer, script) lets others recompute r too, which narrows this to "the base and the prompt sets". - **that quantization did not activate something.** #54 cites quantization-activated backdoors @@ -172,7 +176,7 @@ Does **not** prove: be built to misbehave only after a given quantizer. Abliterating in full precision and then quantizing inherits exactly that risk. Mitigation: vet and eval the *integer* artifact (M3), and prefer exact match (M4) for anything safety-relevant, because clipped gap lets a provider pick a - second-ranked token (#50, #51: one swap proved and accepted 15/16 and 13/16 under the shipped + second-ranked token (#51: one swap proved and accepted 15/16 and 13/16 under the shipped limits, measured, unreplicated, honest-prover only). - that the base publisher's weights are what they claim to be about training data or provenance. @@ -249,9 +253,15 @@ Untested at real scale. Caveats (inferred): these use upstream's best-case steady rates (start-up and commit excluded); the host must have about 330 GB of RAM for Maverick, which most single-H100 vast offers do not (our rentals were not checked for that); the 93x compares against an FP8 engine on other hardware, -so "93x the price of unproved inference" is a rough ceiling, not a measured price ratio on vast. +so "93x the price of unproved inference" is a rough figure, not a measured price ratio on vast, +and if anything low: upstream says its vLLM baseline is not the most those machines can do, which +flatters the prover (`guide/performance.md`, Cost). Client-side verification at about 21 CPU-seconds per proof is a real cost for a gateway and is -not in the GPU figure. +not in the GPU figure. The reply figure is proving only: generating the reply is not in it, and +upstream's stored replies were chosen because scoring expected them to meet the clipped-gap limits +(`guide/performance.md`, Whole replies). That rate is under clipped gap, which `tools/manifest` +`verify` does not accept yet: it calls `cp_verify` without `-policy` and requires `YES`, so an +honest clipped-gap manifest on `examples/accepted-proof-clipped-gap` is refused (`NO: policy`). ### 3.2 Which models are provable today diff --git a/tools/manifest/README.md b/tools/manifest/README.md index 2c444b60..6f1b0222 100644 --- a/tools/manifest/README.md +++ b/tools/manifest/README.md @@ -9,11 +9,11 @@ check that runs next to `cp_verify`. The prover is unchanged. Standard library o |---|---|---| | `commitment.id` | the FlashProve commitment ID | `cp_verify -id` (and its `id=`) | | `commitment.kind` | `public-weights` or `hiding` | signer's word, even with `--public-weights-seed`: that flag checks the seed file against the digest, not the kind against the commitment, so a false `public-weights` with a seed file the signer ships is accepted, and nothing checks a claimed `hiding`; only rederivation checks the kind | -| `upstream` | `huggingface`: repo, 40-hex revision, sha256 of every file; or `synthetic`: generator + seed | `--upstream-dir` (rehash, refuse unlisted files), else signer's word (kind, repo, revision, files) | +| `upstream` | `huggingface`: repo, 40-hex revision, sha256 of every file; or `synthetic`: generator + seed | `--upstream-dir` rehashes the files (refuses unlisted files), else signer's word (kind, files); `repo` and `revision` are the signer's word even with the flag (it checks contents, not origin); generator and seed: signer's word | | `conversion` | script path + sha256, repo commit, version, params, sha256 of committed files, public-weights seed digest | `--recipe` (or explicit `--no-recipe`: digest is then the signer's word), `--committed-dir`, `--public-weights-seed`, else signer's word; params/version/commit: signer's word, checkable by rederivation | | `configuration` | sha256 of `policy.txt`, `KL.bin`, `layout.txt` | the `--config` files | | `serving` | tokenizer files, chat template, system prompt (each a digest or `{"none": true}`), end tokens | `--tokenizer-dir`, `--chat-template`, `--system-prompt`, `--no-*`, `--end-tokens` (all required) | -| `decode_policy` | `exact` or `clipped-gap` + C, theta, cap, s, sh, sT, F | `policy.txt` and `cp_verify`'s `result-v1` | +| `decode_policy` | `exact` or `clipped-gap` + C, theta, cap, s, sh, sT, F | `policy.txt` and `cp_verify`'s `result-v1`; only `exact` verifies today: `verify` passes no `-policy` and requires `YES`, so a clipped-gap manifest is refused (fails closed) | | `verifier.build_digest` | the `build=` that `cp_verify` prints | `cp_verify`'s `result-v1` | | `signer` | name, public key | the key the CALLER passes with `--signer-key` (never the manifest's own) | From b0cb7eb99ee62f6de9c1abce5f6907bf79225abe Mon Sep 17 00:00:00 2001 From: wakesync <60821182+wakesync@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:39:45 +0000 Subject: [PATCH 12/12] results.yaml: point qwen-deltanet-m1b-lookup metrics at squash commit b6186e6c (cc573ae9 was branch-only); regenerate README block and CLAIMS.md with signed-manifest row --- README.md | 3 ++- research/results.yaml | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fb44855b..65ea6de6 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,10 @@ Every number below comes from a file on `main` (the one exception, a reviewer fi | Qwen Gated DeltaNet head at real shape, M1 (PR #47) | negative, unreplicated | FAIL (NO-GO for M2 as registered): 10 of 10 honest proofs accepted (prove median 51.4 s, verify 11.6 s, 50.4 MB) and 40 of 40 hostile proofs rejected, but under heavy-tailed values delta reaches 422,167 > 2^18 at F = 12 (0 of 5 seeds fit) | one head, 128 x 128 state, T = 4, F = 12, synthetic inputs, research test security settings (target 10 bits), CPU, shared loaded VM; range study 1,024 steps x 4 distributions x 5 seeds, integer reference only | [research/operators/qwen_deltanet/](research/operators/qwen_deltanet/README.md) | | Qwen Gated DeltaNet head with the RNG/LKP range check, M1b (PR #63) | measured, unreplicated | PASS vs prereg (GO for M2 design): RNG/LKP composed with the wide product oracle in one group; 10 of 10 honest proofs (prove median 39.2 s, verify 7.1 s, 25.4 MB, table 2^21), 44 of 44 hostile rejected incl. limb-recombination and out-of-range state, and 5 of 5 R3 windows where M1 overflowed proved | one head, 128 x 128 state, T = 4, F = 12, two-limb delta, exact remainder bits, synthetic inputs, research security (target 10 bits, N = 10 combinations), CPU 6 cores, shared loaded VM | [experiments/20261105-32-qwen-deltanet-m1b-lookup/RESULTS.md](experiments/20261105-32-qwen-deltanet-m1b-lookup/RESULTS.md) | | #45 attack classes through the honest cp_prove + cp_verify, small synthetic model, one H100 (PR #51) | measured, unreplicated | honest-prover path: Python score matched all 256 decisions (89 cp_verify accepts, 167 honest-prover refusals, 0 cp_verify rejections); one swap then greedy continuation proved and accepted 15/16 (fp2nd, 95% CI 0.70-1.00) and 13/16 (rand, 0.54-0.96) under the shipped limits; 4 swaps and random replies 0/16; cp_verify's limit check was not exercised on a forged proof | target_small_f2_gap, shipped limits and #45's fit point (both cap 1), one fresh commitment each; #45's seed 2026 held-out replies, 16 per class, one draw; synthetic weights; soundness flag vacuous for a cheating prover | [experiments/20261104-38-swap-at-verifier/RESULTS.md](experiments/20261104-38-swap-at-verifier/RESULTS.md) | +| Signed model manifest checked next to cp_verify, stored small example (PR #59) | measured, unreplicated | 137 of 137 registered tampers refused, 10 of 10 untampered verifies accepted (cp_verify YES), verify about 1.41 s; 14 of 37 fields are the trusted signer's word: 11 always (10 as a one-field change, plus commitment.kind, which a matching seed file passes even with --public-weights-seed) and 3 (upstream, committed files, seed digest) unless the caller passes the rehash flag; small-order ed25519 keys and R refused; symlinks in rehashed dirs, deep nesting and non-true none refused | stored synthetic example, fixture tokenizer/template/system prompt, no real model; client-side detached manifest, pure-Python ed25519 (RFC 8032 3 of 3, 64 of 64 vs cryptography); CPU only, re-measured after PREREG amendment 3 | [experiments/20261104-58-signed-manifest/RESULTS.md](experiments/20261104-58-signed-manifest/RESULTS.md) | | Still open: integer-vs-BF16/FP8 fidelity (#8), KDA model-scale sweep (PR #42), cost teardown and larger-memory GPU test (PR #44) | in progress | none yet | open PRs, issues and lanes, not merged | [docs/research-roadmap.md](docs/research-roadmap.md) | -_Last regenerated from `research/results.yaml` on 2026-11-05 (UTC), against main `0344ae53`._ +_Last regenerated from `research/results.yaml` on 2026-11-05 (UTC), against main `b6186e6c`._ What this table does not show: no full-size (Maverick) run, no real model weights on GPU, no serving, no TEE, no production use, and no comparison with other verifiable-inference systems. The GPU rows are upstream's small example on made-up weights. diff --git a/research/results.yaml b/research/results.yaml index c2a58c38..226aa8de 100644 --- a/research/results.yaml +++ b/research/results.yaml @@ -348,12 +348,12 @@ results: conditions: "one head, 128 x 128 state, T = 4, F = 12, two-limb delta, exact remainder bits, synthetic inputs, research security (target 10 bits, N = 10 combinations), CPU 6 cores, shared loaded VM" evidence: [experiments/20261105-32-qwen-deltanet-m1b-lookup/RESULTS.md] metrics: - - {name: honest_accepted, stat: count, value: 10, total: 10, unit: count, n: 10, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: cc573ae99d359971caf5d96c4934ab28ebbc2175, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl} - - {name: prove_wall, stat: median, value: 39.1845, unit: s, n: 10, spread: {sd: 0.441, min: 38.793, max: 40.085}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: cc573ae99d359971caf5d96c4934ab28ebbc2175, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl, note: "indicative only, shared loaded VM, 6 cores"} - - {name: verify_wall, stat: median, value: 7.148, unit: s, n: 10, spread: {sd: 0.088, min: 7.055, max: 7.315}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: cc573ae99d359971caf5d96c4934ab28ebbc2175, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl} - - {name: proof_bytes, stat: median, value: 25411392, unit: B, n: 10, spread: {sd: 313, min: 25410848, max: 25411968}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: cc573ae99d359971caf5d96c4934ab28ebbc2175, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl} - - {name: hostile_rejected, stat: count, value: 44, total: 44, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: cc573ae99d359971caf5d96c4934ab28ebbc2175, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/hostile-seed1.jsonl, note: "seed 1, 10 sampled classes; not exhaustive"} - - {name: r3_windows_proved, stat: count, value: 5, total: 5, unit: count, n: 5, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: cc573ae99d359971caf5d96c4934ab28ebbc2175, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/h3b-r3.jsonl, note: "T = 4 window ending at the first |delta| >= 2^18 step"} + - {name: honest_accepted, stat: count, value: 10, total: 10, unit: count, n: 10, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: b6186e6ccb687348e3be66fec51bb3756879b8dc, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl} + - {name: prove_wall, stat: median, value: 39.1845, unit: s, n: 10, spread: {sd: 0.441, min: 38.793, max: 40.085}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: b6186e6ccb687348e3be66fec51bb3756879b8dc, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl, note: "indicative only, shared loaded VM, 6 cores"} + - {name: verify_wall, stat: median, value: 7.148, unit: s, n: 10, spread: {sd: 0.088, min: 7.055, max: 7.315}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: b6186e6ccb687348e3be66fec51bb3756879b8dc, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl} + - {name: proof_bytes, stat: median, value: 25411392, unit: B, n: 10, spread: {sd: 313, min: 25410848, max: 25411968}, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: b6186e6ccb687348e3be66fec51bb3756879b8dc, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/honest.jsonl} + - {name: hostile_rejected, stat: count, value: 44, total: 44, unit: count, n: 1, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: b6186e6ccb687348e3be66fec51bb3756879b8dc, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/hostile-seed1.jsonl, note: "seed 1, 10 sampled classes; not exhaustive"} + - {name: r3_windows_proved, stat: count, value: 5, total: 5, unit: count, n: 5, spread: null, hardware: cpu-epyc-rome-kvm-16, date: 2026-11-05, commit: b6186e6ccb687348e3be66fec51bb3756879b8dc, source: experiments/20261105-32-qwen-deltanet-m1b-lookup/runs/h3b-r3.jsonl, note: "T = 4 window ending at the first |delta| >= 2^18 step"} - id: swap-at-verifier title: "#45 attack classes through the honest cp_prove + cp_verify, small synthetic model, one H100 (PR #51)"