diff --git a/CLAIMS.md b/CLAIMS.md index 02290de1..987e17c0 100644 --- a/CLAIMS.md +++ b/CLAIMS.md @@ -30,4 +30,5 @@ tracked file, which both scripts resolve (they check that it exists, not what it | `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 | | `qwen-deltanet-m1b-lookup` | Qwen Gated DeltaNet head with the RNG/LKP range check, M1b (PR #63): 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 | measured, unreplicated | unreplicated | [experiments/20261105-32-qwen-deltanet-m1b-lookup/RESULTS.md](experiments/20261105-32-qwen-deltanet-m1b-lookup/RESULTS.md) | #63 | | `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; 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 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/experiments/20261104-58-signed-manifest/ENV.json b/experiments/20261104-58-signed-manifest/ENV.json new file mode 100644 index 00000000..2f990093 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/ENV.json @@ -0,0 +1,41 @@ +{ + "commits": { + "base": "eb6e6ea379db87b3bd34c6377414422ed6a236dd", + "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", + "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 3 (2026-11-05 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 new file mode 100644 index 00000000..56905082 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/PREREG.md @@ -0,0 +1,86 @@ +# 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 + +- 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-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. + 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. + +- 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/RESULTS.md b/experiments/20261104-58-signed-manifest/RESULTS.md new file mode 100644 index 00000000..8b7e6fb2 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/RESULTS.md @@ -0,0 +1,135 @@ +# 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` (61 passed). + +**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 | | +|---|---|---|---| +| 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.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 +`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. + +## 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 (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` (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` | +| 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 +**`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 +(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. + +## Omission (amendment 2, no threshold) + +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. + +## 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 + 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. 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 (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 new file mode 100644 index 00000000..cd6678d7 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/numbers.json @@ -0,0 +1,1478 @@ +{ + "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.4708434715867043 + }, + { + "i": 1, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4079530201852322 + }, + { + "i": 2, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.3795696422457695 + }, + { + "i": 3, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.41568049415946 + }, + { + "i": 4, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4066016040742397 + }, + { + "i": 5, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.3835452757775784 + }, + { + "i": 6, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4110643826425076 + }, + { + "i": 7, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.400022517889738 + }, + { + "i": 8, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.382567085325718 + }, + { + "i": 9, + "exit": 0, + "last": "manifest: ACCEPTED id=79a5a2bc7a469b40df73dedf40abeb740671e8ae8077738bd3743897aeb22183 signer=test signer (fixture key)", + "wall_s": 1.4216599352657795 + } + ], + "coordinated": [ + { + "alternate": "kind hiding->public-weights + made-up seed digest", + "covers": [ + "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": [ + "conversion.public_weights_seed_sha256" + ], + "flag": [ + "--public-weights-seed" + ], + "exit": 1, + "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", + "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", + "expected_exit": 1 + }, + { + "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", + "expected_exit": 1 + }, + { + "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", + "expected_exit": 1 + }, + { + "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": { + "matched": 3, + "total": 3 + }, + "cross_check": { + "agree": 64, + "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 + }, + "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.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" + }, + "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" + ], + "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": 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" + ], + "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": [ + "kind hiding->public-weights + digest of a seed file the signer ships" + ], + "signers_word_unless_flag_fields": [ + "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": { + "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 new file mode 100644 index 00000000..0b999198 --- /dev/null +++ b/experiments/20261104-58-signed-manifest/run.py @@ -0,0 +1,281 @@ +#!/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": [], "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): + 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}) + # 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() + # (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, + "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, + "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), + ("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}) + + # 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 + 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)} + # 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 + 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"]] + 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"]), + "verify_wall_s": {"median": statistics.median(walls), "sd": statistics.stdev(walls), + "min": min(walls), "max": max(walls), "n": len(walls), + "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), + "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 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") + print(json.dumps(out["summary"], indent=1)) + print(json.dumps({"rfc8032": out["rfc8032"], "cross_check": out["cross_check"], + "small_order": out["small_order"]})) + + +if __name__ == "__main__": + main() diff --git a/research/results.yaml b/research/results.yaml index 416211ba..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)" @@ -375,6 +375,22 @@ 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; 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-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)" status: in progress diff --git a/research/security/DESIGN-VAST-ABLITERATED.md b/research/security/DESIGN-VAST-ABLITERATED.md new file mode 100644 index 00000000..9067cb3c --- /dev/null +++ b/research/security/DESIGN-VAST-ABLITERATED.md @@ -0,0 +1,309 @@ +# 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 (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`. + +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. (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 + 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. + +### 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** 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`. +- **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. 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 +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. Without that rerun, the client can +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 +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 `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 + (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 (#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 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. 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 + +- **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..6f1b0222 --- /dev/null +++ b/tools/manifest/README.md @@ -0,0 +1,74 @@ +# 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` | 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` 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`; 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) | + +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-`, `--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 `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 +with it on edge-case signatures; honest RFC 8032 signatures verify under both. + +## 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 | --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 +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. 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 (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 + +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. diff --git a/tools/manifest/ed25519.py b/tools/manifest/ed25519.py new file mode 100644 index 00000000..23cb7375 --- /dev/null +++ b/tools/manifest/ed25519.py @@ -0,0 +1,141 @@ +# 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 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. + 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 or is_small_order(big_a): + return False + big_r = _decompress(signature[:32]) + if big_r is None or is_small_order(big_r): + 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..3d5455bb --- /dev/null +++ b/tools/manifest/manifest.py @@ -0,0 +1,455 @@ +#!/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. +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). +""" + +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 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 is_none(obj): + 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 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") + _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")) + same = canonical(m) == manifest_bytes + except (UnicodeDecodeError, ValueError) as e: + raise Refusal("manifest: not ASCII JSON (%s)" % e) + 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: + 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): + """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 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"])) + 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 is_none(s["tokenizer"]): + 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 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("_", "-"))) + 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 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: + 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", 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") + + +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..0edb4861 --- /dev/null +++ b/tools/manifest/tamper.py @@ -0,0 +1,252 @@ +# 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) + + +# ---- 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, 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). 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 + 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", + ["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", + "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)}, 1) + + b2 = copy.deepcopy(b) + yield ("same, caller passes --upstream-dir only", ["upstream.kind"], b2, + {"--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)}, 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, 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, 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, None) + g = copy.deepcopy(m) + g["serving"]["tokenizer"] = {"none": True} + yield ("serving.tokenizer files->none", ["serving.tokenizer"], g, None, None) diff --git a/tools/manifest/test_manifest.py b/tools/manifest/test_manifest.py new file mode 100644 index 00000000..a7ae0490 --- /dev/null +++ b/tools/manifest/test_manifest.py @@ -0,0 +1,524 @@ +# 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_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; + 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. +""" + +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)") + +# 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", + "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", +} +# 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): 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 = { + "conversion.public_weights_seed_sha256", + "upstream.kind", + "conversion.committed_files", +} +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", + "conversion.public_weights_seed_sha256", "decode_policy.kind", "decode_policy.C", + "decode_policy.theta", "decode_policy.cap", "decode_policy.sT", +} + + +@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 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 ---- + + +@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() + schema_only = 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) + 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, or (amendment 3) accepted with it too. Fields with no such + flag must refuse without one.""" + tmp, m, recipe = fx + 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) == 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 +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 +@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 + + +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 + 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)