diff --git a/contrib/codeql/lib/policy.qll b/contrib/codeql/lib/policy.qll index 1d55e00e..68687ae3 100644 --- a/contrib/codeql/lib/policy.qll +++ b/contrib/codeql/lib/policy.qll @@ -41,7 +41,7 @@ predicate isNotEncodable(TypeItem t) { /** Holds if `t` holds secret or security-sensitive material. */ predicate isSecretType(TypeItem t) { ( - t.getName().getText().regexpMatch(".*(Secret|Private|Seed|Password|Mnemonic|SkBytes).*") + t.getName().getText().regexpMatch(".*(Secret|Private|Seed|Password|Mnemonic|SkBytes|DhBytes).*") or // "Share" is the one keyword that "Shared" (e.g. SharedState) matches without holding a secret, // so the guard applies to it alone, exceptions to this rule are explicitly enumerated. diff --git a/pkgs/pkc/bench/bls.rs b/pkgs/pkc/bench/bls.rs index bf1f780a..8c05dcb6 100644 --- a/pkgs/pkc/bench/bls.rs +++ b/pkgs/pkc/bench/bls.rs @@ -36,7 +36,7 @@ fn verify(bencher: Bencher) { #[divan::bench(types = [BlsScChia, BlsScIetf], args = [2, 5, 25, 50, 100])] fn aggregate_pk_n(bencher: Bencher, n: usize) { let pks: Vec<_> = (0..n) - .map(|i| BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap().public_key()) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap().public_key()) .collect(); let pk_refs: Vec<_> = pks.iter().collect(); bencher @@ -48,12 +48,12 @@ fn aggregate_pk_n(bencher: Bencher, n: usize) { #[divan::bench(types = [BlsScChia, BlsScIetf], args = [2, 10, 100])] fn aggregate_sig_n(bencher: Bencher, n: usize) { let keys: Vec<_> = (0..n) - .map(|i| BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap()) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap()) .collect(); let sigs: Vec<_> = keys .iter() .enumerate() - .map(|(i, key)| key.sign(S::msg_ref(&test_msg(i as u8)))) + .map(|(i, key)| key.sign(S::msg_ref(&test_msg(i)))) .collect(); let sig_refs: Vec<&BlsSignature> = sigs.iter().collect(); bencher @@ -65,9 +65,9 @@ fn aggregate_sig_n(bencher: Bencher, n: usize) { #[divan::bench(types = [BlsScChia, BlsScIetf], args = [100, 1000])] fn verify_n_individual(bencher: Bencher, n: usize) { let keys: Vec<_> = (0..n) - .map(|i| BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap()) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap()) .collect(); - let msgs: Vec<[u8; 32]> = (0..n).map(|i| test_msg(i as u8)).collect(); + let msgs: Vec<[u8; 32]> = (0..n).map(test_msg).collect(); let pks: Vec<_> = keys.iter().map(BlsSecretKey::public_key).collect(); let sigs: Vec<_> = keys .iter() @@ -86,7 +86,7 @@ fn verify_n_individual(bencher: Bencher, n: usize) { #[divan::bench(types = [BlsScChia, BlsScIetf], args = [10, 100, 1000])] fn fast_verify_n(bencher: Bencher, n: usize) { let keys: Vec<_> = (0..n) - .map(|i| BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap()) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap()) .collect(); let msg = test_msg(42); let pks: Vec<_> = keys.iter().map(BlsSecretKey::public_key).collect(); @@ -161,33 +161,69 @@ fn recover_threshold(bencher: Bencher, threshold: usize) { .bench(|| BlsSignature::::recover(&subset)); } +/// Aggregate signatures over distinct messages, then verify. +#[divan::bench(types = [BlsScChia, BlsScIetf], args = [10, 100, 1000])] +fn verify_aggregated_block(bencher: Bencher, n: usize) +where + S::Msg: Sync, +{ + let keys: Vec<_> = (0..n) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap()) + .collect(); + let msgs: Vec<[u8; 32]> = (0..n).map(test_msg).collect(); + let pks: Vec<_> = keys.iter().map(BlsSecretKey::public_key).collect(); + let sigs: Vec<_> = keys + .iter() + .zip(&msgs) + .map(|(key, msg)| key.sign(S::msg_ref(msg))) + .collect(); + let sig_refs: Vec<&BlsSignature> = sigs.iter().collect(); + let aggregate = BlsSignature::::aggregate(&sig_refs).unwrap(); + let pk_refs: Vec<_> = pks.iter().collect(); + let msg_refs: Vec<&S::Msg> = msgs.iter().map(|msg| S::msg_ref(msg)).collect(); + + bencher + .counter(ItemsCount::new(n)) + .bench(|| aggregate.verify_aggregates(&msg_refs, &pk_refs)); +} + +/// Public-key-weighted aggregation, one scalar multiplication per signature on +/// top of the plain sum. +#[divan::bench(types = [BlsScChia, BlsScIetf], args = [2, 10, 100])] +fn secure_aggregate_n(bencher: Bencher, n: usize) { + let keys: Vec<_> = (0..n) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap()) + .collect(); + let msg = test_msg(42); + let pks: Vec<_> = keys.iter().map(BlsSecretKey::public_key).collect(); + let sigs: Vec<_> = keys.iter().map(|key| key.sign(S::msg_ref(&msg))).collect(); + let sig_refs: Vec<&BlsSignature> = sigs.iter().collect(); + let pk_refs: Vec<_> = pks.iter().collect(); + + bencher + .counter(ItemsCount::new(n)) + .bench(|| BlsSignature::::secure_aggregate(&sig_refs, &pk_refs)); +} + +/// Evaluating the master secret polynomial at a participant id, over a master +/// key of `n` coefficients. +#[divan::bench(types = [BlsScChia, BlsScIetf], args = [2, 5, 10])] +fn derive_share_n(bencher: Bencher, n: usize) { + let master: Vec<_> = (0..n) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap()) + .collect(); + let master_refs: Vec<&BlsSecretKey> = master.iter().collect(); + let id = sequential_ids(1)[0]; + + bencher + .counter(ItemsCount::new(n)) + .bench(|| BlsSecretKey::::derive_share(&master_refs, &id)); +} + /// IETF-only BLS operations. mod ietf { use super::*; - /// Aggregate signatures over distinct messages, then verify. - #[divan::bench(args = [10, 100, 1000])] - fn verify_aggregated_block(bencher: Bencher, n: usize) { - let keys: Vec<_> = (0..n) - .map(|i| BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap()) - .collect(); - let msgs: Vec<[u8; 32]> = (0..n).map(|i| test_msg(i as u8)).collect(); - let pks: Vec<_> = keys.iter().map(BlsSecretKey::public_key).collect(); - let sigs: Vec<_> = keys - .iter() - .zip(&msgs) - .map(|(key, msg)| key.sign(msg.as_slice())) - .collect(); - let sig_refs: Vec<&BlsSignature> = sigs.iter().collect(); - let aggregate = BlsSignature::::aggregate(&sig_refs).unwrap(); - let pk_refs: Vec<_> = pks.iter().collect(); - let msg_refs: Vec<&[u8]> = msgs.iter().map(|msg| msg.as_slice()).collect(); - - bencher - .counter(ItemsCount::new(n)) - .bench(|| aggregate.verify_aggregates(&msg_refs, &pk_refs)); - } - /// Proof of possession creation. #[divan::bench] fn prove_pop(bencher: Bencher) { @@ -214,8 +250,8 @@ mod worker { fn setup_sigs(n: usize) -> Vec<(BlsSignature, BlsPublicKey, [u8; 32])> { (0..n) .map(|i| { - let sk = BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap(); - let msg = test_msg(i as u8); + let sk = BlsSecretKey::::generate(&test_ikm(i)).unwrap(); + let msg = test_msg(i); let pk = sk.public_key(); let sig = sk.sign(S::msg_ref(&msg)); (sig, pk, msg) @@ -234,7 +270,7 @@ mod worker { #[divan::bench(types = [BlsScChia, BlsScIetf], args = [100, 1000])] fn aggregate_pk_n(bencher: Bencher, n: usize) { let pks: Vec> = (0..n) - .map(|i| BlsSecretKey::::generate(&test_ikm(i as u8)).unwrap().public_key()) + .map(|i| BlsSecretKey::::generate(&test_ikm(i)).unwrap().public_key()) .collect(); bencher .counter(ItemsCount::new(n)) diff --git a/pkgs/pkc/corpus/bls_chia_aggregate.json5 b/pkgs/pkc/corpus/bls_chia_aggregate.json5 index eea9727f..7618f9ab 100644 --- a/pkgs/pkc/corpus/bls_chia_aggregate.json5 +++ b/pkgs/pkc/corpus/bls_chia_aggregate.json5 @@ -52,5 +52,63 @@ ], "agg_sig": "8584ad5356fbe1e04cc94f4d1a13e4d6055380105ea70f5a76a5e8740219276287f05cfcd2c82f013af553a1a791b2e20bd19c2e5c929a5d5b1fce1de6b3093a0c2da0c2381052552651d89e2d29c5df8ce7c638eda2717d9d0dbcd03e93f86a" } + ], + "aggregate_verify": [ + { + "pks": [ + "8a1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "0004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222" + ], + "agg_sig": "8b1bcc746cd2d0d66a4c2f266b26cb9dbea97b365e24bda8902864596535d030b8477bf392c671fb7a69a21f4ddfcf2919f34b447662aa9ff76aa2b5c3de021b6c41993e1ec5e5a513378875a84da799f350406e5ba84f6a83d90cd773f2fd28", + "accepted": true + }, + { + "pks": [ + "8a1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "0004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c", + "8355519968b7db86b1ceb2261e179f6cde1a6010b8588e4a1a59eae804c9eed5f3e3d433a69dabb1eb7403c9c2721116" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222", + "3333333333333333333333333333333333333333333333333333333333333333" + ], + "agg_sig": "0c35b81c8be4dfda86a8e2eccec0842c45652eb5132c83d03f3365b79d089c7f3f7110186944d33115af544e97d6814a0dba5b5c3413dcebf13532764535e8cc6cd7d66247bdd58b11653d9d832dcab9d55db16693ca61b0a7109358aa0af3ca", + "accepted": true + }, + { + "pks": [ + "8a1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "0004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c", + "8355519968b7db86b1ceb2261e179f6cde1a6010b8588e4a1a59eae804c9eed5f3e3d433a69dabb1eb7403c9c2721116", + "184c7b6984b75a5bd6f8a8b1db3eedb7624910057d2951c6d6b39afa2d0b5b192d42f4ef531fea1bdf563e7478c0b831", + "80d3ea109332aa3911781d3f6ab88750b1ea1322c1a4c503a4a855e610950eb9a3a11c1be648e914f74eae275d57f9b9" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222", + "3333333333333333333333333333333333333333333333333333333333333333", + "4444444444444444444444444444444444444444444444444444444444444444", + "5555555555555555555555555555555555555555555555555555555555555555" + ], + "agg_sig": "0262ab395c3b1a757211230962494345b9b69cf5a5d97dc92d97214f29ddae01bc27d7ba858fb9fdbe415a7929e5969b0d6b64085701d7c35ea442684c62b198da24db8b4096b5701ea932637a972fe0d31edf44558e7f8dda4c3fe8b0cbc300", + "accepted": true + }, + { + "pks": [ + "8a1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "0004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111" + ], + "agg_sig": "834c7d972c285c2ec38c99d600b18b0ae68fb9266a65358920131556b71a4420f43a24bc8278ba3f697d45054aba6606015f3247c157d6cc8c42041f05bcf52a59f256201b081a96a66a3dac7cffd1f86e66f63a16f90b02a2900a3123d6006f", + "accepted": true + } ] } diff --git a/pkgs/pkc/corpus/bls_ietf_aggregate.json5 b/pkgs/pkc/corpus/bls_ietf_aggregate.json5 index ac6e3532..a6946baf 100644 --- a/pkgs/pkc/corpus/bls_ietf_aggregate.json5 +++ b/pkgs/pkc/corpus/bls_ietf_aggregate.json5 @@ -52,5 +52,63 @@ ], "agg_sig": "aefe0f3eab10e0580edfcad7b31ee019a41d41291e8528a7fc9be42bfeaf1f3751548fbbde424b3e9ce934090ce0dd3701516fe9754fbbc7a9a0e083e4d02dffb9d532432ed8c181ffc5282cc5c17d8c79c68effd51fb37f173fd2f62ff66fa7" } + ], + "aggregate_verify": [ + { + "pks": [ + "aa1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "8004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222" + ], + "agg_sig": "a1d5a771f58b0f5dfecf3c84a7b3f9dcdf5b5469f6d59998d8c6147a3a31853832a55cf658797c82e0c4df55d374312a0708504b17d7d553424809bce93a7aac8321008cf34c73640faf644ecc643baabc633ba8c14eda8fcd41a1c5a6030672", + "accepted": true + }, + { + "pks": [ + "aa1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "8004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c", + "a355519968b7db86b1ceb2261e179f6cde1a6010b8588e4a1a59eae804c9eed5f3e3d433a69dabb1eb7403c9c2721116" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222", + "3333333333333333333333333333333333333333333333333333333333333333" + ], + "agg_sig": "940da8906951211c25b928f228342bf434b53a2770c3ea9f754df6d6ad8792a7d612f3b1c29c4662ddd5ff59fc26b3dc02ebb2ca823a1c09da9130291c0dc7aa2cd9a05d67b4f4c4a1192831f270780e319a1a2ce7aa1a34766a3571d4aa5430", + "accepted": true + }, + { + "pks": [ + "aa1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "8004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c", + "a355519968b7db86b1ceb2261e179f6cde1a6010b8588e4a1a59eae804c9eed5f3e3d433a69dabb1eb7403c9c2721116", + "984c7b6984b75a5bd6f8a8b1db3eedb7624910057d2951c6d6b39afa2d0b5b192d42f4ef531fea1bdf563e7478c0b831", + "a0d3ea109332aa3911781d3f6ab88750b1ea1322c1a4c503a4a855e610950eb9a3a11c1be648e914f74eae275d57f9b9" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "2222222222222222222222222222222222222222222222222222222222222222", + "3333333333333333333333333333333333333333333333333333333333333333", + "4444444444444444444444444444444444444444444444444444444444444444", + "5555555555555555555555555555555555555555555555555555555555555555" + ], + "agg_sig": "a6aa06a4294d03ce142e8426fc2558389ed680402f4b882db16842a2454ec960b729ae1dc406ce01eccdefcaec67844c19a8c765d21da1084c8811af9f01b25617bdf0479ad6dca3aa942a33a7f0c82fdff5093c1e5aca763f9bf6d0f26e9ad3", + "accepted": true + }, + { + "pks": [ + "aa1a1c26055a329817a5759d877a2795f9499b97d6056edde0eea39512f24e8bc874b4471f0501127abb1ea0d9f68ac1", + "8004066a1a5cb9cdf244e45f0a59cf579a78d90ac0bc24663565264601c1c9251c0aa3dfb9835b520e0ba0f211a6696c" + ], + "msgs": [ + "1111111111111111111111111111111111111111111111111111111111111111", + "1111111111111111111111111111111111111111111111111111111111111111" + ], + "agg_sig": "a61c6b1abaf8a0baac5947e9b2266e72285c10eb28db3023b0ec6e00fb77b63b8d65af635b73a1c12545e99ca331a6b407def3a9e0c152c8479994ea403e5f9c9665503b4a013ce3e7178734a0274094d9389c4bb9aca6be1b7506b8e25c8fea", + "accepted": false + } ] } diff --git a/pkgs/pkc/src/bls/blst_ffi.rs b/pkgs/pkc/src/bls/blst_ffi.rs index 14aa1731..ae96e107 100644 --- a/pkgs/pkc/src/bls/blst_ffi.rs +++ b/pkgs/pkc/src/bls/blst_ffi.rs @@ -24,6 +24,32 @@ pub(crate) fn bendian_from_scalar(scalar: &blst_scalar) -> [u8; 32] { out } +/// Whether `e(G1 generator, lhs_g2)` equals the product of `e(g1, g2)` over +/// the paired slices, the multi-pairing behind per-signer-message verifying. +pub(crate) fn pairings_equal_with_g1_generator_prod(lhs_g2: &G2Affine, rhs_g2: &[G2], rhs_g1: &[&G1Affine]) -> bool { + if rhs_g2.len() != rhs_g1.len() || rhs_g2.is_empty() { + return false; + } + + let lhs_g2_aff = blst_p2_affine::from(*lhs_g2); + let g1_generator = blst_p1_affine::from(G1Affine::generator()); + let mut lhs = blst_fp12::default(); + + unsafe { + blst_miller_loop(&mut lhs, &lhs_g2_aff, &g1_generator); + let mut rhs = *blst_fp12_one(); + for (g2, g1) in rhs_g2.iter().zip(rhs_g1) { + let g2_aff = blst_p2_affine::from(g2.to_affine()); + let g1_aff = blst_p1_affine::from(**g1); + let mut term = blst_fp12::default(); + blst_miller_loop(&mut term, &g2_aff, &g1_aff); + let acc = rhs; + blst_fp12_mul(&mut rhs, &acc, &term); + } + blst_fp12_finalverify(&lhs, &rhs) + } +} + /// Pairing check `e(lhs_g2, G1) == e(rhs_g2, rhs_g1)` pub(crate) fn pairings_equal_with_g1_generator(lhs_g2: &G2Affine, rhs_g2: &G2, rhs_g1: &G1Affine) -> bool { let lhs_g2_aff = blst_p2_affine::from(*lhs_g2); @@ -345,6 +371,12 @@ pub(crate) trait Point: Copy + Default + Add { pub struct G1(blst_p1); impl G1 { + /// Whether the point lies in the prime-order subgroup. + #[cfg(test)] + pub(crate) fn in_subgroup(&self) -> bool { + unsafe { blst_p1_in_g1(&self.0) } + } + /// Convert to affine coordinates. pub(crate) fn to_affine(self) -> G1Affine { let mut aff = blst_p1_affine::default(); @@ -434,6 +466,12 @@ impl G2 { Self(unsafe { *blst_p2_generator() }) } + /// Whether the point lies in the prime-order subgroup. + #[cfg(test)] + pub(crate) fn in_subgroup(&self) -> bool { + unsafe { blst_p2_in_g2(&self.0) } + } + /// Point doubling. pub(crate) fn double(&self) -> Self { let mut out = blst_p2::default(); @@ -515,6 +553,11 @@ impl G2Affine { G2(out) } + /// Whether the point is at infinity. + pub(crate) fn is_inf(&self) -> bool { + unsafe { blst_p2_affine_is_inf(&self.0) } + } + /// Serialize to the 96-byte compressed encoding. pub(crate) fn compress(&self) -> [u8; 96] { let mut out = [0u8; 96]; diff --git a/pkgs/pkc/src/bls/dh_bytes.rs b/pkgs/pkc/src/bls/dh_bytes.rs new file mode 100644 index 00000000..4f22dbc5 --- /dev/null +++ b/pkgs/pkc/src/bls/dh_bytes.rs @@ -0,0 +1,64 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! BLS Diffie-Hellman shared key byte bag. + +use crate::bls::BlsSchemeId; + +use dash_types::derive_sbytes; +use subtle::ConstantTimeEq; +use zeroize::Zeroize; + +use core::marker::PhantomData; + +/// Raw shared secret length (G1 compressed). +pub const BLS_DH_LEN: usize = 48; + +/// A scheme-tagged Diffie-Hellman shared key, `sk * peer_pk`. +pub struct BlsDhBytes { + inner: [u8; BLS_DH_LEN], + _scheme: PhantomData, +} + +impl BlsDhBytes { + /// Wraps raw bytes. + pub const fn from_bytes(bytes: [u8; BLS_DH_LEN]) -> Self { + Self { + inner: bytes, + _scheme: PhantomData, + } + } + + /// Borrows the inner byte array. + pub const fn as_bytes(&self) -> &[u8; BLS_DH_LEN] { + &self.inner + } +} + +impl Zeroize for BlsDhBytes { + fn zeroize(&mut self) { + self.inner.zeroize(); + } +} + +derive_sbytes!(for[S: BlsSchemeId] BlsDhBytes, BLS_DH_LEN); + +impl Clone for BlsDhBytes { + fn clone(&self) -> Self { + Self { + inner: self.inner, + _scheme: PhantomData, + } + } +} + +impl Eq for BlsDhBytes {} + +impl PartialEq for BlsDhBytes { + fn eq(&self, other: &Self) -> bool { + self.inner.ct_eq(&other.inner).into() + } +} diff --git a/pkgs/pkc/src/bls/error.rs b/pkgs/pkc/src/bls/error.rs index 93dddc60..7df10b23 100644 --- a/pkgs/pkc/src/bls/error.rs +++ b/pkgs/pkc/src/bls/error.rs @@ -11,8 +11,10 @@ use core::fmt; /// Errors produced by BLS operations. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum BlsError { - /// public key and message counts do not match + /// paired input counts do not match CountMismatch, + /// repeated message in a distinct-message aggregate + DuplicateMessage, /// duplicate share id in recovery set DuplicateShareId, /// no items provided for aggregation @@ -40,7 +42,8 @@ pub enum BlsError { impl fmt::Display for BlsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::CountMismatch => write!(f, "public key and message counts differ"), + Self::CountMismatch => write!(f, "paired input counts differ"), + Self::DuplicateMessage => write!(f, "repeated message in a distinct-message aggregate"), Self::DuplicateShareId => write!(f, "duplicate share id in recovery set"), Self::EmptyAggregation => write!(f, "no items provided for aggregation"), Self::InsufficientShares => write!(f, "not enough shares to recover"), diff --git a/pkgs/pkc/src/bls/mod.rs b/pkgs/pkc/src/bls/mod.rs index 06b40de2..3387d640 100644 --- a/pkgs/pkc/src/bls/mod.rs +++ b/pkgs/pkc/src/bls/mod.rs @@ -6,6 +6,7 @@ //! Unified BLS cryptography module. +mod dh_bytes; mod error; mod public_bytes; mod schemes; @@ -13,6 +14,7 @@ mod secret_bytes; mod sig_bytes; mod sig_id; +pub use dh_bytes::{BlsDhBytes, BLS_DH_LEN}; pub use error::BlsError; pub use public_bytes::{BlsPkBytes, BLS_PK_LEN}; pub use schemes::{BlsScChia, BlsScIetf, BlsSchemeId}; diff --git a/pkgs/pkc/src/bls/public_ops.rs b/pkgs/pkc/src/bls/public_ops.rs index 31ace6af..6fca131e 100644 --- a/pkgs/pkc/src/bls/public_ops.rs +++ b/pkgs/pkc/src/bls/public_ops.rs @@ -43,6 +43,18 @@ impl BlsPublicKey { S::pk_to_bytes(&self.0) } + /// Re-encode this key under another scheme. + /// + /// The key is lifted to its point and lowered again, so the target scheme's + /// admission rules apply. + /// + /// # Errors + /// + /// Returns `InvalidPublicKey` when the target scheme refuses the point. + pub fn to_scheme(&self) -> Result, BlsError> { + T::g1_to_pk(S::pk_to_g1(&self.0)?).map(BlsPublicKey::from_inner) + } + /// Aggregate multiple public keys into one. /// /// # Errors @@ -102,7 +114,10 @@ type_cvrt!(for[S: BlsScheme] TryFrom> for BlsPublicKey, BlsErro #[expect(clippy::unwrap_used, reason = "test code")] mod tests { use super::*; - use crate::bls::tests::{SEED_0, SEED_1}; + use crate::bls::tests::{ + ietf_g1_encoding, G1_OFF_SUBGROUP_CHIA, G1_OFF_SUBGROUP_IETF, G1_X_EQ_PRIME_CHIA, G1_X_GE_PRIME_CHIA, + G1_X_MAX_CHIA, SEED_0, SEED_1, + }; use crate::bls::{BlsScChia, BlsScIetf, BlsSecretKey}; use cfg_if::cfg_if; @@ -138,7 +153,7 @@ mod tests { let sk = BlsSecretKey::::from_bytes(&arr_from_hex(&v.sk)).unwrap(); let peer = BlsPublicKey::::from_bytes(&arr_from_hex(&v.peer_pk)).unwrap(); let shared = sk.dh_exchange(&peer).unwrap(); - assert_eq!(shared.to_bytes().to_lower_hex_string(), v.shared); + assert_eq!(shared.as_bytes().to_lower_hex_string(), v.shared); } } @@ -155,7 +170,7 @@ mod tests { let shared_ab = sk_a.dh_exchange(&sk_b.public_key()).unwrap(); let shared_ba = sk_b.dh_exchange(&sk_a.public_key()).unwrap(); - assert_eq!(shared_ab.to_bytes(), shared_ba.to_bytes()); + assert_eq!(shared_ab, shared_ba); } #[rstest] @@ -165,6 +180,77 @@ mod tests { assertion(); } + /// In the Chia scheme, DH weighs whatever the decoder passed, which leaks + /// the scalar mod the cofactor's small factors. IETF rejects this. + fn assert_off_subgroup_peer_policy(encoded: &[u8; 48], reaches_dh: bool) { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + + match BlsPublicKey::::from_bytes(encoded) { + Ok(peer) => { + assert!(reaches_dh, "decoder admitted an off-subgroup key"); + assert!(!S::pk_to_g1(&peer.0).unwrap().in_subgroup()); + assert!(sk.dh_exchange(&peer).is_ok(), "weighted without complaint"); + } + Err(_) => assert!(!reaches_dh, "decoder refused it before DH could see it"), + } + } + + #[rstest] + #[case::chia(assert_off_subgroup_peer_policy::, &G1_OFF_SUBGROUP_CHIA, true)] + #[case::ietf(assert_off_subgroup_peer_policy::, &G1_OFF_SUBGROUP_IETF, false)] + fn off_subgroup_peer_policy( + #[case] assertion: fn(&[u8; 48], bool), + #[case] encoded: &[u8; 48], + #[case] reaches_dh: bool, + ) { + assertion(encoded, reaches_dh); + } + + /// Conversion re-encodes one point, so a round trip returns the original and + /// the same-scheme case is a copy. + fn assert_scheme_conversion_round_trips() { + let pk = BlsSecretKey::::generate(&SEED_0).unwrap().public_key(); + let there = pk.to_scheme::().unwrap(); + + assert_eq!(there.to_scheme::().unwrap().to_bytes(), pk.to_bytes()); + } + + #[rstest] + #[case::chia_to_ietf(assert_scheme_conversion_round_trips::)] + #[case::ietf_to_chia(assert_scheme_conversion_round_trips::)] + #[case::chia_to_chia(assert_scheme_conversion_round_trips::)] + #[case::ietf_to_ietf(assert_scheme_conversion_round_trips::)] + fn scheme_conversion_round_trips(#[case] assertion: fn()) { + assertion(); + } + + /// A key Chia admits and IETF does not must not become an IETF key by being + /// converted, or the conversion would launder it past the check that refused + /// it at the decoder. + #[rstest] + fn scheme_conversion_applies_target_rules() { + let off_subgroup = BlsPublicKey::::from_bytes(&G1_OFF_SUBGROUP_CHIA).unwrap(); + + assert!(off_subgroup.to_scheme::().is_ok()); + assert!(off_subgroup.to_scheme::().is_err()); + } + + /// Rejection alone is weak evidence, since the policy test below cannot + /// tell a composite-order point from a malformed one. Hold both encodings + /// to a single point so that distinction is made here. + #[rstest] + fn off_subgroup_g1_fixtures_are_one_point() { + let chia = BlsPublicKey::::from_bytes(&G1_OFF_SUBGROUP_CHIA).unwrap(); + let point = BlsScChia::pk_to_g1(&chia.0).unwrap(); + + assert!(!point.in_subgroup(), "fixture is not off-subgroup"); + assert_eq!( + point.to_affine().compress(), + G1_OFF_SUBGROUP_IETF, + "the IETF fixture encodes a different point" + ); + } + fn assert_pk_roundtrip() { let pk = BlsSecretKey::::generate(&SEED_0).unwrap().public_key(); let bytes = pk.to_bytes(); @@ -178,13 +264,72 @@ mod tests { assertion(); } - /// The legacy decoder rejects the infinity marker rather than yielding an - /// identity public key. - #[rstest] - fn chia_rejects_identity_public_key() { + /// Neither decoder yields an identity key. Chia guards the marker and the + /// all-zero buffer outright, IETF reaches the same answer through + /// `validate`, which refuses the identity despite a canonical encoding. + fn assert_identity_public_key_rejected() { let mut infinity = [0u8; 48]; infinity[0] = 0xc0; - assert!(BlsPublicKey::::from_bytes(&infinity).is_err()); + assert!(BlsPublicKey::::from_bytes(&infinity).is_err()); + assert!(BlsPublicKey::::from_bytes(&[0u8; 48]).is_err()); + } + + #[rstest] + #[case::chia(assert_identity_public_key_rejected::)] + #[case::ietf(assert_identity_public_key_rejected::)] + fn identity_public_key_rejected(#[case] assertion: fn()) { + assertion(); + } + + /// Chia has no prime-order subgroup check, so a composite-order point decodes + /// and round-trips; refusing it would diverge on a value Chia encodings + /// already carry. IETF validates and refuses it. + fn assert_off_subgroup_public_key_policy(encoded: &[u8; 48], accepted: bool) { + match BlsPublicKey::::from_bytes(encoded) { + Ok(pk) => { + assert!(accepted, "off-subgroup key accepted"); + assert_eq!(pk.to_bytes(), *encoded); + } + Err(_) => assert!(!accepted, "off-subgroup key rejected"), + } + } + + #[rstest] + #[case::chia(assert_off_subgroup_public_key_policy::, &G1_OFF_SUBGROUP_CHIA, true)] + #[case::ietf(assert_off_subgroup_public_key_policy::, &G1_OFF_SUBGROUP_IETF, false)] + fn off_subgroup_public_key_policy( + #[case] assertion: fn(&[u8; 48], bool), + #[case] encoded: &[u8; 48], + #[case] accepted: bool, + ) { + assertion(encoded, accepted); + } + + /// An `x` at or above the field prime is not a coordinate, and neither scheme + /// reduces it into range. + /// + /// Under Chia the refusal is a divergence rather than agreement, since the + /// read error is suppressed there and the G1 value left behind is not the + /// identity, which is all the consumer tests before calling a key valid. + /// + /// It stands because the alternative is a decoded point for bytes that hold + /// none, and the value left behind verifies nothing in any case. + /// + /// Each case was measured, not assumed: `p`, `p + 4` and an all-ones `x` + /// decode under Chia and are refused under IETF. + fn assert_out_of_range_coordinate_rejected(encoded: [u8; 48]) { + assert!(BlsPublicKey::::from_bytes(&encoded).is_err()); + } + + #[rstest] + #[case::chia_eq_prime(assert_out_of_range_coordinate_rejected::, G1_X_EQ_PRIME_CHIA)] + #[case::chia_gt_prime(assert_out_of_range_coordinate_rejected::, G1_X_GE_PRIME_CHIA)] + #[case::chia_max(assert_out_of_range_coordinate_rejected::, G1_X_MAX_CHIA)] + #[case::ietf_eq_prime(assert_out_of_range_coordinate_rejected::, ietf_g1_encoding(G1_X_EQ_PRIME_CHIA))] + #[case::ietf_gt_prime(assert_out_of_range_coordinate_rejected::, ietf_g1_encoding(G1_X_GE_PRIME_CHIA))] + #[case::ietf_max(assert_out_of_range_coordinate_rejected::, ietf_g1_encoding(G1_X_MAX_CHIA))] + fn out_of_range_coordinate_rejected(#[case] assertion: fn([u8; 48]), #[case] encoded: [u8; 48]) { + assertion(encoded); } /// The legacy decoder normalizes stray high bits, so a mutated encoding @@ -202,8 +347,37 @@ mod tests { assert_eq!(decoded.to_bytes(), clean); } - /// The same G1 point encodes differently under the two schemes, and the - /// legacy encoding must round-trip through the wrapper. + /// Bit 6 has no meaning on its own in the Chia encoding and is masked like + /// bit 5; paired with bit 7 it is read as the infinity marker instead, which + /// the decoder rejects whether or not the rest of the buffer is zero. + #[rstest] + fn chia_masks_stray_bit_six() { + let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "bls_chia_ser_internals"); + let v = corpus.vectors::("pk_serialization").swap_remove(0); + + // Clear the sign bit so bit 6 is the only stray bit under test. + let mut clean: [u8; 48] = arr_from_hex(&v.pk_legacy); + clean[0] &= 0x1f; + assert_eq!(BlsPublicKey::::from_bytes(&clean).unwrap().to_bytes(), clean); + + let mut stray = clean; + stray[0] |= 0x40; + assert_eq!( + BlsPublicKey::::from_bytes(&stray).unwrap().to_bytes(), + clean, + "bit 6 alone must be masked, not read as a flag" + ); + + let mut marker = clean; + marker[0] |= 0xc0; + assert!( + BlsPublicKey::::from_bytes(&marker).is_err(), + "bits 6 and 7 together mark infinity, which the decoder rejects" + ); + } + + /// The same G1 point encodes differently under the two schemes, and each + /// encoding must round-trip through the wrapper of its own scheme. #[rstest] fn serialization_formats_match_vectors() { let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "bls_chia_ser_internals"); @@ -213,6 +387,9 @@ mod tests { let legacy = BlsPublicKey::::from_bytes(&arr_from_hex(&v.pk_legacy)).unwrap(); assert_eq!(legacy.to_bytes().to_lower_hex_string(), v.pk_legacy); + let ietf = BlsPublicKey::::from_bytes(&arr_from_hex(&v.pk_ietf)).unwrap(); + assert_eq!(ietf.to_bytes().to_lower_hex_string(), v.pk_ietf); + assert_ne!(v.pk_legacy, v.pk_ietf, "legacy and ietf should differ"); } } @@ -242,7 +419,18 @@ mod tests { cfg_if! { if #[cfg(feature = "serde")] { - use dash_dev::assert_json_rt; + use dash_dev::{assert_json_rt, to_json}; + + #[rstest] + fn serde_emits_hex_string() { + let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "bls_chia_ser_internals"); + let v = corpus.vectors::("pk_serialization").swap_remove(0); + + let chia = BlsPublicKey::::from_bytes(&arr_from_hex(&v.pk_legacy)).unwrap(); + let ietf = BlsPublicKey::::from_bytes(&arr_from_hex(&v.pk_ietf)).unwrap(); + assert_eq!(to_json(&chia), format!("\"{}\"", v.pk_legacy)); + assert_eq!(to_json(&ietf), format!("\"{}\"", v.pk_ietf)); + } #[rstest] fn serde_roundtrip() { diff --git a/pkgs/pkc/src/bls/scheme_chia.rs b/pkgs/pkc/src/bls/scheme_chia.rs index ff1f2b50..b7155cfb 100644 --- a/pkgs/pkc/src/bls/scheme_chia.rs +++ b/pkgs/pkc/src/bls/scheme_chia.rs @@ -11,6 +11,7 @@ use super::chia_h2c; use super::error::BlsError; use super::scheme_ops::BlsScheme; use super::schemes::BlsScChia; +use crate::prelude::*; use blst::min_pk; use hex_conservative::hex; @@ -173,14 +174,16 @@ impl BlsScheme for BlsScChia { /// convention differences: blst lays out `[x.c1, x.c0, y.c1, y.c0]`, /// legacy `[x.c0, x.c1]` with the sign at byte\[0\] bit 7. fn sig_to_bytes(sig: &Self::InnerSig) -> [u8; 96] { - let uncomp = sig.serialize(); - - if uncomp.iter().all(|&b| b == 0) { + // Take blst's own infinity flag rather than testing the buffer: its + // uncompressed form sets bit 6 of byte 0 and zeroes the rest, so an + // all-zero test never fires and the swizzle would relocate that flag. + if sig.is_inf() { let mut out = [0u8; 96]; out[0] = 0xc0; return out; } + let uncomp = sig.serialize(); let x_c1 = &uncomp[0..48]; let x_c0 = &uncomp[48..96]; let y_c1 = &uncomp[96..144]; @@ -260,6 +263,23 @@ impl BlsScheme for BlsScChia { let agg_pk = Self::aggregate_pk(pks)?; Self::verify(sig, msg, &agg_pk) } + + /// Hash each message on its own, then verify all pairs in one multi-pairing. + fn verify_aggregates(sig: &Self::InnerSig, msgs: &[&Self::Msg], pks: &[&Self::InnerPk]) -> Result<(), BlsError> { + if pks.len() != msgs.len() { + return Err(BlsError::CountMismatch); + } + if pks.is_empty() { + return Err(BlsError::EmptyAggregation); + } + + let hashes: Vec = msgs.iter().map(|msg| chia_h2c::hash_to_g2(msg)).collect(); + if blst_ffi::pairings_equal_with_g1_generator_prod(sig, &hashes, pks) { + Ok(()) + } else { + Err(BlsError::VerifyFailed) + } + } } #[cfg(test)] @@ -267,7 +287,6 @@ impl BlsScheme for BlsScChia { mod tests { use super::*; use crate::bls::tests::{MSG_DEADBEEF, SEED_0, SEED_1}; - use crate::prelude::*; use dash_dev::{arr_from_hex, Corpus}; use hex_conservative::DisplayHex; diff --git a/pkgs/pkc/src/bls/scheme_ietf.rs b/pkgs/pkc/src/bls/scheme_ietf.rs index b42764e8..73469ac6 100644 --- a/pkgs/pkc/src/bls/scheme_ietf.rs +++ b/pkgs/pkc/src/bls/scheme_ietf.rs @@ -11,6 +11,7 @@ use super::error::BlsError; use super::scheme_ops::{verify_ok, BlsScheme}; use super::schemes::BlsScIetf; use super::sig_id::BlsSigId; +use crate::prelude::*; use blst::min_pk::{AggregatePublicKey, AggregateSignature, PublicKey, SecretKey, Signature}; @@ -153,16 +154,39 @@ impl BlsScheme for BlsScIetf { } verify_ok(sig.fast_aggregate_verify(true, msg, DST_BASIC, pks)) } + + fn verify_aggregates(sig: &Self::InnerSig, msgs: &[&Self::Msg], pks: &[&Self::InnerPk]) -> Result<(), BlsError> { + if pks.len() != msgs.len() { + return Err(BlsError::CountMismatch); + } + if pks.is_empty() { + return Err(BlsError::EmptyAggregation); + } + + // Two equal messages collapse to `e(H(m), pk_a + pk_b)`, proving only + // that someone holds the sum. Absent a proof of possession, one signer + // can pick `pk_b` to cancel `pk_a` and verify without them. + let mut sorted: Vec<&[u8]> = msgs.to_vec(); + sorted.sort_unstable(); + if sorted.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(BlsError::DuplicateMessage); + } + verify_ok(sig.aggregate_verify(true, msgs, DST_BASIC, pks, true)) + } } impl BlsScIetf { - /// Sign under the DST selected by `id`. - pub(crate) fn sign_with(sk: &SecretKey, msg: &[u8], id: BlsSigId) -> Signature { - let dst = match id { + /// The domain separation tag `id` signs and verifies under. + const fn dst_of(id: BlsSigId) -> &'static [u8] { + match id { BlsSigId::Basic => DST_BASIC, BlsSigId::ProofOfPossession => DST_POP, - }; - sk.sign(msg, dst, &[]) + } + } + + /// Sign under the DST selected by `id`. + pub(crate) fn sign_with(sk: &SecretKey, msg: &[u8], id: BlsSigId) -> Signature { + sk.sign(msg, Self::dst_of(id), &[]) } /// Verify under the DST selected by `id`. @@ -171,11 +195,7 @@ impl BlsScIetf { /// /// Returns `VerifyFailed` when the pairing check does not hold. pub(crate) fn verify_with(sig: &Signature, msg: &[u8], pk: &PublicKey, id: BlsSigId) -> Result<(), BlsError> { - let dst = match id { - BlsSigId::Basic => DST_BASIC, - BlsSigId::ProofOfPossession => DST_POP, - }; - verify_ok(sig.verify(true, msg, dst, &[], pk, true)) + verify_ok(sig.verify(true, msg, Self::dst_of(id), &[], pk, true)) } /// Prove possession by signing the public key under the PoP-prove DST. @@ -191,24 +211,6 @@ impl BlsScIetf { pub(crate) fn verify_possession(pk: &PublicKey, pop: &Signature) -> Result<(), BlsError> { verify_ok(pop.verify(true, &pk.compress(), DST_POP_PROVE, &[], pk, true)) } - - /// Verify an aggregated signature where each signer signed a distinct - /// message. - /// - /// # Errors - /// - /// Returns `CountMismatch` when the message and key counts differ, - /// `EmptyAggregation` when no keys are given, or `VerifyFailed` on - /// mismatch. - pub(crate) fn verify_aggregates(sig: &Signature, msgs: &[&[u8]], pks: &[&PublicKey]) -> Result<(), BlsError> { - if pks.len() != msgs.len() { - return Err(BlsError::CountMismatch); - } - if pks.is_empty() { - return Err(BlsError::EmptyAggregation); - } - verify_ok(sig.aggregate_verify(true, msgs, DST_BASIC, pks, true)) - } } #[cfg(test)] @@ -216,7 +218,6 @@ impl BlsScIetf { mod tests { use super::*; use crate::bls::tests::{MSG_DEADBEEF, SEED_0, SEED_1}; - use crate::prelude::*; use dash_dev::{arr_from_hex, vec_from_hex, Corpus}; use hex_conservative::hex; diff --git a/pkgs/pkc/src/bls/scheme_ops.rs b/pkgs/pkc/src/bls/scheme_ops.rs index 10bcfbe2..1cc53ef5 100644 --- a/pkgs/pkc/src/bls/scheme_ops.rs +++ b/pkgs/pkc/src/bls/scheme_ops.rs @@ -169,6 +169,15 @@ pub trait BlsScheme: BlsSchemeId { /// when the aggregate does not verify. fn fast_verify_aggregates(sig: &Self::InnerSig, msg: &Self::Msg, pks: &[&Self::InnerPk]) -> Result<(), BlsError>; + /// Verify an aggregate carrying one message per signer. + /// + /// # Errors + /// + /// Returns `CountMismatch` when the message and key counts differ, + /// `EmptyAggregation` when no keys are given, `DuplicateMessage` where the + /// scheme refuses a repeat, or `VerifyFailed` on mismatch. + fn verify_aggregates(sig: &Self::InnerSig, msgs: &[&Self::Msg], pks: &[&Self::InnerPk]) -> Result<(), BlsError>; + /// Decode a sorted input public key from its 48-byte encoding to a G1 point /// for secure aggregation. /// @@ -196,21 +205,8 @@ pub trait BlsScheme: BlsSchemeId { let mut sorted: Vec<[u8; 48]> = pks.iter().map(|pk| Self::pk_to_bytes(pk)).collect(); sorted.sort_unstable(); - let mut hasher = Sha256::new(); - for pk_bytes in &sorted { - hasher.update(pk_bytes); - } - let pk_hash: [u8; 32] = hasher.finalize().into(); - let mut acc = G1::identity(); - for (i, pk_bytes) in sorted.iter().enumerate() { - // weight = SHA256(i_as_4_bytes_be || pk_hash), reduced by blst_p1_mult. - let mut weight_hasher = Sha256::new(); - weight_hasher.update((i as u32).to_be_bytes()); - weight_hasher.update(pk_hash); - let weight_hash: [u8; 32] = weight_hasher.finalize().into(); - let weight = blst_ffi::scalar_from_bendian(&weight_hash); - + for (pk_bytes, weight) in sorted.iter().zip(secure_weights(&sorted)) { acc = acc + Self::secure_agg_point(pk_bytes)?.mul_scalar(&weight.b, WEIGHT_BITS); } @@ -218,6 +214,41 @@ pub trait BlsScheme: BlsSchemeId { Self::verify(sig, msg, &agg_pk) } + /// Aggregate signatures under the same public-key weighting that + /// [`Self::secure_verify_aggregates`] checks. + /// + /// # Errors + /// + /// Returns `CountMismatch` when the signature and key counts differ, + /// `EmptyAggregation` when nothing is given, or `InvalidSignature` when a + /// signature or the weighted sum fails to decode. + fn secure_aggregate_sig(sigs: &[&Self::InnerSig], pks: &[&Self::InnerPk]) -> Result { + if sigs.len() != pks.len() { + return Err(BlsError::CountMismatch); + } + if sigs.is_empty() { + return Err(BlsError::EmptyAggregation); + } + + // Each signature takes the weight of its own key, so the pairs travel + // together through the same sort the verifying side applies. + let mut paired: Vec<([u8; 48], &Self::InnerSig)> = pks + .iter() + .zip(sigs) + .map(|(pk, sig)| (Self::pk_to_bytes(pk), *sig)) + .collect(); + paired.sort_by_key(|(pk_bytes, _)| *pk_bytes); + + let sorted: Vec<[u8; 48]> = paired.iter().map(|(pk_bytes, _)| *pk_bytes).collect(); + + let mut acc = G2::identity(); + for ((_, sig), weight) in paired.iter().zip(secure_weights(&sorted)) { + acc = acc + Self::sig_to_g2(sig)?.mul_scalar(&weight.b, WEIGHT_BITS); + } + + Self::g2_to_sig(acc) + } + /// Sum multiple secret keys (mod group order). /// /// # Errors @@ -319,6 +350,65 @@ pub trait BlsScheme: BlsSchemeId { Self::g1_to_pk(result) } + + /// Evaluate the master secret polynomial at a participant id, the secret + /// counterpart to [`Self::derive_pk_share`]. + /// + /// The secret and public evaluations are one polynomial over different + /// groups, which is why they share an error contract: two coefficients are + /// the minimum that describes a polynomial, and the id reduces first. + /// + /// # Errors + /// + /// Returns `InvalidVerificationVector` when fewer than two keys are given, + /// `InvalidShareId` on a zero-reducing id, or `InvalidSecretKey` when the + /// result is not a valid scalar. + fn derive_sk_share(master_sks: &[&Self::InnerSk], id: &Hash256) -> Result { + if master_sks.len() < 2 { + return Err(BlsError::InvalidVerificationVector); + } + + let mut coeffs = Zeroizing::new(Vec::with_capacity(master_sks.len())); + for sk in master_sks { + let bytes = Zeroizing::new(Self::sk_to_bytes(sk)); + let mut scalar = blst_ffi::scalar_from_bendian(&bytes); + coeffs.push(Fr::from(&scalar)); + scalar.b.zeroize(); + } + + let x = reduce_id(id)?; + let mut y = poly_eval(&coeffs, &x); + + let mut y_scalar = blst::blst_scalar::from(&y); + let y_bytes = Zeroizing::new(blst_ffi::bendian_from_scalar(&y_scalar)); + y_scalar.b.zeroize(); + y.zeroize(); + + Self::sk_from_bytes(&y_bytes) + } +} + +/// The scalar each key is weighted by, for key encodings in sorted order. +/// +/// `SHA256(index || SHA256(all keys))`, kept in one place so the secure +/// aggregate and its verification cannot drift apart. +fn secure_weights(sorted_pks: &[[u8; 48]]) -> Vec { + let mut hasher = Sha256::new(); + for pk_bytes in sorted_pks { + hasher.update(pk_bytes); + } + let pk_hash: [u8; 32] = hasher.finalize().into(); + + (0..sorted_pks.len()) + .map(|i| { + let mut weight_hasher = Sha256::new(); + // The index is represented on the hash wire as 4-byte unsigned big-endian + weight_hasher.update((i as u32).to_be_bytes()); + weight_hasher.update(pk_hash); + let weight_hash: [u8; 32] = weight_hasher.finalize().into(); + blst_ffi::scalar_from_bendian(&weight_hash) + }) + .collect() } /// Sum secret key scalars (mod group order). diff --git a/pkgs/pkc/src/bls/secret_ops.rs b/pkgs/pkc/src/bls/secret_ops.rs index af0aa0e0..4cc15015 100644 --- a/pkgs/pkc/src/bls/secret_ops.rs +++ b/pkgs/pkc/src/bls/secret_ops.rs @@ -6,6 +6,7 @@ //! Scheme-generic BLS secret key. +use super::dh_bytes::BlsDhBytes; use super::error::BlsError; use super::public_ops::BlsPublicKey; use super::scheme_ops::BlsScheme; @@ -62,16 +63,13 @@ impl BlsSecretKey { /// Compute a DH shared key: `self * peer_pk`. /// - /// The result is secret material despite its [`BlsPublicKey`] type: it is a - /// shared secret, so it must not be published, logged, or compared - /// non-uniformly the way a real public key may be. - /// /// # Errors /// /// Returns `InvalidPublicKey` when the peer key or the product point /// is invalid. - pub fn dh_exchange(&self, peer_pk: &BlsPublicKey) -> Result, BlsError> { - S::dh_exchange(&self.0, &peer_pk.0).map(BlsPublicKey::from_inner) + pub fn dh_exchange(&self, peer_pk: &BlsPublicKey) -> Result, BlsError> { + let shared = S::dh_exchange(&self.0, &peer_pk.0)?; + Ok(BlsDhBytes::from_bytes(S::pk_to_bytes(&shared))) } /// Sum multiple secret keys (mod group order). @@ -236,6 +234,27 @@ mod tests { assert_ne!(chia.public_key().to_bytes(), ietf.public_key().to_bytes()); } + fn assert_codec_roundtrip() { + use dash_types::codec::BaseCodec; + + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let mut buf = Vec::new(); + sk.encode(&mut buf); + assert_eq!(buf.len(), 32); + + let mut slice = buf.as_slice(); + let decoded = BlsSecretKey::::decode(&mut slice).unwrap(); + assert_eq!(decoded.to_bytes(), sk.to_bytes()); + assert!(slice.is_empty()); + } + + #[rstest] + #[case::chia(assert_codec_roundtrip::)] + #[case::ietf(assert_codec_roundtrip::)] + fn codec_roundtrip(#[case] assertion: fn()) { + assertion(); + } + /// Summing scalars is scheme-independent, so one corpus serves both. fn assert_aggregate_vectors() { let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), "bls_aggregate"); diff --git a/pkgs/pkc/src/bls/share_ops.rs b/pkgs/pkc/src/bls/share_ops.rs index 61dec5af..36ddd9e9 100644 --- a/pkgs/pkc/src/bls/share_ops.rs +++ b/pkgs/pkc/src/bls/share_ops.rs @@ -145,6 +145,19 @@ impl BlsSecretKey { BlsSkShare::new(id, BlsSecretKey::from_inner(inner)) }) } + + /// Derive a secret key share by evaluating the master secret polynomial at + /// the given participant id. + /// + /// # Errors + /// + /// Returns `InvalidVerificationVector` when fewer than two master keys are + /// given, `InvalidShareId` on a zero-reducing id, or `InvalidSecretKey` + /// when the result is not a valid scalar. + pub fn derive_share(master_sks: &[&Self], id: &Hash256) -> Result { + let inner_refs: Vec<&S::InnerSk> = master_sks.iter().map(|sk| &sk.0).collect(); + S::derive_sk_share(&inner_refs, id).map(Self::from_inner) + } } impl BlsPublicKey { @@ -166,9 +179,10 @@ impl BlsPublicKey { #[expect(clippy::unwrap_used, reason = "test code")] mod tests { use super::*; - use crate::bls::tests::{hash_from_hex, make_id, sequential_ids, GROUP_ORDER, SEED_0}; + use crate::bls::tests::{hash_from_hex, make_id, sequential_ids, GROUP_ORDER, MSG_DEADBEEF, RSEED, SEED_0, SEED_1}; use crate::bls::{BlsScChia, BlsScIetf}; + use cfg_if::cfg_if; use dash_dev::{arr_from_hex, Corpus, Value}; use hex_conservative::DisplayHex; use rand_core::OsRng; @@ -245,6 +259,42 @@ mod tests { assertion(); } + /// The secret and public evaluations are the same polynomial, so a derived + /// secret share must expose exactly the public share derived from the + /// verification vector. + fn assert_sk_share_matches_pk_share() { + let master: Vec> = [&RSEED[0], &RSEED[1], &RSEED[2]] + .iter() + .map(|ikm| BlsSecretKey::::generate(*ikm).unwrap()) + .collect(); + let master_refs: Vec<&BlsSecretKey> = master.iter().collect(); + let vvec: Vec> = master.iter().map(BlsSecretKey::public_key).collect(); + let vvec_refs: Vec<&BlsPublicKey> = vvec.iter().collect(); + + for i in 1..=4u32 { + let id = make_id(i); + let sk_share = BlsSecretKey::::derive_share(&master_refs, &id).unwrap(); + let pk_share = BlsPublicKey::::derive_share(&vvec_refs, &id).unwrap(); + assert_eq!(sk_share.public_key(), pk_share); + } + + assert!(matches!( + BlsSecretKey::::derive_share(&master_refs[..1], &make_id(1)), + Err(BlsError::InvalidVerificationVector) + )); + assert!(matches!( + BlsSecretKey::::derive_share(&master_refs, &Hash256::from_bytes([0u8; 32])), + Err(BlsError::InvalidShareId) + )); + } + + #[rstest] + #[case::chia(assert_sk_share_matches_pk_share::)] + #[case::ietf(assert_sk_share_matches_pk_share::)] + fn sk_share_matches_pk_share(#[case] assertion: fn()) { + assertion(); + } + /// Evaluating the verification-vector polynomial needs at least two /// coefficients, so a single master key is rejected. fn assert_derive_share_rejects_short_vv() { @@ -579,4 +629,77 @@ mod tests { fn llmq_finalize_aggregated_member_sigs(#[case] corpus: &str, #[case] assertion: fn(&str)) { assertion(corpus); } + + /// `Hash` wants a `core::hash::Hasher`, which is not the interface the + /// crate's digests expose and which `no_std` doesn't provide a default for. + struct TestHasher(u64); + + impl Hasher for TestHasher { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.0 = (self.0 ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3); + } + } + } + + fn hash_of(value: &T) -> u64 { + let mut hasher = TestHasher(0xcbf2_9ce4_8422_2325); + value.hash(&mut hasher); + hasher.finish() + } + + /// Both impls are written out rather than derived, so nothing stops one from + /// quietly dropping a field. Shares agreeing on id and signature compare and + /// hash alike; changing either separates them. + fn assert_share_eq_and_hash() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let other_sk = BlsSecretKey::::generate(&SEED_1).unwrap(); + let msg = S::msg_ref(&MSG_DEADBEEF); + + let share = BlsSkShare::new(make_id(1), sk.clone()).sign(msg); + let same = BlsSkShare::new(make_id(1), sk.clone()).sign(msg); + + // One field varies at a time, or a dropped field would hide behind the other + // still differing. + let other_id = BlsSkShare::new(make_id(2), sk).sign(msg); + let other_sig = BlsSkShare::new(make_id(1), other_sk).sign(msg); + + assert_eq!(share, same); + assert_eq!(hash_of(&share), hash_of(&same)); + + assert_ne!(share, other_id); + assert_ne!(hash_of(&share), hash_of(&other_id)); + + assert_ne!(share, other_sig); + assert_ne!(hash_of(&share), hash_of(&other_sig)); + } + + #[rstest] + #[case::chia(assert_share_eq_and_hash::)] + #[case::ietf(assert_share_eq_and_hash::)] + fn share_equality_and_hashing(#[case] assertion: fn()) { + assertion(); + } + + cfg_if! { + if #[cfg(feature = "serde")] { + use dash_dev::assert_json_rt; + + fn assert_share_serde_roundtrip() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + assert_json_rt(&BlsSkShare::new(make_id(1), sk).sign(S::msg_ref(&MSG_DEADBEEF))); + } + + #[rstest] + #[case::chia(assert_share_serde_roundtrip::)] + #[case::ietf(assert_share_serde_roundtrip::)] + fn share_serde_roundtrip(#[case] assertion: fn()) { + assertion(); + } + } + } } diff --git a/pkgs/pkc/src/bls/sig_aggregate.rs b/pkgs/pkc/src/bls/sig_aggregate.rs index fa4e8545..b8642eee 100644 --- a/pkgs/pkc/src/bls/sig_aggregate.rs +++ b/pkgs/pkc/src/bls/sig_aggregate.rs @@ -10,7 +10,6 @@ use super::error::BlsError; use super::public_ops::BlsPublicKey; use super::scheme_ops::BlsScheme; use super::sig_basic::BlsSignature; -use super::BlsScIetf; use crate::prelude::*; impl BlsSignature { @@ -44,6 +43,21 @@ impl BlsSignature { S::fast_verify_aggregates(&self.0, msg, &inner_pks) } + /// Aggregate signatures under public-key weighting, the counterpart to + /// [`Self::secure_verify_aggregates`]: each signature is raised to its own + /// key's weight, so a rogue key cannot cancel an honest one. + /// + /// # Errors + /// + /// Returns `CountMismatch` when the signature and key counts differ, + /// `EmptyAggregation` when nothing is given, or `InvalidSignature` when a + /// signature or the weighted sum fails to decode. + pub fn secure_aggregate(sigs: &[&Self], pks: &[&BlsPublicKey]) -> Result { + let inner_sigs: Vec<&S::InnerSig> = sigs.iter().map(|sig| &sig.0).collect(); + let inner_pks: Vec<&S::InnerPk> = pks.iter().map(|pk| &pk.0).collect(); + S::secure_aggregate_sig(&inner_sigs, &inner_pks).map(Self::from_inner) + } + /// Securely aggregate and verify signatures with public-key /// weighting. /// @@ -57,18 +71,17 @@ impl BlsSignature { } } -impl BlsSignature { - /// Verify an aggregated signature where each signer signed a distinct - /// message. +impl BlsSignature { + /// Verify an aggregate carrying one message per signer. /// /// # Errors /// /// Returns `CountMismatch` when the message and key counts differ, - /// `EmptyAggregation` when no keys are given, or `VerifyFailed` on - /// mismatch. - pub fn verify_aggregates(&self, msgs: &[&[u8]], pks: &[&BlsPublicKey]) -> Result<(), BlsError> { + /// `EmptyAggregation` when no keys are given, `DuplicateMessage` where the + /// scheme refuses a repeat, or `VerifyFailed` on mismatch. + pub fn verify_aggregates(&self, msgs: &[&S::Msg], pks: &[&BlsPublicKey]) -> Result<(), BlsError> { let inner_pks: Vec<_> = pks.iter().map(|k| &k.0).collect(); - BlsScIetf::verify_aggregates(&self.0, msgs, &inner_pks) + S::verify_aggregates(&self.0, msgs, &inner_pks) } } @@ -89,6 +102,7 @@ mod tests { struct SecureVec { msg: String, pks: Vec, + sigs: Vec, agg_sig_secure: String, } @@ -98,6 +112,14 @@ mod tests { agg_sig: String, } + #[derive(Deserialize)] + struct AggVerifyVec { + pks: Vec, + msgs: Vec, + agg_sig: String, + accepted: bool, + } + fn assert_aggregate_same_message() { let sk1 = BlsSecretKey::::generate(&SEED_0).unwrap(); let sk2 = BlsSecretKey::::generate(&SEED_1).unwrap(); @@ -147,21 +169,201 @@ mod tests { assertion(corpus); } - #[rstest] - fn ietf_verify_distinct_messages() { - let sk1 = BlsSecretKey::::generate(&SEED_0).unwrap(); - let sk2 = BlsSecretKey::::generate(&SEED_1).unwrap(); + /// Per-signer messages verify, and each binds to its own signer, swapping + /// the two fails. Both schemes agree here, along with the count and + /// emptiness contracts. + fn assert_distinct_messages_verify() { + let sk1 = BlsSecretKey::::generate(&SEED_0).unwrap(); + let sk2 = BlsSecretKey::::generate(&SEED_1).unwrap(); - let msg1: &[u8] = b"first message"; - let msg2: &[u8] = b"second message"; + let msg1 = S::msg_ref(&[0x11u8; 32]); + let msg2 = S::msg_ref(&MSG_DEADBEEF); let sig1 = sk1.sign(msg1); let sig2 = sk2.sign(msg2); - let agg = BlsSignature::aggregate(&[&sig1, &sig2]).unwrap(); + let agg = BlsSignature::::aggregate(&[&sig1, &sig2]).unwrap(); let pk1 = sk1.public_key(); let pk2 = sk2.public_key(); assert!(agg.verify_aggregates(&[msg1, msg2], &[&pk1, &pk2]).is_ok()); assert!(agg.verify_aggregates(&[msg2, msg1], &[&pk1, &pk2]).is_err()); + + assert_eq!( + agg.verify_aggregates(&[msg1], &[&pk1, &pk2]), + Err(BlsError::CountMismatch) + ); + assert_eq!(agg.verify_aggregates(&[], &[]), Err(BlsError::EmptyAggregation)); + } + + #[rstest] + #[case::chia(assert_distinct_messages_verify::)] + #[case::ietf(assert_distinct_messages_verify::)] + fn verify_aggregate_distinct_messages(#[case] assertion: fn()) { + assertion(); + } + + /// A repeat collapses the check onto the sum of the repeated signers' keys, + /// which either could have picked to cancel the other. IETF refuses it; Chia + /// accepts. + fn assert_duplicate_message_policy(accepted: bool) { + let sk1 = BlsSecretKey::::generate(&SEED_0).unwrap(); + let sk2 = BlsSecretKey::::generate(&SEED_1).unwrap(); + + let msg = S::msg_ref(&MSG_DEADBEEF); + let sig1 = sk1.sign(msg); + let sig2 = sk2.sign(msg); + let agg = BlsSignature::::aggregate(&[&sig1, &sig2]).unwrap(); + + let pk1 = sk1.public_key(); + let pk2 = sk2.public_key(); + let res = agg.verify_aggregates(&[msg, msg], &[&pk1, &pk2]); + assert_eq!(res.is_ok(), accepted, "duplicate-message policy"); + if !accepted { + assert_eq!(res, Err(BlsError::DuplicateMessage)); + } + + // Sound results either way through the shared-message entry point, so the + // refusal above is a matter of policy and not a bad aggregate. + assert!(agg.fast_verify_aggregates(msg, &[&pk1, &pk2]).is_ok()); + } + + #[rstest] + #[case::chia(assert_duplicate_message_policy::, true)] + #[case::ietf(assert_duplicate_message_policy::, false)] + fn verify_policy_duplicate_messages(#[case] assertion: fn(bool), #[case] accepted: bool) { + assertion(accepted); + } + + fn assert_aggregate_verify_matches_vectors(corpus: &str) { + let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), corpus); + let vecs: Vec = corpus.vectors("aggregate_verify"); + assert!(!vecs.is_empty(), "corpus section is empty"); + + for v in &vecs { + let pks: Vec> = v + .pks + .iter() + .map(|pk| BlsPublicKey::::from_bytes(&arr_from_hex(pk)).unwrap()) + .collect(); + let pk_refs: Vec<&BlsPublicKey> = pks.iter().collect(); + let msgs: Vec<[u8; 32]> = v.msgs.iter().map(|m| arr_from_hex(m)).collect(); + let msg_refs: Vec<&S::Msg> = msgs.iter().map(|m| S::msg_ref(m)).collect(); + let agg = BlsSignature::::from_bytes(&arr_from_hex(&v.agg_sig)).unwrap(); + assert_eq!(agg.to_bytes().to_lower_hex_string(), v.agg_sig); + + assert_eq!( + agg.verify_aggregates(&msg_refs, &pk_refs).is_ok(), + v.accepted, + "reference verdict for {} signers", + v.pks.len() + ); + } + } + + #[rstest] + #[case::chia(assert_aggregate_verify_matches_vectors::, "bls_chia_aggregate")] + #[case::ietf(assert_aggregate_verify_matches_vectors::, "bls_ietf_aggregate")] + fn aggregate_verify_matches_vectors(#[case] assertion: fn(&str), #[case] corpus: &str) { + assertion(corpus); + } + + /// The weighted aggregate is what the weighted verify accepts, and the + /// weights follow the sorted keys rather than the caller's order, so the + /// same set aggregates alike however it is presented. + fn assert_secure_aggregate_round_trips() { + let sk1 = BlsSecretKey::::generate(&SEED_0).unwrap(); + let sk2 = BlsSecretKey::::generate(&SEED_1).unwrap(); + let msg = S::msg_ref(&MSG_DEADBEEF); + + let sig1 = sk1.sign(msg); + let sig2 = sk2.sign(msg); + let pk1 = sk1.public_key(); + let pk2 = sk2.public_key(); + + let agg = BlsSignature::::secure_aggregate(&[&sig1, &sig2], &[&pk1, &pk2]).unwrap(); + assert!(agg.secure_verify_aggregates(msg, &[&pk1, &pk2]).is_ok()); + + let swapped = BlsSignature::::secure_aggregate(&[&sig2, &sig1], &[&pk2, &pk1]).unwrap(); + assert_eq!(agg, swapped, "weights follow the keys, not the argument order"); + + // A plain aggregate carries no weights, so the weighted check rejects it. + let plain = BlsSignature::::aggregate(&[&sig1, &sig2]).unwrap(); + assert!(plain.secure_verify_aggregates(msg, &[&pk1, &pk2]).is_err()); + + assert_eq!( + BlsSignature::::secure_aggregate(&[&sig1], &[&pk1, &pk2]), + Err(BlsError::CountMismatch) + ); + assert_eq!( + BlsSignature::::secure_aggregate(&[], &[]), + Err(BlsError::EmptyAggregation) + ); + } + + /// A wrong sort order or weight formula would still round-trip against our + /// own verifier above, so only the recorded aggregate can catch a convention + /// that is self-consistent and still not canonical. + fn assert_secure_aggregate_matches_vectors(corpus: &str) { + let corpus = Corpus::open(env!("CARGO_MANIFEST_DIR"), corpus); + let vecs: Vec = corpus.vectors("secure_verify_aggregates"); + for v in &vecs { + let pks: Vec> = v + .pks + .iter() + .map(|pk| BlsPublicKey::::from_bytes(&arr_from_hex(pk)).unwrap()) + .collect(); + let sigs: Vec> = v + .sigs + .iter() + .map(|sig| BlsSignature::::from_bytes(&arr_from_hex(sig)).unwrap()) + .collect(); + let pk_refs: Vec<&BlsPublicKey> = pks.iter().collect(); + let sig_refs: Vec<&BlsSignature> = sigs.iter().collect(); + + let agg = BlsSignature::::secure_aggregate(&sig_refs, &pk_refs).unwrap(); + assert_eq!(agg.to_bytes().to_lower_hex_string(), v.agg_sig_secure); + } + } + + #[rstest] + #[case::chia(assert_secure_aggregate_matches_vectors::, "bls_chia_secure_aggregate")] + #[case::ietf(assert_secure_aggregate_matches_vectors::, "bls_ietf_secure_aggregate")] + fn secure_aggregate_matches_vectors(#[case] assertion: fn(&str), #[case] corpus: &str) { + assertion(corpus); + } + + #[rstest] + #[case::chia(assert_secure_aggregate_round_trips::)] + #[case::ietf(assert_secure_aggregate_round_trips::)] + fn secure_aggregate_round_trips(#[case] assertion: fn()) { + assertion(); + } + + /// Weights go by position in the sorted keys, so a repeated key leaves two + /// weights and nothing in the keys to say which signature takes which. + /// + /// A stable sort settles that by argument order and an unstable one settles + /// it otherwise, so a duplicated key costs the caller a canonical aggregate + /// whichever implementation computes it. + /// + /// [`secure_aggregate_round_trips`] holds the distinct-key case, where the + /// keys give a total order and the argument order stops mattering. + fn assert_duplicate_key_pairing_is_order_bound() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let pk = sk.public_key(); + + let sig_a = sk.sign(S::msg_ref(&[0x11u8; 32])); + let sig_b = sk.sign(S::msg_ref(&MSG_DEADBEEF)); + + let ab = BlsSignature::::secure_aggregate(&[&sig_a, &sig_b], &[&pk, &pk]).unwrap(); + let ba = BlsSignature::::secure_aggregate(&[&sig_b, &sig_a], &[&pk, &pk]).unwrap(); + assert_ne!(ab, ba, "a repeated key leaves the pairing to the caller's order"); + } + + #[rstest] + #[case::chia(assert_duplicate_key_pairing_is_order_bound::)] + #[case::ietf(assert_duplicate_key_pairing_is_order_bound::)] + fn secure_aggregate_duplicate_key_pairing(#[case] assertion: fn()) { + assertion(); } /// An empty aggregate has no signers to bind, so both aggregation entry @@ -259,6 +461,25 @@ mod tests { } } + /// An identity aggregate encodes to the canonical infinity form, `0xc0` over + /// zeros. The decoder refuses that encoding as the identity is reachable + /// by computation, not off the wire. + #[rstest] + fn chia_identity_encodes_canonically() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let sig = sk.sign(&[0x11u8; 32]); + + let mut neg_bytes = sig.to_bytes(); + neg_bytes[0] ^= 0x80; + let neg_sig = BlsSignature::::from_bytes(&neg_bytes).unwrap(); + let identity = BlsSignature::::aggregate(&[&sig, &neg_sig]).unwrap(); + + let mut expected = [0u8; 96]; + expected[0] = 0xc0; + assert_eq!(identity.to_bytes(), expected); + assert!(BlsSignature::::from_bytes(&expected).is_err()); + } + #[rstest] #[case::chia(assert_identity_cancellation::, 0x80, true)] #[case::ietf(assert_identity_cancellation::, 0x20, false)] diff --git a/pkgs/pkc/src/bls/sig_basic.rs b/pkgs/pkc/src/bls/sig_basic.rs index 0f73983b..6afc4847 100644 --- a/pkgs/pkc/src/bls/sig_basic.rs +++ b/pkgs/pkc/src/bls/sig_basic.rs @@ -42,6 +42,19 @@ impl BlsSignature { S::sig_to_bytes(&self.0) } + /// Re-encode this signature under another scheme. + /// + /// The signature is lifted to its point and lowered again, so the target + /// scheme's admission rules apply. Message augmentation is unaffected and a + /// converted signature still verifies only under the scheme that produced it. + /// + /// # Errors + /// + /// Returns `InvalidSignature` when the target scheme refuses the point. + pub fn to_scheme(&self) -> Result, BlsError> { + T::g2_to_sig(S::sig_to_g2(&self.0)?).map(BlsSignature::from_inner) + } + /// Verify over a message of the scheme's message type. /// /// # Errors @@ -111,7 +124,7 @@ type_cvrt!(for[S: BlsScheme] TryFrom> for BlsSignature, BlsErr mod tests { use super::*; use crate::bls::secret_ops::BlsSecretKey; - use crate::bls::tests::{MSG_DEADBEEF, SEED_0, SEED_1}; + use crate::bls::tests::{G2_OFF_SUBGROUP_CHIA, G2_OFF_SUBGROUP_IETF, MSG_DEADBEEF, SEED_0, SEED_1}; use crate::bls::{BlsScChia, BlsScIetf}; use crate::prelude::*; @@ -153,6 +166,38 @@ mod tests { assertion(); } + /// The byte-oriented IETF entry points must bind a signature to the selected + /// DST. Correct variants verify, while the other variant, another message, + /// and another key all fail. + #[rstest] + fn ietf_signature_variant_contract() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let pk = sk.public_key(); + let other_pk = BlsSecretKey::::generate(&SEED_1).unwrap().public_key(); + let msg = b"variant-bound message"; + let wrong_msg = b"another message"; + + for (variant, other) in [ + (BlsSigId::Basic, BlsSigId::ProofOfPossession), + (BlsSigId::ProofOfPossession, BlsSigId::Basic), + ] { + let sig = sk.sign_with(msg, variant); + assert!(sig.verify_with(msg, &pk, variant).is_ok()); + assert!(sig.verify_with(msg, &pk, other).is_err()); + assert!(sig.verify_with(wrong_msg, &pk, variant).is_err()); + assert!(sig.verify_with(msg, &other_pk, variant).is_err()); + + let decoded = BlsSignature::::from_bytes(&sig.to_bytes()).unwrap(); + assert!(decoded.verify_with(msg, &pk, variant).is_ok()); + } + + assert_ne!( + sk.sign_with(msg, BlsSigId::Basic), + sk.sign_with(msg, BlsSigId::ProofOfPossession), + "different DSTs must produce different signatures", + ); + } + /// BLS signing draws no randomness, so the same key over the same message /// yields the same signature every time. fn assert_sign_is_deterministic() { @@ -181,15 +226,53 @@ mod tests { assertion(); } - /// The legacy decoder rejects the all-zero encoding and the infinity marker - /// rather than yielding an identity signature. + /// A small fixed vector set can miss one half of the compressed-point sign + /// convention. Exercise many deterministic keys and require both sign-bit + /// branches to round-trip for public keys and signatures. + fn assert_many_serialization_roundtrips(sign_bit: u8) { + let mut pk_signs = [false; 2]; + let mut sig_signs = [false; 2]; + + for seed_byte in 0..64u8 { + let sk = BlsSecretKey::::generate(&[seed_byte; 32]).unwrap(); + let pk = sk.public_key(); + let sig = sk.sign(S::msg_ref(&MSG_DEADBEEF)); + + let pk_bytes = pk.to_bytes(); + let sig_bytes = sig.to_bytes(); + pk_signs[usize::from(pk_bytes[0] & sign_bit != 0)] = true; + sig_signs[usize::from(sig_bytes[0] & sign_bit != 0)] = true; + + assert_eq!(BlsPublicKey::::from_bytes(&pk_bytes).unwrap(), pk); + assert_eq!(BlsSignature::::from_bytes(&sig_bytes).unwrap(), sig); + } + + assert!(pk_signs.into_iter().all(core::convert::identity)); + assert!(sig_signs.into_iter().all(core::convert::identity)); + } + #[rstest] - fn chia_rejects_identity_signature() { - assert!(BlsSignature::::from_bytes(&[0u8; 96]).is_err()); + #[case::chia(assert_many_serialization_roundtrips::, 0x80)] + #[case::ietf(assert_many_serialization_roundtrips::, 0x20)] + fn many_serialization_roundtrips(#[case] assertion: fn(u8), #[case] sign_bit: u8) { + assertion(sign_bit); + } + /// Neither decoder yields an identity signature. Chia guards the marker and + /// the all-zero buffer outright, IETF reaches the same answer through + /// `validate`, which refuses the identity despite a canonical encoding. + fn assert_identity_signature_rejected() { let mut infinity = [0u8; 96]; infinity[0] = 0xc0; - assert!(BlsSignature::::from_bytes(&infinity).is_err()); + assert!(BlsSignature::::from_bytes(&infinity).is_err()); + assert!(BlsSignature::::from_bytes(&[0u8; 96]).is_err()); + } + + #[rstest] + #[case::chia(assert_identity_signature_rejected::)] + #[case::ietf(assert_identity_signature_rejected::)] + fn identity_signature_rejected(#[case] assertion: fn()) { + assertion(); } /// Only bit 7 of byte 0 is the legacy sign flag, and unlike G1 the legacy @@ -213,14 +296,44 @@ mod tests { assert!(BlsSignature::::from_bytes(&mutated).is_err()); } - /// The IETF decoder runs `validate`, which rejects the identity even though - /// its encoding is canonical. + /// Rejection alone is weak evidence, since the policy test below cannot + /// tell a composite-order point from a malformed one. Hold both encodings + /// to a single point so that distinction is made here. #[rstest] - fn ietf_rejects_identity_signature() { - let mut infinity = [0u8; 96]; - infinity[0] = 0xc0; - assert!(BlsSignature::::from_bytes(&infinity).is_err()); - assert!(BlsSignature::::from_bytes(&[0u8; 96]).is_err()); + fn off_subgroup_g2_fixtures_are_one_point() { + let chia = BlsSignature::::from_bytes(&G2_OFF_SUBGROUP_CHIA).unwrap(); + let point = BlsScChia::sig_to_g2(&chia.0).unwrap(); + + assert!(!point.in_subgroup(), "fixture is not off-subgroup"); + assert_eq!( + point.to_affine().compress(), + G2_OFF_SUBGROUP_IETF, + "the IETF fixture encodes a different point" + ); + } + + /// Chia has no prime-order subgroup check, so a composite-order G2 point + /// decodes and round-trips. IETF validates and refuses the same point in + /// its own encoding. + fn assert_off_subgroup_signature_policy(encoded: &[u8; 96], accepted: bool) { + match BlsSignature::::from_bytes(encoded) { + Ok(sig) => { + assert!(accepted, "off-subgroup signature accepted"); + assert_eq!(sig.to_bytes(), *encoded); + } + Err(_) => assert!(!accepted, "off-subgroup signature rejected"), + } + } + + #[rstest] + #[case::chia(assert_off_subgroup_signature_policy::, &G2_OFF_SUBGROUP_CHIA, true)] + #[case::ietf(assert_off_subgroup_signature_policy::, &G2_OFF_SUBGROUP_IETF, false)] + fn off_subgroup_signature_policy( + #[case] assertion: fn(&[u8; 96], bool), + #[case] encoded: &[u8; 96], + #[case] accepted: bool, + ) { + assertion(encoded, accepted); } /// The same G2 point encodes differently under the two schemes, and each @@ -270,9 +383,50 @@ mod tests { assert_ne!(chia.sign(&MSG_DEADBEEF).to_bytes(), ietf.sign(&MSG_DEADBEEF).to_bytes()); } + /// Conversion re-encodes one point, so a round trip returns the original. + fn assert_sig_scheme_conversion_round_trips() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let sig = sk.sign(S::msg_ref(&MSG_DEADBEEF)); + let there = sig.to_scheme::().unwrap(); + + assert_eq!(there.to_scheme::().unwrap().to_bytes(), sig.to_bytes()); + } + + #[rstest] + #[case::chia_to_ietf(assert_sig_scheme_conversion_round_trips::)] + #[case::ietf_to_chia(assert_sig_scheme_conversion_round_trips::)] + #[case::chia_to_chia(assert_sig_scheme_conversion_round_trips::)] + fn sig_scheme_conversion_round_trips(#[case] assertion: fn()) { + assertion(); + } + + /// Conversion moves the encoding and nothing else, so the signature still + /// answers to the scheme that made it: the message is hashed differently + /// under each, and a converted signature verifies under neither the target's + /// hash nor the target's key. + #[rstest] + fn sig_scheme_conversion_does_not_move_the_augmentation() { + let sk = BlsSecretKey::::generate(&SEED_0).unwrap(); + let sig = sk.sign(&MSG_DEADBEEF); + + let converted = sig.to_scheme::().unwrap(); + let pk_ietf = sk.public_key().to_scheme::().unwrap(); + assert!(converted.verify(&MSG_DEADBEEF, &pk_ietf).is_err()); + } + + /// A signature Chia admits and IETF does not must not become an IETF one by + /// being converted. + #[rstest] + fn sig_scheme_conversion_applies_target_rules() { + let off_subgroup = BlsSignature::::from_bytes(&G2_OFF_SUBGROUP_CHIA).unwrap(); + + assert!(off_subgroup.to_scheme::().is_ok()); + assert!(off_subgroup.to_scheme::().is_err()); + } + cfg_if! { if #[cfg(feature = "serde")] { - use dash_dev::assert_json_rt; + use dash_dev::{assert_json_rt, to_json}; /// The wrapper serializes through the byte bag, so the round-trip is /// pinned per scheme. @@ -283,6 +437,19 @@ mod tests { let ietf = BlsSecretKey::::generate(&SEED_0).unwrap(); assert_json_rt(&ietf.sign(&MSG_DEADBEEF)); } + + #[rstest] + fn serde_emits_hex_string() { + let chia = BlsSecretKey::::generate(&SEED_0) + .unwrap() + .sign(&MSG_DEADBEEF); + let ietf = BlsSecretKey::::generate(&SEED_0) + .unwrap() + .sign(&MSG_DEADBEEF); + + assert_eq!(to_json(&chia), format!("\"{}\"", chia.to_bytes().to_lower_hex_string())); + assert_eq!(to_json(&ietf), format!("\"{}\"", ietf.to_bytes().to_lower_hex_string())); + } } } } diff --git a/pkgs/pkc/src/bls/sig_threshold.rs b/pkgs/pkc/src/bls/sig_threshold.rs index 85c2b20b..47166551 100644 --- a/pkgs/pkc/src/bls/sig_threshold.rs +++ b/pkgs/pkc/src/bls/sig_threshold.rs @@ -35,10 +35,12 @@ impl BlsSignature { #[expect(clippy::unwrap_used, reason = "test code")] mod tests { use crate::bls::scheme_ops::BlsScheme; - use crate::bls::tests::{sequential_ids, MSG_DEADBEEF, SEED_0}; - use crate::bls::{BlsError, BlsScChia, BlsScIetf, BlsSecretKey, BlsSigShare, BlsSignature}; + use crate::bls::tests::{make_id, sequential_ids, MSG_DEADBEEF, SEED_0}; + use crate::bls::{BlsError, BlsScChia, BlsScIetf, BlsSecretKey, BlsSigShare, BlsSignature, BlsSkShare}; use crate::prelude::*; + use dash_dev::{arr_from_hex, Corpus, Value}; + use hex_conservative::DisplayHex; use rand_core::OsRng; use rstest::rstest; @@ -119,4 +121,68 @@ mod tests { fn recover_rejects_insufficient_shares(#[case] assertion: fn()) { assertion(); } + + /// Shares come from the corpus rather than a fresh `split`, whose random + /// polynomial leaves nothing to assert against but a round trip. `full_sig` + /// cross-checks interpolation against the master's own signature. + fn assert_recovery_matches_vectors(corpus: &str) { + let f: Value = Corpus::open(env!("CARGO_MANIFEST_DIR"), corpus).into_value(); + let case = &f["recover_sig"]; + let inputs = &case["inputs"]; + let threshold = inputs["t"].as_u64().unwrap() as usize; + let total = inputs["n"].as_u64().unwrap() as usize; + let msg: [u8; 32] = arr_from_hex(inputs["msg"].as_str().unwrap()); + + // The polynomial's constant term is the master secret key. + let master = BlsSecretKey::::from_bytes(&arr_from_hex(inputs["master_sks"][0].as_str().unwrap())).unwrap(); + + for out in case["outputs"].as_array().unwrap() { + let sk_shares = out["sk_shares"].as_array().unwrap(); + let sig_shares = out["sig_shares"].as_array().unwrap(); + assert_eq!(sk_shares.len(), total); + assert_eq!(sig_shares.len(), total); + + // Each share key signs the message to its recorded signature share. + for (i, (sk_hex, sig_hex)) in sk_shares.iter().zip(sig_shares).enumerate() { + let sk = BlsSecretKey::::from_bytes(&arr_from_hex(sk_hex.as_str().unwrap())).unwrap(); + let share = BlsSkShare::new(make_id(i as u32 + 1), sk); + let signed = share.sign(S::msg_ref(&msg)); + assert_eq!( + signed.signature().to_bytes().to_lower_hex_string(), + sig_hex.as_str().unwrap() + ); + } + + // Recover from the recorded shares, so interpolation is pinned even if + // share signing were to regress. + let ids = out["recover_ids"].as_array().unwrap(); + assert_eq!(ids.len(), threshold); + let picked: Vec> = ids + .iter() + .map(|id| { + let i = id.as_u64().unwrap() as usize; + let sig = BlsSignature::::from_bytes(&arr_from_hex(sig_shares[i - 1].as_str().unwrap())).unwrap(); + BlsSigShare::new(make_id(i as u32), sig) + }) + .collect(); + let refs: Vec<&BlsSigShare> = picked.iter().collect(); + let recovered = BlsSignature::::recover(&refs).unwrap(); + + let expected = out["recovered_sig"].as_str().unwrap(); + assert_eq!(recovered.to_bytes().to_lower_hex_string(), expected); + assert_eq!( + out["full_sig"].as_str().unwrap(), + expected, + "recovery must match the master" + ); + assert_eq!(master.sign(S::msg_ref(&msg)).to_bytes().to_lower_hex_string(), expected); + } + } + + #[rstest] + #[case::chia(assert_recovery_matches_vectors::, "bls_chia_threshold")] + #[case::ietf(assert_recovery_matches_vectors::, "bls_ietf_threshold")] + fn recovery_matches_vectors(#[case] assertion: fn(&str), #[case] corpus: &str) { + assertion(corpus); + } } diff --git a/pkgs/pkc/src/bls/tests.rs b/pkgs/pkc/src/bls/tests.rs index dd6acbe1..9865a74a 100644 --- a/pkgs/pkc/src/bls/tests.rs +++ b/pkgs/pkc/src/bls/tests.rs @@ -25,6 +25,50 @@ pub const RSEED: [[u8; 32]; 4] = [[0u8; 32], [1u8; 32], [2u8; 32], [3u8; 32]]; /// Test message. pub const MSG_DEADBEEF: [u8; 32] = hex!("deadbeefdeadbeefdeadbeefdeadbeefcafebabecafebabecafebabecafebabe"); +/// Smallest off-subgroup G1 point, Chia-encoded: `x = 4` is the least `x` +/// with `x^3 + 4` a residue mod `p` and `[r]P != O`. +pub const G1_OFF_SUBGROUP_CHIA: [u8; 48] = + hex!("000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004"); + +/// [`G1_OFF_SUBGROUP_CHIA`] in the IETF encoding: compression bit set, sign +/// bit clear. +pub const G1_OFF_SUBGROUP_IETF: [u8; 48] = + hex!("800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004"); + +/// [`G1_OFF_SUBGROUP_CHIA`] with the field prime added to `x`, so the +/// coordinate is out of range while the flag bits stay untouched. +pub const G1_X_GE_PRIME_CHIA: [u8; 48] = + hex!("1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaaf"); + +/// The least out-of-range `x`, the field prime itself, Chia-encoded. +pub const G1_X_EQ_PRIME_CHIA: [u8; 48] = + hex!("1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab"); + +/// The largest `x` the Chia encoding can carry, every bit below the three +/// flags set, so nothing beyond the flags is left to reinterpret. +pub const G1_X_MAX_CHIA: [u8; 48] = + hex!("1fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + +/// Smallest off-subgroup G2 point, Chia-encoded: `x.c0 = 2` is the least +/// value with `x^3 + 4(1 + u)` square in `Fp2` and `[r]P != O`. +pub const G2_OFF_SUBGROUP_CHIA: [u8; 96] = hex!(concat!( + "800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002", + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +)); + +/// [`G2_OFF_SUBGROUP_CHIA`] in the IETF encoding: `[x.c1, x.c0]` order, +/// compression bit and sign bit set. +pub const G2_OFF_SUBGROUP_IETF: [u8; 96] = hex!(concat!( + "a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002" +)); + +/// Re-encode a Chia G1 coordinate under the IETF compression bit. +pub const fn ietf_g1_encoding(mut chia: [u8; 48]) -> [u8; 48] { + chia[0] |= 0x80; + chia +} + /// Parse a 32-byte hash from a hex string. pub fn hash_from_hex(s: &str) -> dash_num::Hash256 { dash_num::Hash256::from_hex(s).unwrap() @@ -43,17 +87,41 @@ pub fn sequential_ids(n: usize) -> Vec { } /// Build a distinct 32-byte IKM from an index, for multi-signer tests. -pub fn test_ikm(i: u8) -> [u8; 32] { +/// +/// The index is carried in full, so a run of more than 256 signers gets that +/// many distinct keys instead of wrapping at 256. +pub fn test_ikm(i: usize) -> [u8; 32] { let mut ikm = [0u8; 32]; - ikm[0] = i; - ikm[31] = i.wrapping_add(1); + ikm[..8].copy_from_slice(&(i as u64).to_be_bytes()); + ikm[24..].copy_from_slice(&(i as u64).wrapping_add(1).to_be_bytes()); ikm } /// Build a distinct 32-byte message from an index, for multi-signer tests. -pub fn test_msg(i: u8) -> [u8; 32] { +/// +/// As with [`test_ikm`], the index is carried in full to keep messages +/// distinct past 256. +pub fn test_msg(i: usize) -> [u8; 32] { let mut m = [0u8; 32]; - m[0] = i.wrapping_mul(7); - m[15] = i; + m[..8].copy_from_slice(&(i as u64).to_be_bytes()); + m[8..16].copy_from_slice(&(i as u64).wrapping_mul(7).to_be_bytes()); m } + +#[cfg(test)] +mod builders { + use super::*; + + use rstest::rstest; + + #[rstest] + #[case::ikm(test_ikm)] + #[case::msg(test_msg)] + fn injective_past_256(#[case] build: fn(usize) -> [u8; 32]) { + let mut built: Vec<[u8; 32]> = (0..1000).map(build).collect(); + built.sort_unstable(); + let total = built.len(); + built.dedup(); + assert_eq!(built.len(), total, "index does not survive into the output"); + } +}