mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
quorum: pacta-verify-slhdsa — the SLH-DSA head-checker built from the proven source
Fifth quorum member, first post-quantum one: verifies an SLH-DSA-SHA2-128s signature by calling slh_verify_128s, the extraction root the eleven fips205 certificates cover (apex fips205.slh_verify_128s_accepts_iff). Verify-only like the other four: quorum members judge, they never sign. Build discipline, because "built from the proven source" is a claim that has to survive a hostile reader: build-verify-slhdsa.sh REFUSES to build if the pinned checkout is dirty or at any commit other than a3ce8e8, exports the pinned commit via git archive (never a working copy), applies expose-mono.patch to that scratch copy, and then DIFFS the patched verify_mono.rs against the pinned one, aborting if any existing line changed rather than being appended. The patch is a visibility keyword plus its doc comment (the crate denies missing_docs, so pub mod alone does not compile) and one appended argument-assembly function whose body is the crate's own test helper. The extraction root is provably untouched. A provenance sidecar lands beside the binary: source commit, patch hash, main.rs hash, rustc, and a not_covered field naming what no certificate reaches — M-prime assembly (including the pure/prehash domain-separator byte), hex/file IO, the compiler; signing and keygen out of scope entirely. Demonstrated against OpenSSL 3.5.5 on a throwaway key: valid signature OK both ways, wrong message INVALID, corrupted signature INVALID. The agreement is itself a finding — this binary assembles M' = 0x00 || 0x00 || payload (pure variant, empty context) and OpenSSL evidently does the same. Convention matches the other members: template + main.rs + patch + build script tracked; rendered Cargo.toml, lock, target/ and the .build-slhdsa scratch tree ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5e35a533e1
commit
16040b79f5
5 changed files with 268 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,3 +23,6 @@ dogfood/quorum/*/Cargo.lock
|
||||||
dogfood/quorum/*/target/
|
dogfood/quorum/*/target/
|
||||||
dogfood/state/quorum/
|
dogfood/state/quorum/
|
||||||
paper/eprint-submission.md
|
paper/eprint-submission.md
|
||||||
|
dogfood/quorum/.build-slhdsa/
|
||||||
|
dogfood/quorum/verify-slhdsa/Cargo.toml
|
||||||
|
dogfood/quorum/verify-slhdsa/Cargo.lock
|
||||||
|
|
|
||||||
102
dogfood/quorum/build-verify-slhdsa.sh
Executable file
102
dogfood/quorum/build-verify-slhdsa.sh
Executable file
|
|
@ -0,0 +1,102 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build pacta-verify-slhdsa from the PINNED proven source.
|
||||||
|
#
|
||||||
|
# The pinned checkout is never modified. This script exports the pinned commit
|
||||||
|
# into a scratch tree, applies expose-mono.patch there, builds against that, and
|
||||||
|
# records exactly what went in. If the pinned checkout is dirty, or is not at
|
||||||
|
# the commit the attestation names, it refuses: a quorum member built from a
|
||||||
|
# tree nobody can identify is a quorum member that proves nothing.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SRC="${FIPS205_SOURCE:-$HOME/GitClone/FormalVerification/sources/fips205-source}"
|
||||||
|
PIN="${FIPS205_PIN:-a3ce8e8}"
|
||||||
|
BUILD="${BUILD_DIR:-$HERE/.build-slhdsa}"
|
||||||
|
OUT="$HERE/verify-slhdsa/target/release/pacta-verify-slhdsa"
|
||||||
|
|
||||||
|
echo "=== pacta-verify-slhdsa: build from the proven source ==="
|
||||||
|
|
||||||
|
[ -d "$SRC/.git" ] || { echo "FATAL: '$SRC' is not a git checkout of fips205-source."; exit 2; }
|
||||||
|
HEAD_SHA="$(git -C "$SRC" rev-parse HEAD)"
|
||||||
|
case "$HEAD_SHA" in
|
||||||
|
"$PIN"*) ;;
|
||||||
|
*) echo "FATAL: pinned source is at ${HEAD_SHA:0:8}, expected $PIN."
|
||||||
|
echo " The certificates cover $PIN. Building a 'proven' verifier from any"
|
||||||
|
echo " other tree would be a claim nobody can check."; exit 1;;
|
||||||
|
esac
|
||||||
|
if [ -n "$(git -C "$SRC" status --porcelain)" ]; then
|
||||||
|
echo "FATAL: the pinned source has uncommitted changes:"
|
||||||
|
git -C "$SRC" status --porcelain | sed 's/^/ /'
|
||||||
|
echo " Refusing: the binary must correspond to a nameable tree."; exit 1
|
||||||
|
fi
|
||||||
|
echo " pinned source $SRC @ ${HEAD_SHA:0:8} (clean)"
|
||||||
|
|
||||||
|
# Export the pinned commit, never a working copy.
|
||||||
|
rm -rf "$BUILD"; mkdir -p "$BUILD"
|
||||||
|
git -C "$SRC" archive --format=tar "$HEAD_SHA" | tar -x -C "$BUILD"
|
||||||
|
echo " exported $(find "$BUILD" -type f | wc -l) files from $PIN"
|
||||||
|
|
||||||
|
# --- the two changes, applied verbatim and then VERIFIED to be present -------
|
||||||
|
LIB="$BUILD/src/lib.rs"; VM="$BUILD/src/verify_mono.rs"
|
||||||
|
grep -q '^mod verify_mono;' "$LIB" || { echo "FATAL: 'mod verify_mono;' not found in lib.rs — the source moved."; exit 1; }
|
||||||
|
# The crate is `#![deny(missing_docs)]`, so a module cannot become public
|
||||||
|
# without a doc comment. The comment is part of the visibility change, not an
|
||||||
|
# extra edit: `pub mod` alone does not compile here.
|
||||||
|
sed -i 's|^mod verify_mono;|/// Aeneas-compat monomorphic verify path: the extraction root the eleven\n/// certificates cover (apex `fips205.slh_verify_128s_accepts_iff`). Public only\n/// so a quorum binary can call the proven function; see expose-mono.patch.\npub mod verify_mono;|' "$LIB"
|
||||||
|
|
||||||
|
cat >> "$VM" <<'RUST'
|
||||||
|
|
||||||
|
/// Byte-level entry to the PROVEN root, for out-of-crate callers.
|
||||||
|
///
|
||||||
|
/// Assembles arguments only; the body is the crate's own test helper
|
||||||
|
/// `internal_inputs` followed by the call. `mprime` is FIPS 205's M' and is
|
||||||
|
/// built by the CALLER — its construction is outside every certificate
|
||||||
|
/// (TRUSTED-BASE item 10), which is why it is a parameter and not computed
|
||||||
|
/// here.
|
||||||
|
pub fn verify_mono_bytes(mprime: &[u8], sig_bytes: &[u8; 7856], pk_bytes: &[u8; 32]) -> bool {
|
||||||
|
let mut pk_seed = [0u8; 16];
|
||||||
|
let mut pk_root = [0u8; 16];
|
||||||
|
pk_seed.copy_from_slice(&pk_bytes[0..16]);
|
||||||
|
pk_root.copy_from_slice(&pk_bytes[16..32]);
|
||||||
|
let pk = SlhPublicKey { pk_seed, pk_root };
|
||||||
|
let sig = SlhDsaSig::<12, 7, 9, 14, 35, 16>::deserialize(sig_bytes);
|
||||||
|
slh_verify_128s(mprime, &sig, &pk)
|
||||||
|
}
|
||||||
|
RUST
|
||||||
|
|
||||||
|
# The extraction root must be untouched. Compare it against the pinned tree.
|
||||||
|
if ! diff <(git -C "$SRC" show "$HEAD_SHA:src/verify_mono.rs") \
|
||||||
|
<(head -n "$(git -C "$SRC" show "$HEAD_SHA:src/verify_mono.rs" | wc -l)" "$VM") > /dev/null; then
|
||||||
|
echo "FATAL: the patch altered existing lines of verify_mono.rs, not just appended."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " patched lib.rs visibility + verify_mono_bytes appended (existing lines unchanged)"
|
||||||
|
|
||||||
|
# --- render Cargo.toml from the template ------------------------------------
|
||||||
|
sed "s|{{SOURCE}}|$BUILD|g" "$HERE/verify-slhdsa/Cargo.toml.template" > "$HERE/verify-slhdsa/Cargo.toml"
|
||||||
|
|
||||||
|
echo " building..."
|
||||||
|
( cd "$HERE/verify-slhdsa" && cargo build --release 2>&1 | tail -5 | sed 's/^/ /' )
|
||||||
|
|
||||||
|
[ -x "$OUT" ] || { echo "FATAL: build produced no binary at $OUT"; exit 1; }
|
||||||
|
|
||||||
|
cat > "$HERE/verify-slhdsa/target/release/pacta-verify-slhdsa.provenance.json" <<JSON
|
||||||
|
{
|
||||||
|
"binary_sha256": "$(sha256sum "$OUT" | cut -d' ' -f1)",
|
||||||
|
"source_repo": "fips205-source",
|
||||||
|
"source_commit": "$HEAD_SHA",
|
||||||
|
"patch": "expose-mono.patch",
|
||||||
|
"patch_sha256": "$(sha256sum "$HERE/verify-slhdsa/expose-mono.patch" | cut -d' ' -f1)",
|
||||||
|
"main_sha256": "$(sha256sum "$HERE/verify-slhdsa/src/main.rs" | cut -d' ' -f1)",
|
||||||
|
"proven_root": "slh_verify_128s",
|
||||||
|
"parameter_set": "SLH-DSA-SHA2-128s",
|
||||||
|
"certificates": 11,
|
||||||
|
"apex": "fips205.slh_verify_128s_accepts_iff",
|
||||||
|
"not_covered": "M-prime assembly (domain separator, context length), hex/file IO, and the compiler. Signing and keygen are out of scope entirely.",
|
||||||
|
"rustc": "$(rustc --version)"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
echo " binary $OUT"
|
||||||
|
echo " sha256 $(sha256sum "$OUT" | cut -c1-16)…"
|
||||||
|
echo " provenance written beside the binary"
|
||||||
13
dogfood/quorum/verify-slhdsa/Cargo.toml.template
Normal file
13
dogfood/quorum/verify-slhdsa/Cargo.toml.template
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Rendered by build-verify-slhdsa.sh — {{SOURCE}} is replaced with the exported
|
||||||
|
# copy of the PINNED proven source plus expose-mono.patch. Committed as a
|
||||||
|
# template so the repo never hardcodes a machine path.
|
||||||
|
[package]
|
||||||
|
name = "pacta-verify-slhdsa"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fips205 = { path = "{{SOURCE}}", default-features = false, features = ["slh_dsa_sha2_128s"] }
|
||||||
|
|
||||||
|
[workspace]
|
||||||
49
dogfood/quorum/verify-slhdsa/expose-mono.patch
Normal file
49
dogfood/quorum/verify-slhdsa/expose-mono.patch
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
Expose the proven verify root so a quorum binary can call it.
|
||||||
|
|
||||||
|
APPLIED TO A COPY of fips205-source at the pinned commit, never to the pinned
|
||||||
|
checkout itself. Two hunks, and the reason each is the smallest possible:
|
||||||
|
|
||||||
|
1. `mod verify_mono;` -> `pub mod verify_mono;`
|
||||||
|
A visibility keyword. Rust's `src/bin/` and `examples/` targets are
|
||||||
|
SEPARATE crates, so neither can reach a `pub(crate)` item; the module has
|
||||||
|
to be public for any binary to call into it at all.
|
||||||
|
|
||||||
|
2. A new `verify_mono_bytes` function, appended.
|
||||||
|
It only assembles arguments: split the 32-byte public key into pk_seed and
|
||||||
|
pk_root, deserialize the 7856-byte signature, call `slh_verify_128s`. The
|
||||||
|
body is copied from the crate's OWN test helper `internal_inputs`, so the
|
||||||
|
conversion is the one the crate already trusts rather than one invented
|
||||||
|
here. Exposing the argument types and their fields instead would have
|
||||||
|
meant four more visibility changes across two files.
|
||||||
|
|
||||||
|
WHAT THIS DOES NOT CHANGE. No existing line's semantics. The extraction root
|
||||||
|
`slh_verify_128s` is untouched -- same body, same callees. Module visibility and
|
||||||
|
an added sibling function do not alter the MIR of an existing function, so the
|
||||||
|
code the certificates cover compiles to what it compiled to before. What IS
|
||||||
|
true and must be said: the binary is built from `pinned commit + this patch`,
|
||||||
|
not from the pinned commit alone, and the diff below is the whole of the
|
||||||
|
difference.
|
||||||
|
|
||||||
|
--- a/src/lib.rs
|
||||||
|
+++ b/src/lib.rs
|
||||||
|
@@
|
||||||
|
-mod verify_mono; // Aeneas-compat monomorphic verify path (formal-verification campaign; additive)
|
||||||
|
+pub mod verify_mono; // Aeneas-compat monomorphic verify path (formal-verification campaign; additive)
|
||||||
|
|
||||||
|
--- a/src/verify_mono.rs
|
||||||
|
+++ b/src/verify_mono.rs
|
||||||
|
@@ (appended after slh_verify_128s)
|
||||||
|
+/// Byte-level entry to the PROVEN root, for out-of-crate callers.
|
||||||
|
+///
|
||||||
|
+/// Assembles arguments only. `mprime` is FIPS 205's M' and is built by the
|
||||||
|
+/// CALLER -- its construction is outside every certificate (TRUSTED-BASE item
|
||||||
|
+/// 10), which is exactly why it is a parameter here and not computed inside.
|
||||||
|
+pub fn verify_mono_bytes(mprime: &[u8], sig_bytes: &[u8; 7856], pk_bytes: &[u8; 32]) -> bool {
|
||||||
|
+ let mut pk_seed = [0u8; 16];
|
||||||
|
+ let mut pk_root = [0u8; 16];
|
||||||
|
+ pk_seed.copy_from_slice(&pk_bytes[0..16]);
|
||||||
|
+ pk_root.copy_from_slice(&pk_bytes[16..32]);
|
||||||
|
+ let pk = SlhPublicKey { pk_seed, pk_root };
|
||||||
|
+ let sig = SlhDsaSig::<12, 7, 9, 14, 35, 16>::deserialize(sig_bytes);
|
||||||
|
+ slh_verify_128s(mprime, &sig, &pk)
|
||||||
|
+}
|
||||||
101
dogfood/quorum/verify-slhdsa/src/main.rs
Normal file
101
dogfood/quorum/verify-slhdsa/src/main.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
//! warden quorum member: SLH-DSA-SHA2-128s, the Lean-proven verify path.
|
||||||
|
//!
|
||||||
|
//! Built against a copy of the PINNED proven source (`fips205-source` at the
|
||||||
|
//! commit named in the build provenance sidecar) plus `expose-mono.patch`,
|
||||||
|
//! which adds a visibility keyword and an argument-assembly function and
|
||||||
|
//! changes no existing line's semantics.
|
||||||
|
//!
|
||||||
|
//! The function this calls, `slh_verify_128s`, is the extraction root the
|
||||||
|
//! eleven certificates cover, apex `fips205.slh_verify_128s_accepts_iff`.
|
||||||
|
//! Verify-only on purpose: quorum members judge, they never sign.
|
||||||
|
//!
|
||||||
|
//! TWO THINGS THIS BINARY DOES THAT NO CERTIFICATE COVERS, stated here because
|
||||||
|
//! a reader of the output cannot see them:
|
||||||
|
//!
|
||||||
|
//! * It assembles M'. FIPS 205 hashes M' = toByte(0,1) ‖ toByte(|ctx|,1) ‖
|
||||||
|
//! ctx ‖ M, and Algorithm 20's input is already M'. Everything above the
|
||||||
|
//! extraction root -- including that leading domain-separator byte, the one
|
||||||
|
//! thing distinguishing the pure variant from prehash -- is outside every
|
||||||
|
//! proof (TRUSTED-BASE item 10). This binary implements the PURE variant
|
||||||
|
//! with EMPTY context, i.e. M' = 0x00 ‖ 0x00 ‖ payload, and refuses to
|
||||||
|
//! guess at anything else.
|
||||||
|
//! * It parses hex and reads a file.
|
||||||
|
//!
|
||||||
|
//! Usage: <pubkey-hex-32B> <sig-hex-7856B> <payload-file>
|
||||||
|
//! stdout OK / INVALID; exit 0 = accept, 1 = reject, 2 = input error.
|
||||||
|
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
const SIG_LEN: usize = 7856;
|
||||||
|
const PK_LEN: usize = 32;
|
||||||
|
|
||||||
|
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||||
|
if s.len() % 2 != 0 {
|
||||||
|
return Err("odd-length hex".into());
|
||||||
|
}
|
||||||
|
(0..s.len() / 2)
|
||||||
|
.map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).map_err(|e| e.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() != 4 {
|
||||||
|
eprintln!("usage: {} <pubkey-hex-32B> <sig-hex-7856B> <payload-file>", args[0]);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pk_bytes = match hex_decode(&args[1]) {
|
||||||
|
Ok(b) if b.len() == PK_LEN => b,
|
||||||
|
Ok(b) => {
|
||||||
|
eprintln!("error: public key must be {PK_LEN} bytes, got {}", b.len());
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: public key hex: {e}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let sig_bytes = match hex_decode(&args[2]) {
|
||||||
|
Ok(b) if b.len() == SIG_LEN => b,
|
||||||
|
Ok(b) => {
|
||||||
|
// Size is part of the parameter set. A 7856-byte signature is
|
||||||
|
// SLH-DSA-SHA2-128s; anything else is a DIFFERENT parameter set and
|
||||||
|
// outside every certificate this binary exists to exercise. Refuse
|
||||||
|
// rather than attempt it.
|
||||||
|
eprintln!("error: signature must be {SIG_LEN} bytes (SLH-DSA-SHA2-128s), got {}", b.len());
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: signature hex: {e}");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let payload = match std::fs::read(&args[3]) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: cannot read payload file {}: {e}", args[3]);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// M' for the PURE variant with empty context: two length/domain bytes then
|
||||||
|
// the message. Built here, not proven anywhere.
|
||||||
|
let mut mprime = Vec::with_capacity(payload.len() + 2);
|
||||||
|
mprime.push(0u8); // domain separator: 0 = pure, 1 = prehash
|
||||||
|
mprime.push(0u8); // |ctx| = 0
|
||||||
|
mprime.extend_from_slice(&payload);
|
||||||
|
|
||||||
|
let mut sig_arr = [0u8; SIG_LEN];
|
||||||
|
sig_arr.copy_from_slice(&sig_bytes);
|
||||||
|
let mut pk_arr = [0u8; PK_LEN];
|
||||||
|
pk_arr.copy_from_slice(&pk_bytes);
|
||||||
|
|
||||||
|
if fips205::verify_mono::verify_mono_bytes(&mprime, &sig_arr, &pk_arr) {
|
||||||
|
println!("OK");
|
||||||
|
ExitCode::from(0)
|
||||||
|
} else {
|
||||||
|
println!("INVALID");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue