diff --git a/proving/igneum-prove/Cargo.lock b/proving/igneum-prove/Cargo.lock index 00bfac365..6c1bac764 100644 --- a/proving/igneum-prove/Cargo.lock +++ b/proving/igneum-prove/Cargo.lock @@ -5230,8 +5230,7 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" [[package]] name = "sha2" version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +source = "git+https://github.com/sp1-patches/RustCrypto-hashes?tag=patch-sha2-0.10.9-sp1-6.2.0#e48b656ebc806117554bb33c2f8687e4637e37ff" dependencies = [ "cfg-if", "cpufeatures 0.2.17", diff --git a/proving/igneum-prove/Cargo.toml b/proving/igneum-prove/Cargo.toml index 117902fd6..fe96f6ffb 100644 --- a/proving/igneum-prove/Cargo.toml +++ b/proving/igneum-prove/Cargo.toml @@ -42,3 +42,6 @@ k256 = { git = "https://github.com/sp1-patches/elliptic-curves", tag = "patch-k2 # Finality in the proof (7 October 2026): BLS12-381 field operations on the precompiles for the certificate check # in the aggregator guest; natively the same crate's own arithmetic. bls12_381 = { git = "https://github.com/sp1-patches/bls12_381", tag = "patch-0.8.0-sp1-6.2.0" } +# The SHA-256 precompile for the key table, ring and history hashes of the finality fold (and the hash-to-curve's +# expand_message inside bls12_381); natively the upstream code, the same bytes. +sha2 = { git = "https://github.com/sp1-patches/RustCrypto-hashes", package = "sha2", tag = "patch-sha2-0.10.9-sp1-6.2.0" } diff --git a/proving/igneum-prove/fin/src/bls.rs b/proving/igneum-prove/fin/src/bls.rs index 18313b991..75c62b51c 100644 --- a/proving/igneum-prove/fin/src/bls.rs +++ b/proving/igneum-prove/fin/src/bls.rs @@ -5,10 +5,19 @@ use crate::{PUBKEY_LEN, SIG_LEN}; +pub const POINT_LEN: usize = 96; + pub trait Bls { - /// `fast_aggregate_verify`: the aggregate of `pubkeys` signed `msg` under `dst` with `sig`. + /// `fast_aggregate_verify`: the aggregate of `pubkeys` signed `msg` under `dst` with `sig`. Every key is + /// decompressed in full (a square root and a subgroup check per key): the reveal path. fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool; + /// The certificate path (docs/design/finality-in-proof.md 6.1, the second fix): every signer's key arrives + /// uncompressed (`points`, 96 bytes each) beside its committed compressed form (`pubkeys`); the point must lie + /// on the curve and compress to the committed bytes, which pins it to the key the reveal validated (the + /// x-coordinate and the sign of y name one curve point), so no square root and no subgroup check per signer. + fn verify_aggregate_points(&self, points: &[[u8; POINT_LEN]], pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool; + fn verify_one(&self, pubkey: &[u8; PUBKEY_LEN], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool { self.verify_aggregate(std::slice::from_ref(pubkey), msg, dst, sig) } @@ -21,9 +30,7 @@ pub struct ZkBls; #[cfg(feature = "zk-bls")] impl Bls for ZkBls { fn verify_aggregate(&self, pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool { - use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve}; - use bls12_381::{multi_miller_loop, G1Affine, G1Projective, G2Affine, G2Prepared, G2Projective, Gt}; - use group::Curve; + use bls12_381::{G1Affine, G1Projective, G2Affine}; if pubkeys.is_empty() { return false; } @@ -44,10 +51,42 @@ impl Bls for ZkBls { return false; } let s = s.unwrap(); - let hm: G2Projective = >>::hash_to_curve(&[msg], dst); - let agg_affine = agg.to_affine(); - let neg_g1 = -G1Affine::generator(); - let r = multi_miller_loop(&[(&agg_affine, &G2Prepared::from(hm.to_affine())), (&neg_g1, &G2Prepared::from(s))]).final_exponentiation(); - r == Gt::identity() + zk_pair(agg, msg, dst, s) + } + + fn verify_aggregate_points(&self, points: &[[u8; POINT_LEN]], pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool { + use bls12_381::{G1Affine, G1Projective, G2Affine}; + if points.is_empty() || points.len() != pubkeys.len() { + return false; + } + let mut agg = G1Projective::identity(); + for (pt, pk) in points.iter().zip(pubkeys) { + let p = G1Affine::from_uncompressed_unchecked(pt); + if p.is_none().into() { + return false; + } + let p = p.unwrap(); + if !bool::from(p.is_on_curve()) || p.is_identity().into() || p.to_compressed() != *pk { + return false; + } + agg += G1Projective::from(p); + } + let s = G2Affine::from_compressed(sig); + if s.is_none().into() { + return false; + } + zk_pair(agg, msg, dst, s.unwrap()) } } + +#[cfg(feature = "zk-bls")] +fn zk_pair(agg: bls12_381::G1Projective, msg: &[u8], dst: &[u8], s: bls12_381::G2Affine) -> bool { + use bls12_381::hash_to_curve::{ExpandMsgXmd, HashToCurve}; + use bls12_381::{multi_miller_loop, G1Affine, G2Prepared, G2Projective, Gt}; + use group::Curve; + let hm: G2Projective = >>::hash_to_curve(&[msg], dst); + let agg_affine = agg.to_affine(); + let neg_g1 = -G1Affine::generator(); + let r = multi_miller_loop(&[(&agg_affine, &G2Prepared::from(hm.to_affine())), (&neg_g1, &G2Prepared::from(s))]).final_exponentiation(); + r == Gt::identity() +} diff --git a/proving/igneum-prove/fin/src/cert.rs b/proving/igneum-prove/fin/src/cert.rs index 3f41ca6e2..16d196452 100644 --- a/proving/igneum-prove/fin/src/cert.rs +++ b/proving/igneum-prove/fin/src/cert.rs @@ -24,6 +24,10 @@ pub struct CertificateWitness { pub bitmap: Vec, #[serde(with = "crate::serde_arrays")] pub signature: [u8; SIG_LEN], + /// Every signer's key uncompressed (96 bytes), in bitmap order: the certificate path takes no square root and + /// no subgroup check per signer (design 6.1, the second fix); each must compress to the table's key. + #[serde(default)] + pub signer_points: Vec>, /// The table at the checkpoint block. pub at: TableAt, /// The table at the previous lock's block, when a lock exists. @@ -103,7 +107,21 @@ pub fn verify_certificate(state: &mut FinState, params: &FinParams, c: &Certific pubkeys.push(k.pubkey); signer_hashes.push(k.key_hash); } - if !bls.verify_aggregate(&pubkeys, &vote_message(¶ms.chain_id, c.index, &c.checkpoint), crate::DST_VOTE, &c.signature) { + let msg = vote_message(¶ms.chain_id, c.index, &c.checkpoint); + let sig_ok = if c.signer_points.is_empty() { + bls.verify_aggregate(&pubkeys, &msg, crate::DST_VOTE, &c.signature) + } else { + if c.signer_points.len() != pubkeys.len() { + return Err("the certificate carries a point count other than its signer count".into()); + } + let mut points = Vec::with_capacity(pubkeys.len()); + for p in &c.signer_points { + let a: [u8; crate::bls::POINT_LEN] = p.as_slice().try_into().map_err(|_| "a signer point is not 96 bytes")?; + points.push(a); + } + bls.verify_aggregate_points(&points, &pubkeys, &msg, crate::DST_VOTE, &c.signature) + }; + if !sig_ok { return Err("the certificate's aggregate signature does not verify".into()); } if total == 0 || !FinParams::floor_met(signed, total) { diff --git a/proving/igneum-prove/fin/tests/harness.rs b/proving/igneum-prove/fin/tests/harness.rs index 90e371723..9369c77a6 100644 --- a/proving/igneum-prove/fin/tests/harness.rs +++ b/proving/igneum-prove/fin/tests/harness.rs @@ -49,6 +49,20 @@ impl Bls for BlstBls { let refs: Vec<&blst::min_pk::PublicKey> = pks.iter().collect(); sig.fast_aggregate_verify(true, msg, dst, &refs) == blst::BLST_ERROR::BLST_SUCCESS } + + fn verify_aggregate_points(&self, points: &[[u8; 96]], pubkeys: &[[u8; PUBKEY_LEN]], msg: &[u8], dst: &[u8], sig: &[u8; SIG_LEN]) -> bool { + let mut pks = Vec::with_capacity(points.len()); + for (p, want) in points.iter().zip(pubkeys) { + let Ok(pk) = blst::min_pk::PublicKey::deserialize(p) else { return false }; + if pk.compress() != *want { + return false; + } + pks.push(pk); + } + let Some(sig) = Signature::from_bytes(sig).ok() else { return false }; + let refs: Vec<&blst::min_pk::PublicKey> = pks.iter().collect(); + sig.fast_aggregate_verify(true, msg, dst, &refs) == blst::BLST_ERROR::BLST_SUCCESS + } } /// The simulated chain: one chain block per DAA second, every block blue, keys mine in proportion to `share`. @@ -157,7 +171,10 @@ impl Sim { bitmap[p / 8] |= 1 << (p % 8); } let frozen = (self.state.lock.index > 0).then(|| self.table_at(self.state.lock.number)); - CertificateWitness { index, checkpoint, voter_count: voters.len() as u32, bitmap, signature: agg, at: self.table_at(number), frozen } + let mut order: Vec = signers.to_vec(); + order.sort_by_key(|&s| voters.iter().position(|&v| table[v].key_hash == self.keys[s].hash).unwrap()); + let signer_points: Vec> = order.iter().map(|&s| self.keys[s].sk.sk_to_pk().serialize().to_vec()).collect(); + CertificateWitness { index, checkpoint, voter_count: voters.len() as u32, bitmap, signature: agg, signer_points, at: self.table_at(number), frozen } } } @@ -264,12 +281,12 @@ fn known_failed_first_todays_light_client_takes_the_voter_list_from_a_node_and_t // refused too (e holds nothing there and is no voter: the bitmap names a position outside the list, or e's // weight is zero). let forged_at = TableAt { leaf: sim.leaves[&cp].clone(), proof: sim.mmr.proof(cp).unwrap(), keys: forged.clone() }; - let cert = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap: bitmap.clone(), signature: sig, at: forged_at, frozen: None }; + let cert = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap: bitmap.clone(), signature: sig, signer_points: Vec::new(), at: forged_at, frozen: None }; let mut w = FoldWitness::default(); w.certificates.push(cert); let err = sim.mine(1, &[], w).unwrap_err(); assert!(err.contains("not the one the proof committed"), "the forged table is refused: {err}"); - let cert_real = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap, signature: sig, at: sim.table_at(cp), frozen: None }; + let cert_real = CertificateWitness { index: 9, checkpoint, voter_count: forged_voters.len() as u32, bitmap, signature: sig, signer_points: Vec::new(), at: sim.table_at(cp), frozen: None }; let mut w = FoldWitness::default(); w.certificates.push(cert_real); let err = sim.mine(1, &[], w).unwrap_err(); @@ -455,6 +472,15 @@ fn the_guest_curve_and_blst_agree_on_real_certificates_and_disagree_with_nothing assert!(!ZkBls.verify_aggregate(&pks, &msg, DST_VOTE, &bad)); let other = vote_message(&sim.params.chain_id, 10, &cert.checkpoint); assert!(!ZkBls.verify_aggregate(&pks, &other, DST_VOTE, &cert.signature)); + // the certificate path with uncompressed points, both curves; a point that compresses to another key refused + let pts: Vec<[u8; 96]> = [0usize, 1].iter().map(|&i| sim.keys[i].sk.sk_to_pk().serialize()).collect(); + assert!(ZkBls.verify_aggregate_points(&pts, &pks, &msg, DST_VOTE, &cert.signature)); + assert!(BlstBls.verify_aggregate_points(&pts, &pks, &msg, DST_VOTE, &cert.signature)); + let swapped = vec![pts[1], pts[0]]; + assert!(!ZkBls.verify_aggregate_points(&swapped, &pks, &msg, DST_VOTE, &cert.signature), "a point must compress to its own table key"); + let mut off = pts.clone(); + off[0][95] ^= 1; + assert!(!ZkBls.verify_aggregate_points(&off, &pks, &msg, DST_VOTE, &cert.signature), "a point off the curve is refused"); // a proof of possession let r = reveal(&sim.keys[0]); assert!(ZkBls.verify_one(&r.pubkey, &r.pubkey, DST_POP, &r.pop)); diff --git a/proving/igneum-prove/host/src/fin.rs b/proving/igneum-prove/host/src/fin.rs index 97a6dcba6..f078d33d1 100644 --- a/proving/igneum-prove/host/src/fin.rs +++ b/proving/igneum-prove/host/src/fin.rs @@ -230,12 +230,16 @@ pub fn synth(fixtures: &[Fixture], keys: usize, voters: usize, window_blocks: u6 let refs: Vec<&blst::min_pk::Signature> = sigs.iter().collect(); let agg = blst::min_pk::AggregateSignature::aggregate(&refs, true).unwrap().to_signature().compress(); let mut bitmap = vec![0u8; vlist.len().div_ceil(8)]; - for p in positions { + for p in &positions { bitmap[p / 8] |= 1 << (p % 8); } + // the signers' uncompressed points in bitmap order + let mut by_pos: Vec<(usize, &usize)> = positions.iter().zip(heavy.iter()).map(|(p, v)| (*p, v)).collect(); + by_pos.sort(); + let signer_points: Vec> = by_pos.iter().map(|(_, v)| { let kh = cp_keys[**v].key_hash; synth_keys.iter().find(|k| k.hash == kh).unwrap().sk.sk_to_pk().serialize().to_vec() }).collect(); let position = cp_number - root_state.history_first; let proof = mmr.proof(position).ok_or_else(|| anyhow!("no history proof for {cp_number}"))?; - w.certificates.push(CertificateWitness { index, checkpoint: cp_leaf.block_hash, voter_count: vlist.len() as u32, bitmap, signature: agg, at: TableAt { leaf: cp_leaf, proof, keys: cp_keys }, frozen: None }); + w.certificates.push(CertificateWitness { index, checkpoint: cp_leaf.block_hash, voter_count: vlist.len() as u32, bitmap, signature: agg, signer_points, at: TableAt { leaf: cp_leaf, proof, keys: cp_keys }, frozen: None }); } fold_block(&mut state, ¶ms, &block, &mut ring, &w, &bls).map_err(|e| anyhow!("fold {n}: {e}"))?; let (_, total) = voters_at(&state.keys, daa, params.dust);