Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions chain/crates/shielded-pq/src/carrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,4 +311,29 @@ mod tests {
d2[0] ^= 1;
assert_ne!(carrier_sighash(&d, &a), carrier_sighash(&d2, &a));
}

#[test]
fn carrier_sighash_byte_kat_pinned() {
// BYTE-EXACT KAT (audit PQV2-08). Every other carrier test re-derives
// the sighash through this same code, so they are self-consistent: a
// silent change to the preimage layout, the scheme byte, or the
// `B3_CARRIER_BINDING` domain separator would keep them ALL green while
// changing the actual authorized message. This pins the known answer
// for a fully-fixed input, so any such change screams here.
//
// The scheme byte is part of the preimage, so its value is pinned too:
assert_eq!(SCHEME_DOMAIN_SIGNER_NONCE, 2, "carrier scheme byte moved");
let digest = [0x11u8; 32];
let ctx = CarrierContext {
chain_id: b"sov-mainnet",
genesis: &[0xAA; 32],
signer: b"usa.reserve.sov",
nonce: 4,
};
assert_eq!(
hex::encode(carrier_sighash(&digest, &ctx)),
"22eba1d2409986def8fdc3f32fb4a2b848626948638447cd72043f749d2ef042",
"carrier sighash KAT drifted — the network/carrier binding preimage changed"
);
}
}
59 changes: 51 additions & 8 deletions chain/crates/shielded-pq/src/prover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ pub enum SpendProofError {
Verification(String),
}

/// FRI query count. 64 (not the previous 42) is load-bearing: with the cubic
/// extension it is what reaches **128 proven** bits (see the table below).
pub const FRI_NUM_QUERIES: usize = 64;
/// LDE blowup factor. 16 (not the previous 8); must stay a power of two.
pub const FRI_BLOWUP_FACTOR: usize = 16;
/// Proof-of-work grinding bits folded into the FRI transcript.
pub const FRI_GRINDING_BITS: u32 = 16;
/// Degree of the extension field the constraints are checked over. **3 —
/// cubic**, not the previous quadratic (2): over 64-bit Goldilocks the proven
/// figure saturates at ~86 bits under a quadratic extension no matter the
/// query budget, so the cubic degree is what lifts the ceiling to 128.
pub const FRI_EXTENSION_DEGREE: u32 = 3;

// --- Build-time regression guards (audit PQV2-08). ---------------------------
// These are the security-critical proof parameters. A silent edit to any of
// them would move the proven security level (128 -> as low as 75) with NO
// consensus-visible signal while the pool is dormant. Pinning each to a
// *separately stated* expectation makes a one-line change fail the BUILD, not
// just a test nobody ran — the same structural-guard discipline as the
// PQV2-03 anchor-ring floor in `state.rs`. If you intend to change one, change
// its pin here too and re-run `tests/security_level.rs` to re-derive the
// proven bits before trusting the new number.
const _: () = assert!(FRI_NUM_QUERIES == 64, "FRI query count moved off 64");
const _: () = assert!(FRI_BLOWUP_FACTOR == 16, "FRI blowup moved off 16");
const _: () = assert!(
FRI_BLOWUP_FACTOR.is_power_of_two(),
"winterfell requires a power-of-two blowup"
);
const _: () = assert!(FRI_GRINDING_BITS == 16, "grinding bits moved off 16");
const _: () = assert!(
FRI_EXTENSION_DEGREE == 3,
"extension degree moved off cubic — proven security regresses to <=86 bits"
);

/// Standard proof options: 64 FRI queries, blowup 16, 16 bits of grinding,
/// **cubic** extension field.
///
Expand All @@ -69,8 +103,13 @@ pub enum SpendProofError {
///
/// | parameters | conjectured (classical) | proven (classical) | proof |
/// |---|---|---|---|
/// | 42q / blowup 8 / quadratic (previous) | 127 | **75** | 53.8 KB |
/// | 64q / blowup 16 / cubic (these) | 128 | **128** | 94.3 KB |
/// | 42q / blowup 8 / quadratic (previous) | 127 | **75** | 54.3 KB |
/// | 64q / blowup 16 / cubic (these) | 128 | **128** | 96.2 KB |
///
/// (The 96.2 KB = 98494-byte figure for the current set is the one
/// `tests/verify_cost.rs` measures and `tests/kat.rs::kat_proof_size_pinned`
/// pins byte-exactly; it is deterministic given these options + the KAT
/// witness.)
///
/// The previous set quoted 127 bits, but that was the *capacity-conjecture*
/// figure; unconditionally it was 75 bits (50 under the unique-decoding
Expand All @@ -84,8 +123,8 @@ pub enum SpendProofError {
/// field, not the number of queries. Goldilocks is a 64-bit base field, and a
/// cubic extension is what lifts the ceiling.
///
/// The cost is honest and bounded: 1.75x proof size (53.8 KB -> 94.3 KB, a
/// full bundle ~62 KB -> ~101 KB) for 75 -> 128 proven bits. That stays well
/// The cost is honest and bounded: ~1.75x proof size (54.3 KB -> 96.2 KB, a
/// full bundle ~62 KB -> ~103 KB) for 75 -> 128 proven bits. That stays well
/// inside `MAX_SHIELDED_V2_BUNDLE_BYTES` (144 KiB), which the weight schedule
/// is already sized against.
///
Expand Down Expand Up @@ -114,11 +153,15 @@ pub enum SpendProofError {
/// a quantified post-quantum soundness level; the QROM caveat above stands
/// until an external analysis closes it.
pub fn proof_options() -> ProofOptions {
// The extension enum is not const-constructible; assert it matches the
// pinned degree so the const guards above actually govern the shipped call.
let ext = FieldExtension::Cubic;
debug_assert_eq!(ext.degree(), FRI_EXTENSION_DEGREE);
ProofOptions::new(
64,
16,
16,
FieldExtension::Cubic,
FRI_NUM_QUERIES,
FRI_BLOWUP_FACTOR,
FRI_GRINDING_BITS,
ext,
4,
31,
BatchingMethod::Linear,
Expand Down
26 changes: 26 additions & 0 deletions chain/crates/shielded-pq/tests/kat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,32 @@ fn kat_bundle_proof_verifies() {
verify_spend(&proof, &pub_inputs).expect("KAT bundle proof must verify");
}

#[test]
fn kat_proof_size_pinned() {
// PROOF-SIZE KAT (audit PQV2-08). A STARK proof is deterministic given the
// proof options + witness (Fiat-Shamir, fixed Blake3 hasher, no RNG), so
// the serialized length of the KAT bundle's proof is a stable known answer,
// NOT a machine- or run-dependent number. `verify_cost.rs` long claimed the
// exact size was "pinned by the KAT suite" — but no such pin existed, so a
// parameter or winterfell-version change could move the proof size (and thus
// the weight/gas derivation) unnoticed. This is that pin.
//
// 98494 bytes = 96.2 KiB, measured for the shipped 64q / blowup 16 / cubic
// options. It last moved with the 42q/8/quadratic -> 64q/16/cubic evolution
// (audit PQV2-08) and the PQV2-01 trace-width 31 -> 32 change. A drift here
// that is NOT a deliberate parameter/witness change is a bug: re-derive the
// weight schedule in `sov_types::weight` before re-pinning.
let (proof, _) = kat_prove();
assert_eq!(
proof.len(),
98_494,
"pool-v2 KAT proof size drifted — proof_options() or the witness shape changed"
);
// And it must fit the wire codec with real margin (the weight schedule
// relies on this).
assert!(proof.len() <= sov_shielded_pq::MAX_PROOF_LEN);
}

#[test]
fn full_4in_4out_with_distinct_anchors_verifies() {
// D5: inputs in ONE bundle may be witnessed against DIFFERENT anchors.
Expand Down
32 changes: 26 additions & 6 deletions chain/crates/shielded-pq/tests/security_level.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,36 @@ fn report_conjectured_and_proven_security_bits() {
let conjectured = proof.conjectured_security::<Blake3_256<BaseElement>>();
let proven = proof.proven_security::<Blake3_256<BaseElement>>();

println!("--- pool-v2 proof security (42 queries, blowup 8, 16 grinding, quadratic ext)");
// These are the SHIPPED options (`prover::proof_options`): 64 queries,
// blowup 16, 16 grinding bits, cubic extension. The label previously read
// "42 queries, blowup 8, quadratic" — the OLD set — while the code below
// measured the new one (audit PQV2-08: the label had drifted off the
// parameters actually shipped).
println!("--- pool-v2 proof security (64 queries, blowup 16, 16 grinding, cubic ext)");
println!("conjectured : {} bits", conjectured.bits());
println!("proven (Johnson/ldr): {} bits", proven.ldr_bits());
println!("proven (unique dec) : {} bits", proven.udr_bits());

// No assertion on the numbers themselves: this test exists to REPORT them
// honestly, and the derived figures are written down in
// `chain/docs/pq-shielded-soundness.md`. Asserting a target here would let
// a parameter change silently move the goalposts instead of failing the
// documented claim.
// Regression FLOOR (audit PQV2-08). The whole reason the parameters moved
// 42q/8/quadratic -> 64q/16/cubic was to raise PROVEN security 75 -> 128
// bits. Previously this test asserted NOTHING, so a silent revert of the
// options would drop proven security to 75 with no failing test. A floor
// (>=, not ==) does not "move the goalposts": it can only catch a
// downward regression, never bless a change. Measured today: conjectured
// 128, proven (ldr) 128. The build-time `const _` guards in `prover.rs`
// pin the parameters themselves; this pins the security level they buy.
assert!(
conjectured.bits() >= 128,
"conjectured security regressed below 128 bits: {} — did proof_options() change?",
conjectured.bits()
);
assert!(
proven.ldr_bits() >= 128,
"PROVEN (Johnson/ldr) security regressed below 128 bits: {} — the 64q/16/cubic \
parameter set exists specifically to reach 128 proven; a drop means proof_options() \
regressed toward the old 42q/8/quadratic set (75 proven)",
proven.ldr_bits()
);
}

/// What parameter set reaches a PROVEN target?
Expand Down
6 changes: 4 additions & 2 deletions chain/crates/shielded-pq/tests/verify_cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,10 @@ fn measure_v2_proof_size_and_verify_cost() {
println!("v2 verify samples (us) = {samples_us:?}");

// The weight derivation relies on the proof fitting the wire codec's
// 128 KiB `MAX_PROOF_LEN`, with real margin. Assert the bound, not the
// exact size (the exact size is pinned by the KAT suite).
// 128 KiB `MAX_PROOF_LEN`, with real margin. Assert the bound here; the
// exact size is pinned byte-for-byte by `kat.rs::kat_proof_size_pinned`
// (that KAT was added in audit PQV2-08 — this comment used to claim a pin
// that did not exist).
assert!(
proof.len() <= sov_shielded_pq::MAX_PROOF_LEN,
"proof {} exceeds MAX_PROOF_LEN {}",
Expand Down
61 changes: 61 additions & 0 deletions notes/pq-pool-v2-audit-response.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,67 @@ reproduce unchanged. Bit 2 is UNARMED on every canonical chain.
**Verification (real output).** `cargo build --workspace` green (documentation/
comment-only changes; see the PR body / agent report for the tail). `cargo fmt
--check` clean on the touched crate.
## PQV2-08 — regression checks have drifted, resolution (2026-07-28) — **FIXED**

*The finding.* The pool-v2 security/performance regression guards had drifted off
the parameters actually shipped, and several security-critical surfaces had no
regression pin at all. A silent parameter change could therefore regress proven
security (128 → as low as 75 bits) or the proof size / weight derivation with **no
failing test** while the pool is dormant.

*Drift inventory and evidence (each verified by running the tests, not guessed):*

1. **Mislabeled security report** (`tests/security_level.rs`). The report line read
`"42 queries, blowup 8, 16 grinding, quadratic ext"` while the code below it
measured the SHIPPED options via `proof_options()`. Evidence: the measured
output is `conjectured 128 / proven(ldr) 128`, which is the **64q / blowup 16 /
cubic** row of the sweep — the quadratic/8/42 set measures 127/75. The label
described the OLD set. → relabeled to the real parameters.

2. **No floor pin on the security level** (`security_level.rs`). The reporting test
asserted *nothing* ("asserting a target would move the goalposts"). That reasoning
is wrong for a *floor*: the entire 42q→64q / quadratic→cubic evolution existed to
raise **proven** security 75→128. A silent revert would drop proven security to 75
with every test still green. → added `assert!(conjectured.bits() >= 128)` and
`assert!(proven.ldr_bits() >= 128)` (measured today: 128 / 128). A `>=` floor can
only catch a downward regression, never bless a change.

3. **Proof parameters not build-time-guarded** (`src/prover.rs`). `proof_options()`
inlined `64, 16, 16, Cubic` as bare literals — a runtime-only fact. → extracted
`FRI_NUM_QUERIES=64`, `FRI_BLOWUP_FACTOR=16`, `FRI_GRINDING_BITS=16`,
`FRI_EXTENSION_DEGREE=3` with `const _: () = assert!(…)` build-time guards (same
structural discipline as the PQV2-03 anchor-ring floor). Proven by experiment: a
one-line edit to `42` fails the BUILD with `evaluation panicked: FRI query count
moved off 64` — not a test nobody ran.

4. **Claimed-but-absent proof-size pin** (`tests/verify_cost.rs` + `tests/kat.rs`).
`verify_cost.rs` stated the exact proof size "is pinned by the KAT suite" — but
**no such pin existed**; only the loose `<= MAX_PROOF_LEN` bound was checked, so a
parameter/winterfell change could move the size (and the weight/gas derivation)
unnoticed. → added `kat_proof_size_pinned` pinning the deterministic proof length
to **98494 bytes** (measured; STARK proofs are Fiat-Shamir-deterministic, so this
is a true known-answer, not a machine-dependent number), and corrected the stale
`verify_cost.rs` reference. Also corrected the `prover.rs` doc table (it claimed
94.3 KB; the shipped `prove_bundle` output is 96.2 KB / 98494 B — a stale figure
from before the PQV2-01 width 31→32 growth).

5. **No byte-KAT on the carrier network binding** (`src/carrier.rs`, PQV2-06). Every
carrier test re-derived the sighash through the same code, so they were mutually
self-consistent: a silent change to the preimage layout, the scheme byte, or the
`B3_CARRIER_BINDING` domain would keep them ALL green while changing the authorized
message. → added `carrier_sighash_byte_kat_pinned`, pinning the exact sighash for a
fixed input and asserting `SCHEME_DOMAIN_SIGNER_NONCE == 2`.

*Already-covered surfaces (left as-is):* the note-commitment / tree-root / nullifier /
owner-tag digest KATs (`kat.rs`), the canonical proof-context KAT already carrying the
`40 10 10 03` = 64/16/16/cubic option bytes with justification (`decode_hardening.rs`),
the PQV2-01 leaf-position double-spend vectors, and the PQV2-03 tree-exhaustion const
floor (`state.rs`) all already pin their respective invariants.

*No KAT/security constant was re-blessed to make a test pass.* The one changed constant
is the corrected report LABEL (drift 1); every added pin was captured from a freshly
measured, deterministic value and justified above. No consensus behavior, gas, digest,
or activation state changed — only the guards around them. Bit 2 remains UNARMED.

## Bar for arming bit 2

Expand Down
Loading