diff --git a/Cargo.lock b/Cargo.lock index 9d162211..fd33866e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5689,6 +5689,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "pluto-frost" +version = "1.7.1" +dependencies = [ + "blst", + "hex", + "rand 0.8.5", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "pluto-k1util" version = "1.7.1" diff --git a/Cargo.toml b/Cargo.toml index f0a08095..23d7af85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/testutil", "crates/tracing", "crates/peerinfo", + "crates/frost", ] resolver = "3" @@ -115,6 +116,7 @@ pluto-testutil = { path = "crates/testutil" } pluto-tracing = { path = "crates/tracing" } pluto-p2p = { path = "crates/p2p" } pluto-peerinfo = { path = "crates/peerinfo" } +pluto-frost = { path = "crates/frost" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/frost/Cargo.toml b/crates/frost/Cargo.toml new file mode 100644 index 00000000..9a5192aa --- /dev/null +++ b/crates/frost/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "pluto-frost" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +blst.workspace = true +rand_core.workspace = true +sha2.workspace = true + +[dev-dependencies] +hex.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints.rust] +missing_docs = "deny" +# Allow unsafe code for blst C bindings (overrides workspace forbid) +unsafe_code = "allow" + +[lints.clippy] +arithmetic_side_effects = "deny" +cast_lossless = "deny" +cast_possible_truncation = "deny" +cast_possible_wrap = "deny" +cast_precision_loss = "deny" +cast_sign_loss = "deny" +needless_return = "deny" +panicking_overflow_checks = "deny" +unwrap_used = "deny" diff --git a/crates/frost/dkg.md b/crates/frost/dkg.md new file mode 100644 index 00000000..97bd74bf --- /dev/null +++ b/crates/frost/dkg.md @@ -0,0 +1,72 @@ +# Kryptology-Compatible Distributed Key Generation (DKG) + +The kryptology DKG module supports generating FROST key shares in a distributed +manner compatible with Go's Coinbase Kryptology FROST DKG. + +The output types ([`KeyPackage`], [`PublicKeyPackage`]) are standard frost-core +types. The key shares can be used for BLS threshold signing via the +`bls_partial_sign`, `bls_combine_signatures`, and `bls_verify` functions. + +## Wire contract + +The supported cross-language contract is the raw field encoding used by the +fixtures and round helpers in this module: + +- Scalars are 32-byte big-endian field elements. +- G1 points are 48-byte compressed encodings. +- Participant identifiers are transported as `u32` values. +- The DKG context is transported as a single `u8` byte. + +Gob encoding is not part of this interoperability contract. + +## Example + +```rust +use std::collections::BTreeMap; + +use pluto_frost::kryptology; + +let mut rng = rand::rngs::OsRng; + +let threshold = 3u16; +let max_signers = 5u16; +let ctx = 0u8; + +// Round 1: each participant generates broadcast data and shares. +let mut bcasts: BTreeMap = BTreeMap::new(); +let mut all_shares: BTreeMap> = BTreeMap::new(); +let mut secrets: BTreeMap = BTreeMap::new(); + +for id in 1..=max_signers as u32 { + let (bcast, shares, secret) = + kryptology::round1(id, threshold, max_signers, ctx, &mut rng) + .expect("round1 should succeed"); + bcasts.insert(id, bcast); + secrets.insert(id, secret); + for (&target_id, share) in &shares { + all_shares.entry(target_id).or_default().insert(id, share.clone()); + } +} + +// Round 2: each participant verifies broadcasts and aggregates shares. +let mut key_packages = BTreeMap::new(); +let mut public_key_packages = Vec::new(); + +for id in 1..=max_signers as u32 { + let received_bcasts: BTreeMap<_, _> = bcasts + .iter() + .filter(|(k, _)| **k != id) + .map(|(k, v)| (*k, v.clone())) + .collect(); + let received_shares = all_shares.remove(&id).unwrap(); + let secret = secrets.remove(&id).unwrap(); + + let (_r2_bcast, key_package, pub_package) = + kryptology::round2(secret, &received_bcasts, &received_shares) + .expect("round2 should succeed"); + key_packages.insert(id, key_package); + public_key_packages.push(pub_package); +} + +// Each participant now has a KeyPackage and PublicKeyPackage for BLS threshold signing. +``` diff --git a/crates/frost/src/curve.rs b/crates/frost/src/curve.rs new file mode 100644 index 00000000..70ebd413 --- /dev/null +++ b/crates/frost/src/curve.rs @@ -0,0 +1,279 @@ +//! Thin wrappers around [`blst`] types for the BLS12-381 scalar field and G1 +//! curve group. +//! +//! Provides [`Scalar`], [`G1Projective`], and [`G1Affine`] with arithmetic +//! operator overloads, serialization, and safe constructors that enforce +//! subgroup membership. + +use std::{ + fmt, + ops::{Add, Mul, Sub}, +}; + +use blst::*; +use rand_core::{CryptoRng, RngCore}; + +/// BLS12-381 scalar field element. Wrapper around `blst_fr` in Montgomery form. +#[derive(Copy, Clone, Default, PartialEq, Eq)] +pub struct Scalar(pub(crate) blst_fr); + +impl fmt::Debug for Scalar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Scalar").field(&self.to_bytes()).finish() + } +} + +impl Scalar { + /// Multiplicative identity. + pub const ONE: Self = { + // Montgomery form of 1 for BLS12-381 scalar field. + // R mod r where R = 2^256 and r is the scalar field order. + // Computed from: blst_scalar_from_uint64([1,0,0,0]) -> blst_fr_from_scalar + // Pre-computed constant: + Scalar(blst_fr { + l: [ + 0x0000_0001_ffff_fffe, + 0x5884_b7fa_0003_4802, + 0x998c_4fef_ecbc_4ff5, + 0x1824_b159_acc5_056f, + ], + }) + }; + /// Additive identity. + pub const ZERO: Self = Scalar(blst_fr { l: [0; 4] }); + + /// Serialize to 32 little-endian bytes. + pub fn to_bytes(&self) -> [u8; 32] { + let mut scalar = blst_scalar::default(); + let mut out = [0u8; 32]; + unsafe { + blst_scalar_from_fr(&mut scalar, &self.0); + blst_lendian_from_scalar(out.as_mut_ptr(), &scalar); + } + out + } + + /// Deserialize from 32 little-endian bytes. Returns `None` if invalid. + pub fn from_bytes(bytes: &[u8; 32]) -> Option { + let mut scalar = blst_scalar::default(); + unsafe { + blst_scalar_from_lendian(&mut scalar, bytes.as_ptr()); + if !blst_scalar_fr_check(&scalar) { + return None; + } + let mut fr = blst_fr::default(); + blst_fr_from_scalar(&mut fr, &scalar); + Some(Scalar(fr)) + } + } + + /// Reduce 64 little-endian bytes modulo the scalar field order. + pub fn from_bytes_wide(bytes: &[u8; 64]) -> Self { + let mut scalar = blst_scalar::default(); + let mut fr = blst_fr::default(); + unsafe { + blst_scalar_from_le_bytes(&mut scalar, bytes.as_ptr(), 64); + blst_fr_from_scalar(&mut fr, &scalar); + } + Scalar(fr) + } + + /// Generate a uniformly random scalar. + pub fn random(rng: &mut R) -> Self { + let mut wide = [0u8; 64]; + rng.fill_bytes(&mut wide); + Self::from_bytes_wide(&wide) + } + + /// Compute the multiplicative inverse. Returns `None` for zero. + pub fn invert(&self) -> Option { + if *self == Self::ZERO { + return None; + } + let mut out = blst_fr::default(); + unsafe { blst_fr_eucl_inverse(&mut out, &self.0) }; + Some(Scalar(out)) + } +} + +impl From for Scalar { + fn from(val: u64) -> Self { + let mut fr = blst_fr::default(); + let limbs: [u64; 4] = [val, 0, 0, 0]; + unsafe { blst_fr_from_uint64(&mut fr, limbs.as_ptr()) }; + Scalar(fr) + } +} + +impl Add for Scalar { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + let mut out = blst_fr::default(); + unsafe { blst_fr_add(&mut out, &self.0, &rhs.0) }; + Scalar(out) + } +} + +impl Sub for Scalar { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + let mut out = blst_fr::default(); + unsafe { blst_fr_sub(&mut out, &self.0, &rhs.0) }; + Scalar(out) + } +} + +impl Mul for Scalar { + type Output = Self; + + fn mul(self, rhs: Self) -> Self { + let mut out = blst_fr::default(); + unsafe { blst_fr_mul(&mut out, &self.0, &rhs.0) }; + Scalar(out) + } +} + +/// BLS12-381 G1 point in projective (Jacobian) coordinates. Wrapper around +/// `blst_p1`. +#[derive(Copy, Clone, Default, Eq)] +pub struct G1Projective(pub(crate) blst_p1); + +impl PartialEq for G1Projective { + fn eq(&self, other: &Self) -> bool { + unsafe { blst_p1_is_equal(&self.0, &other.0) } + } +} + +impl fmt::Debug for G1Projective { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("G1Projective") + .field(&G1Affine::from(*self).to_compressed()) + .finish() + } +} + +impl G1Projective { + /// The fixed generator of G1. + pub fn generator() -> Self { + unsafe { G1Projective(*blst_p1_generator()) } + } + + /// The identity (point at infinity). + pub fn identity() -> Self { + Self::default() + } + + /// Check whether this is the identity element. + pub fn is_identity(&self) -> bool { + unsafe { blst_p1_is_inf(&self.0) } + } + + /// Deserialize from 48-byte compressed form. + /// Returns `None` on invalid encoding or point not in G1, or the identity + /// (point at infinity). + pub fn from_compressed(bytes: &[u8; 48]) -> Option { + let affine = G1Affine::from_compressed(bytes)?; + if affine.is_identity() { + return None; + } + Some(G1Projective::from(affine)) + } +} + +impl Add for G1Projective { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + let mut out = blst_p1::default(); + unsafe { blst_p1_add_or_double(&mut out, &self.0, &rhs.0) }; + G1Projective(out) + } +} + +impl Sub for G1Projective { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + let mut neg = rhs.0; + let mut out = blst_p1::default(); + unsafe { + blst_p1_cneg(&mut neg, true); + blst_p1_add_or_double(&mut out, &self.0, &neg); + } + G1Projective(out) + } +} + +impl Mul for G1Projective { + type Output = Self; + + fn mul(self, rhs: Scalar) -> Self { + let mut scalar = blst_scalar::default(); + let mut out = blst_p1::default(); + unsafe { + blst_scalar_from_fr(&mut scalar, &rhs.0); + blst_p1_mult(&mut out, &self.0, scalar.b.as_ptr(), 255); + } + G1Projective(out) + } +} + +/// BLS12-381 G1 point in affine coordinates (for serialization). Wrapper around +/// `blst_p1_affine`. +#[derive(Copy, Clone, Default)] +pub struct G1Affine(pub(crate) blst_p1_affine); + +impl G1Affine { + /// Serialize to 48-byte compressed form. + pub fn to_compressed(&self) -> [u8; 48] { + unsafe { + let mut out = [0u8; 48]; + blst_p1_affine_compress(out.as_mut_ptr(), &self.0); + out + } + } + + /// Deserialize from 48-byte compressed form. + /// Returns `None` on invalid encoding or point not in G1. + pub fn from_compressed(bytes: &[u8; 48]) -> Option { + let mut affine = blst_p1_affine::default(); + unsafe { + if blst_p1_uncompress(&mut affine, bytes.as_ptr()) != BLST_ERROR::BLST_SUCCESS { + return None; + } + if !blst_p1_affine_in_g1(&affine) { + return None; + } + } + Some(G1Affine(affine)) + } + + /// Check whether this is the identity (point at infinity). + pub fn is_identity(&self) -> bool { + unsafe { blst_p1_affine_is_inf(&self.0) } + } +} + +impl From for G1Affine { + fn from(p: G1Projective) -> Self { + let mut affine = blst_p1_affine::default(); + unsafe { blst_p1_to_affine(&mut affine, &p.0) }; + G1Affine(affine) + } +} + +impl From<&G1Projective> for G1Affine { + fn from(p: &G1Projective) -> Self { + G1Affine::from(*p) + } +} + +impl From for G1Projective { + fn from(a: G1Affine) -> Self { + let mut p = blst_p1::default(); + unsafe { blst_p1_from_affine(&mut p, &a.0) }; + G1Projective(p) + } +} diff --git a/crates/frost/src/frost_core.rs b/crates/frost/src/frost_core.rs new file mode 100644 index 00000000..b01f230a --- /dev/null +++ b/crates/frost/src/frost_core.rs @@ -0,0 +1,463 @@ +//! Port of frost-core types and functions, specialized for BLS12-381 G1 curve +//! operations. +//! +//! Contains the key material types (identifiers, shares, packages) and the +//! polynomial evaluation functions needed by the kryptology-compatible DKG. + +#![allow(clippy::arithmetic_side_effects)] + +use std::{ + cmp::Ordering, + collections::{BTreeMap, BTreeSet}, +}; + +use super::*; + +/// Errors from key operations. +#[derive(Debug)] +pub enum FrostCoreError { + /// Participant ID is zero. + InvalidZeroScalar, + /// Invalid number of minimum signers (must be >= 2 and <= max_signers). + InvalidMinSigners, + /// Invalid number of maximum signers (must be >= 2). + InvalidMaxSigners, + /// The secret share verification (Feldman VSS) failed. + InvalidSecretShare, + /// Commitment count mismatch during aggregation. + IncorrectNumberOfCommitments, + /// The commitment has no coefficients. + IncorrectCommitment, +} + +/// A participant identifier wrapping a non-zero scalar. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/identifier.rs#L14-L26 +#[derive(Copy, Clone, Debug)] +pub struct Identifier(Scalar); + +impl Identifier { + /// Create a new identifier from a non-zero u32. + pub fn from_u32(id: u32) -> Result { + let scalar = Scalar::from(u64::from(id)); + if scalar == Scalar::ZERO { + Err(FrostCoreError::InvalidZeroScalar) + } else { + Ok(Self(scalar)) + } + } + + /// Return the underlying scalar. + pub fn to_scalar(&self) -> Scalar { + self.0 + } +} + +impl PartialEq for Identifier { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for Identifier {} + +impl PartialOrd for Identifier { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/identifier.rs#L121-L137 +impl Ord for Identifier { + /// Compare identifiers by their numeric scalar value, using big-endian byte + /// order. Serializes to little-endian, and compares in reverse order. + fn cmp(&self, other: &Self) -> Ordering { + let a = self.0.to_bytes(); + let b = other.0.to_bytes(); + for i in (0..32).rev() { + match a[i].cmp(&b[i]) { + Ordering::Equal => continue, + other => return other, + } + } + Ordering::Equal + } +} + +/// A commitment to a single polynomial coefficient (a group element). +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L242-L249 +#[derive(Copy, Clone, Debug)] +pub struct CoefficientCommitment(G1Projective); + +impl CoefficientCommitment { + /// Create a new coefficient commitment. + pub fn new(value: G1Projective) -> Self { + Self(value) + } + + /// Return the underlying group element. + pub fn value(&self) -> G1Projective { + self.0 + } +} + +/// The commitments to the coefficients of a secret polynomial, used for +/// Feldman verifiable secret sharing. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L293-L310 +#[derive(Clone, Debug)] +pub struct VerifiableSecretSharingCommitment(Vec); + +impl VerifiableSecretSharingCommitment { + /// Create from a vector of coefficient commitments. + pub fn new(coefficients: Vec) -> Self { + Self(coefficients) + } + + /// Return the coefficient commitments. + pub fn coefficients(&self) -> &[CoefficientCommitment] { + &self.0 + } + + /// Derive a VSS commitment from a list of compressed group elements. + pub fn from_commitments(commitments: &[[u8; 48]]) -> Option { + let cc = commitments + .iter() + .map(|bytes| G1Projective::from_compressed(bytes).map(CoefficientCommitment::new)) + .collect::>>()?; + + Some(VerifiableSecretSharingCommitment::new(cc)) + } +} + +/// A secret scalar value representing a signer's share of the group secret. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L82-L87 +#[derive(Copy, Clone, Debug)] +pub struct SigningShare(Scalar); + +impl SigningShare { + /// Create a signing share from a scalar. + pub fn new(scalar: Scalar) -> Self { + Self(scalar) + } + + /// Return the underlying scalar. + pub fn to_scalar(&self) -> Scalar { + self.0 + } + + /// Evaluate the polynomial defined by `coefficients` at `peer`. + pub fn from_coefficients(coefficients: &[Scalar], peer: Identifier) -> Self { + Self::new(evaluate_polynomial(peer, coefficients)) + } +} +/// A public group element that represents a single signer's public +/// verification share. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L158-L165 +#[derive(Copy, Clone, Debug)] +pub struct VerifyingShare(G1Projective); + +impl VerifyingShare { + /// Create a verifying share from a group element. + pub fn new(element: G1Projective) -> Self { + Self(element) + } + + /// Return the underlying group element. + pub fn to_element(&self) -> G1Projective { + self.0 + } + + /// Compute the verifying share for `identifier` from the summed VSS + /// commitment. + pub fn from_commitment( + identifier: Identifier, + commitment: &VerifiableSecretSharingCommitment, + ) -> Self { + Self::new(evaluate_vss(identifier, commitment)) + } +} + +/// The group public key, used to verify threshold signatures. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/verifying_key.rs#L10-L20 +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct VerifyingKey(G1Projective); + +impl VerifyingKey { + /// Create a verifying key from a group element. + pub fn new(element: G1Projective) -> Self { + Self(element) + } + + /// Return the underlying group element. + pub fn to_element(&self) -> G1Projective { + self.0 + } + + /// Derive the verifying key from the first coefficient commitment. + /// + /// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/verifying_key.rs#L81-L93 + pub fn from_commitment( + commitment: &VerifiableSecretSharingCommitment, + ) -> Result { + Ok(Self::new( + commitment + .coefficients() + .first() + .ok_or(FrostCoreError::IncorrectCommitment)? + .value(), + )) + } +} + +/// Secret and public key material generated during DKG. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L384-L411 +pub struct SecretShare { + identifier: Identifier, + signing_share: SigningShare, + commitment: VerifiableSecretSharingCommitment, +} + +impl SecretShare { + /// Create a new secret share. + pub fn new( + identifier: Identifier, + signing_share: SigningShare, + commitment: VerifiableSecretSharingCommitment, + ) -> Self { + Self { + identifier, + signing_share, + commitment, + } + } + + /// Verify the share against the commitment using Feldman VSS. + /// + /// Checks that `G * signing_share == evaluate_vss(identifier, commitment)`. + /// + /// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L431-L468 + pub fn verify(&self) -> Result<(), FrostCoreError> { + let f_result = G1Projective::generator() * self.signing_share.to_scalar(); + let result = evaluate_vss(self.identifier, &self.commitment); + + if f_result != result { + return Err(FrostCoreError::InvalidSecretShare); + } + + Ok(()) + } +} + +/// A key package containing all key material for a participant. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L617-L643 +#[derive(Debug)] +pub struct KeyPackage { + identifier: Identifier, + signing_share: SigningShare, + verifying_share: VerifyingShare, + verifying_key: VerifyingKey, + min_signers: u16, +} + +impl KeyPackage { + /// Create a new key package. + pub fn new( + identifier: Identifier, + signing_share: SigningShare, + verifying_share: VerifyingShare, + verifying_key: VerifyingKey, + min_signers: u16, + ) -> Self { + Self { + identifier, + signing_share, + verifying_share, + verifying_key, + min_signers, + } + } + + /// The participant identifier. + pub fn identifier(&self) -> &Identifier { + &self.identifier + } + + /// The signing share (secret). + pub fn signing_share(&self) -> &SigningShare { + &self.signing_share + } + + /// The participant's public verifying share. + pub fn verifying_share(&self) -> &VerifyingShare { + &self.verifying_share + } + + /// The group public key. + pub fn verifying_key(&self) -> &VerifyingKey { + &self.verifying_key + } + + /// The minimum number of signers. + pub fn min_signers(&self) -> u16 { + self.min_signers + } +} + +/// Public data containing all signers' verification shares and the group +/// public key. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L712-L729 +#[derive(Debug)] +pub struct PublicKeyPackage { + verifying_shares: BTreeMap, + verifying_key: VerifyingKey, +} + +impl PublicKeyPackage { + /// Create a new public key package. + pub fn new( + verifying_shares: BTreeMap, + verifying_key: VerifyingKey, + ) -> Self { + Self { + verifying_shares, + verifying_key, + } + } + + /// The group public key. + pub fn verifying_key(&self) -> &VerifyingKey { + &self.verifying_key + } + + /// The verifying shares for all participants. + pub fn verifying_shares(&self) -> &BTreeMap { + &self.verifying_shares + } + + /// Derive a public key package from all participants' DKG commitments. + /// + /// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L765-L777 + pub fn from_dkg_commitments( + commitments: &BTreeMap, + ) -> Result { + let identifiers: BTreeSet<_> = commitments.keys().copied().collect(); + let commitments: Vec<_> = commitments.values().copied().collect(); + let group_commitment = sum_commitments(&commitments)?; + Self::from_commitment(&identifiers, &group_commitment) + } + + /// Derive verifying shares for each participant from a summed commitment. + /// + /// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L747-L763 + fn from_commitment( + identifiers: &BTreeSet, + commitment: &VerifiableSecretSharingCommitment, + ) -> Result { + let verifying_shares: BTreeMap<_, _> = identifiers + .iter() + .map(|id| (*id, VerifyingShare::from_commitment(*id, commitment))) + .collect(); + Ok(Self::new( + verifying_shares, + VerifyingKey::from_commitment(commitment)?, + )) + } +} + +/// Evaluate a polynomial using Horner's method. +/// +/// Given coefficients `[a_0, a_1, ..., a_{t-1}]`, computes +/// `a_0 + a_1 * x + a_2 * x^2 + ... + a_{t-1} * x^{t-1}`. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L573-L595 +fn evaluate_polynomial(identifier: Identifier, coefficients: &[Scalar]) -> Scalar { + let mut value = Scalar::ZERO; + let x = identifier.to_scalar(); + + for coeff in coefficients.iter().skip(1).rev() { + value = value + *coeff; + value = value * x; + } + value = value + + *coefficients + .first() + .expect("coefficients must have at least one element"); + value +} + +/// Evaluate the VSS verification equation at `identifier`. +/// +/// Computes `sum_{k=0}^{t-1} commitment[k] * identifier^k`. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L597-L615 +fn evaluate_vss( + identifier: Identifier, + commitment: &VerifiableSecretSharingCommitment, +) -> G1Projective { + let i = identifier.to_scalar(); + + let (_, result) = commitment.0.iter().fold( + (Scalar::ONE, G1Projective::identity()), + |(i_to_the_k, sum_so_far), comm_k| { + (i * i_to_the_k, sum_so_far + comm_k.value() * i_to_the_k) + }, + ); + result +} + +/// Sum multiple participants' commitments element-wise. +/// +/// Given commitments from n participants each of length t, produces a single +/// commitment of length t where each element is the sum of the corresponding +/// elements across all participants. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L35-L62 +fn sum_commitments( + commitments: &[&VerifiableSecretSharingCommitment], +) -> Result { + let mut group_commitment = vec![ + CoefficientCommitment::new(G1Projective::identity()); + commitments + .first() + .ok_or(FrostCoreError::IncorrectNumberOfCommitments)? + .0 + .len() + ]; + for commitment in commitments { + for (i, c) in group_commitment.iter_mut().enumerate() { + *c = CoefficientCommitment::new( + c.value() + + commitment + .0 + .get(i) + .ok_or(FrostCoreError::IncorrectNumberOfCommitments)? + .value(), + ); + } + } + Ok(VerifiableSecretSharingCommitment(group_commitment)) +} + +/// Validate that (min_signers, max_signers) form a valid pair. +/// +/// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L796-L815 +pub fn validate_num_of_signers(min_signers: u16, max_signers: u16) -> Result<(), FrostCoreError> { + if min_signers < 2 { + return Err(FrostCoreError::InvalidMinSigners); + } + if max_signers < 2 { + return Err(FrostCoreError::InvalidMaxSigners); + } + if min_signers > max_signers { + return Err(FrostCoreError::InvalidMinSigners); + } + Ok(()) +} diff --git a/crates/frost/src/kryptology.rs b/crates/frost/src/kryptology.rs new file mode 100644 index 00000000..865a2414 --- /dev/null +++ b/crates/frost/src/kryptology.rs @@ -0,0 +1,615 @@ +//! Kryptology-compatible DKG for interoperability with Go's Coinbase Kryptology +//! FROST DKG. +//! +//! This module implements the same DKG protocol as +//! `github.com/coinbase/kryptology/pkg/dkg/frost`, which differs from the +//! standard FROST DKG in frost-core in the hash-to-scalar construction, +//! challenge preimage format, proof representation, and round structure. +//! +//! The output types ([`KeyPackage`], [`PublicKeyPackage`]) are standard +//! frost-core types usable with frost-core's signing protocol. + +#![allow(clippy::arithmetic_side_effects)] + +use std::collections::BTreeMap; + +use blst::*; +use rand_core::{CryptoRng, RngCore}; +use sha2::{Digest, Sha256}; + +use super::*; + +/// Errors from the kryptology-compatible DKG. +#[derive(Debug)] +pub enum DkgError { + /// Participant ID is zero or out of range. + InvalidParticipantId(u32), + /// Two or more partial signatures share the same identifier. + DuplicateIdentifier(u32), + /// Fewer partial signatures than the threshold were provided. + InsufficientSigners, + /// Invalid number of signers. + InvalidSignerCount, + /// Invalid proof of knowledge from a specific participant. + InvalidProof { + /// The 1-indexed ID of the participant whose proof failed. + culprit: u32, + }, + /// Invalid Feldman share from a specific participant. + InvalidShare { + /// The 1-indexed ID of the participant whose share failed. + culprit: u32, + }, + /// Wrong number of received packages. + IncorrectPackageCount, + /// Failed to deserialize a scalar from wire format bytes. + InvalidScalar, + /// Failed to deserialize a G1 point from wire format bytes. + InvalidPoint, + /// Commitment count does not match threshold. + InvalidCommitmentCount { + /// The participant whose commitment count was wrong. + participant: u32, + }, + /// An error from frost-core. + FrostCoreError(FrostCoreError), +} + +impl From for DkgError { + fn from(e: FrostCoreError) -> Self { + DkgError::FrostCoreError(e) + } +} + +/// Kryptology Round 1 broadcast data matching Go's `frost.Round1Bcast`. +/// +/// Scalars (`wi`, `ci`) are in **big-endian** byte order to match Go's +/// kryptology wire format. Commitments are compressed G1 points (48 bytes). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Round1Bcast { + /// Feldman verifier commitments `[A_{i,0}, ..., A_{i,t-1}]`. + pub commitments: Vec<[u8; 48]>, + /// Proof-of-knowledge response scalar (big-endian). + pub wi: [u8; 32], + /// Proof-of-knowledge challenge scalar (big-endian). + pub ci: [u8; 32], +} + +/// Kryptology Round 2 broadcast data matching Go's `frost.Round2Bcast`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Round2Bcast { + /// The group verification key (compressed G1, 48 bytes). + pub verification_key: [u8; 48], + /// This participant's verification share (compressed G1, 48 bytes). + pub vk_share: [u8; 48], +} + +/// A Shamir secret share matching Go's `sharing.ShamirShare`. +/// +/// The `value` field is in **big-endian** byte order. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ShamirShare { + /// The share identifier (1-indexed participant ID). + pub id: u32, + /// The share value as big-endian scalar bytes. + pub value: [u8; 32], +} + +/// Secret state held by a participant between round 1 and round 2. +/// +/// # Security +/// +/// This MUST NOT be sent to other participants. +pub struct Round1Secret { + id: u32, + ctx: u8, + coefficients: Vec, + commitment: VerifiableSecretSharingCommitment, + threshold: u16, + max_signers: u16, +} + +impl Round1Secret { + /// Reconstruct a [`Round1Secret`] from wire-format data (e.g. a test + /// fixture) so that the standard [`round2`] function can be called. + /// + /// `own_share` is the big-endian scalar the participant computed for + /// itself. It is stored as the constant term of a zero polynomial so + /// that [`round2`]'s `from_coefficients` evaluation returns it + /// unchanged. + pub fn from_raw( + id: u32, + ctx: u8, + threshold: u16, + max_signers: u16, + own_share: &[u8; 32], + commitment_bytes: &[[u8; 48]], + ) -> Result { + let own_share_scalar = scalar_from_be(own_share)?; + let commitment = deserialize_commitment(id, threshold, commitment_bytes)?; + + let mut coefficients = vec![Scalar::ZERO; threshold as usize]; + coefficients[0] = own_share_scalar; + + Ok(Self { + id, + ctx, + coefficients, + commitment, + threshold, + max_signers, + }) + } +} + +/// Convert a `Scalar` to big-endian 32 bytes (Go's wire format). +pub fn scalar_to_be(s: &Scalar) -> [u8; 32] { + let mut bytes = s.to_bytes(); + bytes.reverse(); + bytes +} + +/// Convert big-endian 32 bytes to a `Scalar`. +pub fn scalar_from_be(bytes: &[u8; 32]) -> Result { + let mut le = *bytes; + le.reverse(); + Scalar::from_bytes(&le).ok_or(DkgError::InvalidScalar) +} + +/// RFC 9380 Section 5.3.1 using SHA-256 +pub fn expand_msg_xmd(msg: &[u8], dst: &[u8], len_in_bytes: usize) -> Vec { + const B_IN_BYTES: usize = 32; // SHA-256 output + const S_IN_BYTES: usize = 64; // SHA-256 block size + + let ell = len_in_bytes.div_ceil(B_IN_BYTES); + assert!(ell <= 255, "RFC 9380: ell must be at most 255"); + assert!( + len_in_bytes <= 65535, + "RFC 9380: len_in_bytes must fit in 2 bytes" + ); + assert!(dst.len() <= 255, "RFC 9380: DST must be at most 255 bytes"); + + let dst_prime_suffix = [u8::try_from(dst.len()).expect("asserted above")]; + let l_i_b_str = u16::try_from(len_in_bytes) + .expect("asserted above") + .to_be_bytes(); + + // b_0 = H(Z_pad || msg || l_i_b_str || I2OSP(0,1) || DST_prime) + let mut h0 = Sha256::new(); + h0.update([0u8; S_IN_BYTES]); + h0.update(msg); + h0.update(l_i_b_str); + h0.update([0u8]); + h0.update(dst); + h0.update(dst_prime_suffix); + let b_0: [u8; 32] = h0.finalize().into(); + + // b_1 = H(b_0 || I2OSP(1,1) || DST_prime) + let mut h1 = Sha256::new(); + h1.update(b_0); + h1.update([1u8]); + h1.update(dst); + h1.update(dst_prime_suffix); + let b_1: [u8; 32] = h1.finalize().into(); + + let mut out = Vec::with_capacity(ell * B_IN_BYTES); + out.extend_from_slice(&b_1); + + let mut b_prev = b_1; + for i in 2..=ell { + let mut xored = [0u8; 32]; + for j in 0..32 { + xored[j] = b_0[j] ^ b_prev[j]; + } + let mut hi = Sha256::new(); + hi.update(xored); + hi.update([u8::try_from(i).expect("ell <= 255 asserted above")]); + hi.update(dst); + hi.update(dst_prime_suffix); + let b_i: [u8; 32] = hi.finalize().into(); + out.extend_from_slice(&b_i); + b_prev = b_i; + } + + out.truncate(len_in_bytes); + out +} + +/// Kryptology hash-to-scalar. +/// +/// See: https://github.com/coinbase/kryptology/blob/1dcc062313d99f2e56ce6abc2003ef63c52dd4a5/pkg/core/curves/bls12381_curve.go#L50 +const KRYPTOLOGY_DST: &[u8] = b"BLS12381_XMD:SHA-256_SSWU_RO_"; + +/// Hash to scalar using kryptology's ExpandMsgXmd construction. +/// +/// `ExpandMsgXmd(SHA-256, msg, DST, 48)` -> reverse bytes -> pad to 64 -> +/// `Scalar::from_bytes_wide`. +fn kryptology_hash_to_scalar(msg: &[u8]) -> Scalar { + let xmd = expand_msg_xmd(msg, KRYPTOLOGY_DST, 48); + let mut reversed = [0u8; 48]; + reversed.copy_from_slice(&xmd); + reversed.reverse(); + let mut wide = [0u8; 64]; + wide[..48].copy_from_slice(&reversed); + Scalar::from_bytes_wide(&wide) +} + +/// Compute the DKG challenge matching kryptology's format. +/// +/// Preimage = `byte(id) || byte(ctx) || A_{i,0}.compressed || R.compressed` +/// (98 bytes). +fn kryptology_challenge(id: u8, ctx: u8, commitment_0: &G1Projective, r: &G1Projective) -> Scalar { + let mut preimage = Vec::with_capacity(98); + preimage.push(id); + preimage.push(ctx); + preimage.extend_from_slice(&G1Affine::from(commitment_0).to_compressed()); + preimage.extend_from_slice(&G1Affine::from(r).to_compressed()); + kryptology_hash_to_scalar(&preimage) +} + +fn deserialize_commitment( + participant: u32, + threshold: u16, + commitments: &[[u8; 48]], +) -> Result { + if commitments.len() != threshold as usize { + return Err(DkgError::InvalidCommitmentCount { participant }); + } + + VerifiableSecretSharingCommitment::from_commitments(commitments).ok_or(DkgError::InvalidPoint) +} + +/// Perform Round 1 of the kryptology-compatible DKG. +/// +/// Generates the secret polynomial, Feldman commitments, Schnorr +/// proof-of-knowledge, and pre-computes Shamir shares for all other +/// participants. +/// +/// # Arguments +/// - `id`: This participant's 1-indexed identifier (1..=max_signers). +/// - `threshold`: Minimum number of signers (t). +/// - `max_signers`: Total number of signers (n). +/// - `ctx`: DKG context byte (typically 0). +/// - `rng`: Cryptographic RNG. +pub fn round1( + id: u32, + threshold: u16, + max_signers: u16, + ctx: u8, + rng: &mut R, +) -> Result<(Round1Bcast, BTreeMap, Round1Secret), DkgError> { + // Kryptology encodes participant identifiers into a single byte. + if max_signers > u16::from(u8::MAX) { + return Err(DkgError::InvalidSignerCount); + } + + validate_num_of_signers(threshold, max_signers)?; + + if id == 0 || id > u32::from(max_signers) { + return Err(DkgError::InvalidParticipantId(id)); + } + + // Generate random polynomial coefficients [a_0, ..., a_{t-1}] + let coefficients: Vec = (0..threshold).map(|_| Scalar::random(&mut *rng)).collect(); + + // Feldman commitments: A_{i,k} = a_{i,k} * G + let commitment_points: Vec = coefficients + .iter() + .map(|c| G1Projective::generator() * *c) + .collect(); + + let commitment = { + let cc: Vec = commitment_points + .iter() + .map(|p| CoefficientCommitment::new(*p)) + .collect(); + VerifiableSecretSharingCommitment::new(cc) + }; + + // Schnorr proof of knowledge: sample nonce k, compute R = k*G + let k = loop { + let s = Scalar::random(&mut *rng); + if s != Scalar::ZERO { + break s; + } + }; + let r_point = G1Projective::generator() * k; + let id_u8 = u8::try_from(id).expect("id <= max_signers <= u8::MAX validated above"); + let ci = kryptology_challenge(id_u8, ctx, &commitment_points[0], &r_point); + let wi = k + coefficients[0] * ci; + + // Pre-compute Shamir shares for every other participant + let mut shares = BTreeMap::new(); + for j in 1..=u32::from(max_signers) { + if j == id { + continue; + } + let j_id = Identifier::from_u32(j)?; + let share_scalar = SigningShare::from_coefficients(&coefficients, j_id).to_scalar(); + shares.insert( + j, + ShamirShare { + id: j, + value: scalar_to_be(&share_scalar), + }, + ); + } + + let bcast = Round1Bcast { + commitments: commitment_points + .iter() + .map(|p| G1Affine::from(p).to_compressed()) + .collect(), + wi: scalar_to_be(&wi), + ci: scalar_to_be(&ci), + }; + + let secret = Round1Secret { + id, + ctx, + coefficients, + commitment, + threshold, + max_signers, + }; + + Ok((bcast, shares, secret)) +} + +/// Perform Round 2 of the kryptology-compatible DKG. +/// +/// Verifies all received Round 1 broadcasts (proof-of-knowledge + Feldman +/// verification), aggregates received Shamir shares, and produces the final +/// key material. +/// +/// # Arguments +/// - `secret`: The [`Round1Secret`] from this participant's [`round1`] call. +/// - `received_bcasts`: Map from source participant ID to their +/// [`Round1Bcast`]. +/// - `received_shares`: Map from source participant ID to the [`ShamirShare`] +/// they sent us. +pub fn round2( + secret: Round1Secret, + received_bcasts: &BTreeMap, + received_shares: &BTreeMap, +) -> Result<(Round2Bcast, KeyPackage, PublicKeyPackage), DkgError> { + let expected = (secret.max_signers - 1) as usize; + if received_bcasts.len() != expected || received_shares.len() != expected { + return Err(DkgError::IncorrectPackageCount); + } + + let own_identifier = Identifier::from_u32(secret.id)?; + let own_share_scalar = + SigningShare::from_coefficients(&secret.coefficients, own_identifier).to_scalar(); + + let mut peer_commitments: BTreeMap = + BTreeMap::new(); + let mut share_sum = Scalar::ZERO; + + for (&sender_id, bcast) in received_bcasts { + let sender_commitment = + deserialize_commitment(sender_id, secret.threshold, &bcast.commitments)?; + let a0 = sender_commitment.coefficients()[0].value(); + + // Verify proof of knowledge + let wi = scalar_from_be(&bcast.wi)?; + let ci = scalar_from_be(&bcast.ci)?; + + // Reconstruct R' = Wi*G - Ci*A_{j,0} + let r_reconstructed = G1Projective::generator() * wi - a0 * ci; + let sender_id_u8 = + u8::try_from(sender_id).map_err(|_| DkgError::InvalidParticipantId(sender_id))?; + let ci_check = kryptology_challenge(sender_id_u8, secret.ctx, &a0, &r_reconstructed); + if ci_check != ci { + return Err(DkgError::InvalidProof { culprit: sender_id }); + } + + // Verify Feldman share + let share = received_shares + .get(&sender_id) + .ok_or(DkgError::IncorrectPackageCount)?; + if share.id != secret.id { + return Err(DkgError::InvalidShare { culprit: sender_id }); + } + let share_scalar = scalar_from_be(&share.value)?; + + let signing_share = SigningShare::new(share_scalar); + let secret_share = + SecretShare::new(own_identifier, signing_share, sender_commitment.clone()); + secret_share + .verify() + .map_err(|_| DkgError::InvalidShare { culprit: sender_id })?; + + share_sum = share_sum + share_scalar; + + let sender_identifier = Identifier::from_u32(sender_id)?; + peer_commitments.insert(sender_identifier, sender_commitment); + } + + let total_scalar = own_share_scalar + share_sum; + + let signing_share = SigningShare::new(total_scalar); + let verifying_share_element = G1Projective::generator() * total_scalar; + let verifying_share = VerifyingShare::new(verifying_share_element); + + // Build PublicKeyPackage from all participants' commitments + peer_commitments.insert(own_identifier, secret.commitment); + let commitment_refs: BTreeMap = + peer_commitments.iter().map(|(id, c)| (*id, c)).collect(); + let public_key_package = PublicKeyPackage::from_dkg_commitments(&commitment_refs)?; + + let verifying_key = *public_key_package.verifying_key(); + + let key_package = KeyPackage::new( + own_identifier, + signing_share, + verifying_share, + verifying_key, + secret.threshold, + ); + + // Serialize Round2Bcast + let vk_element = verifying_key.to_element(); + let bcast = Round2Bcast { + verification_key: G1Affine::from(vk_element).to_compressed(), + vk_share: G1Affine::from(verifying_share_element).to_compressed(), + }; + + Ok((bcast, key_package, public_key_package)) +} + +/// Domain separation tag for Ethereum 2.0 BLS signatures (proof of possession +/// scheme). +/// +/// Matches Go's `bls.NewSigEth2()` which uses `blsSignaturePopDst`. +pub const BLS_SIG_DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + +/// A BLS partial signature in G2, produced by a single signer's key share. +#[derive(Clone)] +pub struct BlsPartialSignature { + /// The signer's 1-indexed identifier (used as the Lagrange x-coordinate). + pub identifier: u32, + point: blst_p2, +} + +impl BlsPartialSignature { + /// Produce a BLS partial signature from a [`KeyPackage`] produced by + /// kryptology DKG. + /// + /// Computes `partial_sig = (key_package.signing_share) * H(msg)` where H + /// hashes the message to a G2 point using the Ethereum 2.0 DST. + /// + /// The `id` must be the original 1-indexed kryptology participant ID. + pub fn from_key_package(id: u32, key_package: &KeyPackage, msg: &[u8]) -> BlsPartialSignature { + let scalar = key_package.signing_share().to_scalar(); + { + let signing_share: &Scalar = &scalar; + let h_msg = hash_to_g2(msg); + BlsPartialSignature { + identifier: id, + point: p2_mult(&h_msg, signing_share), + } + } + } +} + +/// A complete BLS signature in G2 (96 bytes compressed). +#[derive(Clone)] +pub struct BlsSignature { + point: blst_p2, +} + +impl BlsSignature { + /// Serialize to 96-byte compressed G2 point. + pub fn to_bytes(&self) -> [u8; 96] { + let mut affine = blst_p2_affine::default(); + let mut out = [0u8; 96]; + unsafe { + blst_p2_to_affine(&mut affine, &self.point); + blst_p2_affine_compress(out.as_mut_ptr(), &affine); + } + out + } + + /// Combine BLS partial signatures via Lagrange interpolation at x = 0. + /// + /// Matches Go's `combineSigs` in + /// `kryptology/pkg/signatures/bls/bls_sig/usual_bls_sig.go`. + /// + /// Returns [`DkgError::InsufficientSigners`] if `min_signers < 2` or + /// fewer than `min_signers` partial signatures are provided. + pub fn from_partial_signatures( + min_signers: u16, + partial_sigs: &[BlsPartialSignature], + ) -> Result { + if min_signers < 2 || partial_sigs.len() < min_signers as usize { + return Err(DkgError::InsufficientSigners); + } + + // Check for duplicate identifiers + let mut seen = std::collections::BTreeSet::new(); + for ps in partial_sigs { + if !seen.insert(ps.identifier) { + return Err(DkgError::DuplicateIdentifier(ps.identifier)); + } + } + + let x_vals: Vec = partial_sigs + .iter() + .map(|ps| Scalar::from(u64::from(ps.identifier))) + .collect(); + + let mut combined = blst_p2::default(); + let mut first = true; + + for (i, ps) in partial_sigs.iter().enumerate() { + // Lagrange coefficient: L_i(0) = prod_{j!=i} ( x_j / (x_j - x_i) ) + let mut lambda = Scalar::ONE; + for (j, _) in partial_sigs.iter().enumerate() { + if i == j { + continue; + } + let num = x_vals[j]; + let den = x_vals[j] - x_vals[i]; + let den_inv = den.invert().ok_or(DkgError::InvalidSignerCount)?; + lambda = lambda * num * den_inv; + } + + let weighted = p2_mult(&ps.point, &lambda); + + if first { + combined = weighted; + first = false; + } else { + let mut tmp = blst_p2::default(); + unsafe { blst_p2_add_or_double(&mut tmp, &combined, &weighted) }; + combined = tmp; + } + } + + Ok(BlsSignature { point: combined }) + } + + /// Verify a BLS signature against a public key. + /// + /// Uses the Ethereum 2.0 BLS verification (pairing check) with the + /// standard DST. + pub fn verify(&self, verifying_key: &VerifyingKey, msg: &[u8]) -> bool { + let pk_affine = G1Affine::from(verifying_key.to_element()); + let pk = blst::min_pk::PublicKey::from(pk_affine.0); + + let mut sig_affine = blst_p2_affine::default(); + unsafe { blst_p2_to_affine(&mut sig_affine, &self.point) }; + let sig = blst::min_pk::Signature::from(sig_affine); + + sig.verify(true, msg, BLS_SIG_DST, &[], &pk, true) == blst::BLST_ERROR::BLST_SUCCESS + } +} + +/// Hash a message to a G2 point using the Ethereum 2.0 BLS DST. +fn hash_to_g2(msg: &[u8]) -> blst_p2 { + let mut out = blst_p2::default(); + unsafe { + blst_hash_to_g2( + &mut out, + msg.as_ptr(), + msg.len(), + BLS_SIG_DST.as_ptr(), + BLS_SIG_DST.len(), + core::ptr::null(), + 0, + ); + } + out +} + +/// Multiply a G2 point by a scalar. +fn p2_mult(point: &blst_p2, scalar: &Scalar) -> blst_p2 { + let mut s = blst_scalar::default(); + let mut out = blst_p2::default(); + unsafe { + blst_scalar_from_fr(&mut s, &scalar.0); + blst_p2_mult(&mut out, point, s.b.as_ptr(), 255); + } + out +} diff --git a/crates/frost/src/lib.rs b/crates/frost/src/lib.rs new file mode 100644 index 00000000..1197bf98 --- /dev/null +++ b/crates/frost/src/lib.rs @@ -0,0 +1,18 @@ +//! Kryptology-compatible FROST DKG and BLS threshold signing over BLS12-381 G1. +//! This crate implements a distributed key generation protocol compatible with +//! Go's Coinbase Kryptology FROST DKG, and BLS threshold signing (Ethereum 2.0 +//! compatible). + +#![allow(non_snake_case)] +#![doc = include_str!("../dkg.md")] + +pub mod curve; +pub mod frost_core; +pub mod kryptology; + +pub use curve::*; +pub use frost_core::*; +pub use rand_core; + +#[cfg(test)] +mod tests; diff --git a/crates/frost/src/tests.rs b/crates/frost/src/tests.rs new file mode 100644 index 00000000..8e8013a2 --- /dev/null +++ b/crates/frost/src/tests.rs @@ -0,0 +1,301 @@ +use std::collections::BTreeMap; + +use rand::{SeedableRng, rngs::StdRng}; + +use crate::kryptology; + +#[test] +fn scalar_one_precomputed() { + let constant = crate::Scalar::ONE; + let computed = crate::Scalar::from(1u64); + assert_eq!(constant, computed); +} + +/// RFC 9380 Section 5.3.1 test vector for expand_msg_xmd with SHA-256. +/// DST = "QUUX-V01-CS02-with-expander-SHA256-128" +/// msg = "" (empty), len_in_bytes = 0x20 (32) +#[test] +fn expand_msg_xmd_rfc9380_vector() { + let dst = b"QUUX-V01-CS02-with-expander-SHA256-128"; + let msg = b""; + let expected = + hex::decode("68a985b87eb6b46952128911f2a4412bbc302a9d759667f87f7a21d803f07235").unwrap(); + + let result = kryptology::expand_msg_xmd(msg, dst, 32); + assert_eq!(result, expected, "expand_msg_xmd empty message vector"); +} + +/// RFC 9380 test vector: msg = "abc", len = 32 +#[test] +fn expand_msg_xmd_rfc9380_abc() { + let dst = b"QUUX-V01-CS02-with-expander-SHA256-128"; + let msg = b"abc"; + let expected = + hex::decode("d8ccab23b5985ccea865c6c97b6e5b8350e794e603b4b97902f53a8a0d605615").unwrap(); + + let result = kryptology::expand_msg_xmd(msg, dst, 32); + assert_eq!(result, expected, "expand_msg_xmd abc vector"); +} + +/// RFC 9380 test vector: msg = "", len = 0x80 (128 bytes) +#[test] +fn expand_msg_xmd_rfc9380_long_output() { + let dst = b"QUUX-V01-CS02-with-expander-SHA256-128"; + let msg = b""; + let expected = hex::decode( + "af84c27ccfd45d41914fdff5df25293e221afc53d8ad2ac06d5e3e2948\ + 5dadbee0d121587713a3e0dd4d5e69e93eb7cd4f5df4cd103e188cf60c\ + b02edc3edf18eda8576c412b18ffb658e3dd6ec849469b979d444cf7b2\ + 6911a08e63cf31f9dcc541708d3491184472c2c29bb749d4286b004ceb\ + 5ee6b9a7fa5b646c993f0ced", + ) + .unwrap(); + + let result = kryptology::expand_msg_xmd(msg, dst, 128); + assert_eq!(result, expected, "expand_msg_xmd 128-byte output vector"); +} + +#[test] +fn kryptology_rejects_more_than_255_signers() { + let mut rng = StdRng::seed_from_u64(42); + let result = kryptology::round1(1, 2, 256, 0, &mut rng); + + assert!(matches!( + result, + Err(kryptology::DkgError::InvalidSignerCount) + )); +} + +#[test] +fn kryptology_accepts_255_signers_boundary() { + let mut rng = StdRng::seed_from_u64(4242); + let (_bcast, shares, _secret) = kryptology::round1(1, 2, 255, 9, &mut rng) + .expect("255 signers should remain within kryptology's u8 transport limit"); + + assert_eq!(shares.len(), 254); + assert!(shares.contains_key(&255)); +} + +/// Full DKG round-trip: 3-of-3 DKG, then BLS threshold sign and verify. +#[test] +fn kryptology_bls_round_trip_3_of_3() { + let mut rng = StdRng::seed_from_u64(42); + let threshold = 3u16; + let max_signers = 3u16; + let ctx = 0u8; + + let mut bcasts: BTreeMap = BTreeMap::new(); + let mut all_shares: BTreeMap> = BTreeMap::new(); + let mut secrets: BTreeMap = BTreeMap::new(); + + for id in 1..=u32::from(max_signers) { + let (bcast, shares, secret) = kryptology::round1(id, threshold, max_signers, ctx, &mut rng) + .expect("round1 should succeed"); + bcasts.insert(id, bcast); + secrets.insert(id, secret); + + for (&target_id, share) in &shares { + all_shares + .entry(target_id) + .or_default() + .insert(id, share.clone()); + } + } + + // --- Round 2: each participant verifies + aggregates --- + let mut key_packages = BTreeMap::new(); + let mut public_key_packages = Vec::new(); + let mut round2_bcasts = BTreeMap::new(); + + for id in 1..=u32::from(max_signers) { + // Collect broadcasts from everyone except ourselves + let received_bcasts: BTreeMap = bcasts + .iter() + .filter(|(k, _)| **k != id) + .map(|(k, v)| (*k, v.clone())) + .collect(); + + let received_shares = all_shares.remove(&id).unwrap(); + let secret = secrets.remove(&id).unwrap(); + + let (r2_bcast, key_package, pub_package) = + kryptology::round2(secret, &received_bcasts, &received_shares) + .expect("round2 should succeed"); + + round2_bcasts.insert(id, r2_bcast); + key_packages.insert(id, key_package); + public_key_packages.push(pub_package); + } + + // All participants should agree on the group verification key + let vk = public_key_packages[0].verifying_key(); + for pkg in &public_key_packages[1..] { + assert_eq!( + vk, + pkg.verifying_key(), + "all participants must agree on the group key" + ); + } + + // All Round2Bcast should carry the same verification_key + let vk_bytes = round2_bcasts[&1].verification_key; + for (&id, bcast) in &round2_bcasts { + assert_eq!( + bcast.verification_key, vk_bytes, + "participant {id} round2 broadcast has different group key" + ); + } + + // BLS sign with all signers (t-of-t) + let message = b"test message"; + + let partial_sigs: Vec<_> = key_packages + .keys() + .map(|&id| { + kryptology::BlsPartialSignature::from_key_package(id, &key_packages[&id], message) + }) + .collect(); + + let signature = kryptology::BlsSignature::from_partial_signatures(threshold, &partial_sigs) + .expect("BLS signature combination should succeed"); + + assert!( + signature.verify(vk, message), + "3-of-3 BLS threshold signature should verify" + ); +} + +/// 2-of-3 DKG then BLS threshold signing (Ethereum 2.0 compatible). +#[test] +fn kryptology_bls_round_trip_2_of_3() { + let mut rng = StdRng::seed_from_u64(123); + let threshold = 2u16; + let max_signers = 3u16; + let ctx = 0u8; + + // Round 1 + let mut bcasts: BTreeMap = BTreeMap::new(); + let mut all_shares: BTreeMap> = BTreeMap::new(); + let mut secrets: BTreeMap = BTreeMap::new(); + + for id in 1..=u32::from(max_signers) { + let (bcast, shares, secret) = + kryptology::round1(id, threshold, max_signers, ctx, &mut rng).unwrap(); + bcasts.insert(id, bcast); + secrets.insert(id, secret); + for (&target_id, share) in &shares { + all_shares + .entry(target_id) + .or_default() + .insert(id, share.clone()); + } + } + + // Round 2 + let mut key_packages = BTreeMap::new(); + let mut public_key_packages = Vec::new(); + + for id in 1..=u32::from(max_signers) { + let received_bcasts: BTreeMap<_, _> = bcasts + .iter() + .filter(|(k, _)| **k != id) + .map(|(k, v)| (*k, v.clone())) + .collect(); + let received_shares = all_shares.remove(&id).unwrap(); + let secret = secrets.remove(&id).unwrap(); + + let (_r2_bcast, key_package, pub_package) = + kryptology::round2(secret, &received_bcasts, &received_shares).unwrap(); + key_packages.insert(id, key_package); + public_key_packages.push(pub_package); + } + + // BLS sign with only participants 1 and 2 (threshold = 2) + let message = b"threshold signing"; + let signers: [u32; 2] = [1, 2]; + + let partial_sigs: Vec<_> = signers + .iter() + .map(|&id| { + kryptology::BlsPartialSignature::from_key_package(id, &key_packages[&id], message) + }) + .collect(); + + let signature = kryptology::BlsSignature::from_partial_signatures(threshold, &partial_sigs) + .expect("BLS signature combination should succeed"); + + let vk = public_key_packages[0].verifying_key(); + assert!( + signature.verify(vk, message), + "BLS threshold signature should verify" + ); + + // Verify wrong message fails + assert!( + !signature.verify(vk, b"wrong message"), + "BLS signature should not verify against a different message" + ); +} + +/// Verify that an invalid proof is caught in round2. +#[test] +fn kryptology_invalid_proof_rejected() { + let mut rng = StdRng::seed_from_u64(99); + let threshold = 2u16; + let max_signers = 3u16; + let ctx = 0u8; + + let (mut bcast1, shares1, _secret1) = + kryptology::round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); + let (_bcast2, _shares2, secret2) = + kryptology::round1(2, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast3, shares3, _secret3) = + kryptology::round1(3, threshold, max_signers, ctx, &mut rng).unwrap(); + + // Corrupt participant 1's proof (flip LSB of ci, keeping it a valid scalar) + bcast1.ci[31] ^= 0x01; + + // Participant 2 should reject participant 1's proof + let received_bcasts: BTreeMap = + [(1, bcast1.clone()), (3, bcast3.clone())].into(); + let received_shares: BTreeMap = + [(1, shares1[&2].clone()), (3, shares3[&2].clone())].into(); + + let result = kryptology::round2(secret2, &received_bcasts, &received_shares); + assert!(result.is_err()); + match result.unwrap_err() { + kryptology::DkgError::InvalidProof { culprit } => assert_eq!(culprit, 1), + other => panic!("expected InvalidProof, got {other:?}"), + } +} + +/// Verify that a share addressed to the wrong participant is rejected in +/// round2. +#[test] +fn kryptology_share_id_mismatch_rejected() { + let mut rng = StdRng::seed_from_u64(42); + let threshold = 2u16; + let max_signers = 3u16; + let ctx = 0u8; + + let (bcast1, shares1, _secret1) = + kryptology::round1(1, threshold, max_signers, ctx, &mut rng).unwrap(); + let (_bcast2, _shares2, secret2) = + kryptology::round1(2, threshold, max_signers, ctx, &mut rng).unwrap(); + let (bcast3, shares3, _secret3) = + kryptology::round1(3, threshold, max_signers, ctx, &mut rng).unwrap(); + + let received_bcasts: BTreeMap = [(1, bcast1), (3, bcast3)].into(); + + let mut wrong_share = shares1[&2].clone(); + wrong_share.id = 3; + let received_shares: BTreeMap = + [(1, wrong_share), (3, shares3[&2].clone())].into(); + + let result = kryptology::round2(secret2, &received_bcasts, &received_shares); + assert!(result.is_err()); + match result.unwrap_err() { + kryptology::DkgError::InvalidShare { culprit } => assert_eq!(culprit, 1), + other => panic!("expected InvalidShare, got {other:?}"), + } +} diff --git a/crates/frost/tests/kryptology_fixtures/2-of-3-ctx-0.json b/crates/frost/tests/kryptology_fixtures/2-of-3-ctx-0.json new file mode 100644 index 00000000..cee3153e --- /dev/null +++ b/crates/frost/tests/kryptology_fixtures/2-of-3-ctx-0.json @@ -0,0 +1,98 @@ +{ + "scenario": "2-of-3-ctx-0", + "threshold": 2, + "max_signers": 3, + "ctx": 0, + "participants": [ + { + "id": 1, + "own_share": "6fe7a63102bb215f2e63c80e34a3d566272391470057cc18cb2b9f7a895d446f", + "round1_bcast": { + "commitments": [ + "83d8ecdc5a7d2b003537db556b72d227939aa8aaf828cb3c815938532ec5c83e61de12417b5dbf632e574fc94f71f6a8", + "9699520ce41c1a7cea3e65a0fd38bb605908590fb436b50ff0463ea0695c7d13dbe6054949774391aeacf09ca8a093cf" + ], + "wi": "1924dfa0b455ec631a7a528f7efe3f8db92a5a4f558f95d5069299fd1a1b5fd6", + "ci": "169cee884e7a8d3e1aeafc96fc6f24465808f7ac7eb7ac1fbfb066ba2c024087" + }, + "shares_sent": [ + { + "to": 2, + "id": 2, + "value": "5525c01444985e474b3b657c3f0bd30e082a7f671601956ebc08317b42ebeb88" + }, + { + "to": 3, + "id": 3, + "value": "3a63d9f786759b2f681302ea4973d0b5e9316d872bab5ec4ace4c37bfc7a92a1" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "a8b196cd671819853edff76cb77b4fa8640a92d76e238ecf33fe70144ffc16885fb9d2f4a57d22232c7925e51d1bffc3", + "vk_share": "8dc83405eb8edc0e88c38b13223b16d3c3bb58e3c6f273c32c478065de8faa177dfc38cacda76688adfcee318291b464", + "signing_share": "15c87b3e2d2d32333cf62de8bfeaaceeaaac521886eccca0f255d093680108f7" + } + }, + { + "id": 2, + "own_share": "4e065ffa61ad2365a33d42190e91c85bd8661934ffaf23db147252909faa05a6", + "round1_bcast": { + "commitments": [ + "a8cd4313241b0433a155fcf367c22ae226e29fc954ab9e30838ffdc2bc6a4df7baca24a05fd9bbb38f99e9d558a21cf7", + "95cd8b8cf649da7090cf8e562a956f37c32e342cc8e5e18470227f71811493472e02dd60ced839b9ea4accc8421c3112" + ], + "wi": "2c6af59835a5ea910aec7b411432b13a78c221caae7258340e0a9a9ea35eb47f", + "ci": "34fd58ac0868df644430c56faf7d849c46f54986597ff55e09bf29b0dcf158cc" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "43550d90fd0969c2b8badc3657501399314b9ae4fe96e191aae5ca625292b176" + }, + { + "to": 3, + "id": 3, + "value": "58b7b263c650dd088dbfa7fbc5d37d1e7f80978500c766247dfedabeecc159d6" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "a8b196cd671819853edff76cb77b4fa8640a92d76e238ecf33fe70144ffc16885fb9d2f4a57d22232c7925e51d1bffc3", + "vk_share": "a7882f0fc549be8adaacc32a4d0a34fa3cf0d2a1363e09045db47a4017c99a6377aa387acae343a01c110d7c54981981", + "signing_share": "18d20bf9d4bd67bbfda0e15f3a2f7d105992491a1a65d9c30fa57bdd6a0ca2f1" + } + }, + { + "id": 3, + "own_share": "709b5f0082c21f9d2eec99ffb870af68474183158168d9fa021188ea82dc5076", + "round1_bcast": { + "commitments": [ + "a4c9ff28683d0dbc12f9e38b6850c6e56f91fada9749fe24530824021b9b1f5740b5eedf925a99afa420e30727f52764", + "b3c299d13d0032d04b321d8ec112eb7609030d90d2dca421c95288dd224fc8088ae4aac1da5266fae179575be51e272b" + ], + "wi": "07651a18f871afe03c190d594061ca5baebc352be98ad8a7385b3efc9a9d2630", + "ci": "240ef8016b73c0d6c98ce323141e131b7f46ed6d00716113eee75af24fb11c92" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "4a67162280a3a1a1bc4b39b4473a73f9f9b86df287fad6f47c4466b48c111314" + }, + { + "to": 2, + "id": 2, + "value": "5d813a9181b2e09f759be9d9ffd591b1207cf88404b1d8773f2af7cf8776b1c5" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "a8b196cd671819853edff76cb77b4fa8640a92d76e238ecf33fe70144ffc16885fb9d2f4a57d22232c7925e51d1bffc3", + "vk_share": "b0642f02c228b4a13b87dbf6053be2aec4f162848187defb7b674f71561eb1af4c6da58616f39e0ac84eb9ff5a2a7fe6", + "signing_share": "1bdb9cb57c4d9d44be4b94d5b4744d320878401baddee6e52cf527276c183ceb" + } + } + ] +} diff --git a/crates/frost/tests/kryptology_fixtures/3-of-3-ctx-0.json b/crates/frost/tests/kryptology_fixtures/3-of-3-ctx-0.json new file mode 100644 index 00000000..675e33bd --- /dev/null +++ b/crates/frost/tests/kryptology_fixtures/3-of-3-ctx-0.json @@ -0,0 +1,101 @@ +{ + "scenario": "3-of-3-ctx-0", + "threshold": 3, + "max_signers": 3, + "ctx": 0, + "participants": [ + { + "id": 1, + "own_share": "0492e87deb89041f39fb02ef29e34a5e56b2866e34d1d0bc424cb782dc8eec01", + "round1_bcast": { + "commitments": [ + "96fe8b3ef10133d9e30991a9347a08d9d684e5f0c22e960877993e7f8cc1965fcd525c654c1cf28cf8b14cc96abaa046", + "b8ee7a50aa1fdbd81b60644ee2f256f11b14165b5be57b8e4091bd55ccd2fe60536ede23677e96d7e5328dc60d00f097", + "b0463740d12d80a834d87f66f50dca6b5860872092e5e6cb1d438488a02f1c016d017a009ff606956862e9a341abe9a9" + ], + "wi": "3b07c49d669794cbb3b09c338975ccc369ba1b9926e479cdb93f3c537f04ddc9", + "ci": "445a769c5e06a2ce71b7ea4664ef197837d11a342ac4e618b7f106cdd79d5b0a" + }, + "shares_sent": [ + { + "to": 2, + "id": 2, + "value": "0e152b19bf45bbe122f40593f4b47b6cd196a468e3a8e22e949c7807f16b4196" + }, + { + "to": 3, + "id": 3, + "value": "6d1f077dc51978f69ffff1f8dbcff11337942cb3ba344891f368626aea771c65" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "b67cc957b409abf46072441ca39f38f3c4475f83054a70ae6a622226fcfe12112782400d3213d8925b5ee40a1dc79514", + "vk_share": "855e8c7950a705f29afe4190c0387e3eab5a5b4096ac89e4d7bc9ef5ab29e0833f478b389e3d3e90b4d5ddc576420a88", + "signing_share": "732e6f52828c69371fb185c8ce16a516cb6a8100616b3dd57c10c09c0a827b43" + } + }, + { + "id": 2, + "own_share": "3c9d784f782cf9a7fe2ce1a66bb3704bf971d5b2e287ee78234abb3bb6329862", + "round1_bcast": { + "commitments": [ + "b497486677c8e128032d80378a0f4bcdb4b355e461e42bd627c4ef7d9b5a4f9f84058e82908d715c143381c3c3502f98", + "957d0a403152ef8ddefa27198ef5b29657ea1aa1b37b0604ab15448dfec2c3bb2693ec0bd1871aa6edfa2bfc70647124", + "a409004071377662c431e7d4600f69bc2b1c4f17ff309746361dba309ea15b411b4045f8a2d4116b9e69b0e281e5e1e7" + ], + "wi": "39efdb83bc0c0721c79f70098b51e0b9c600d22ad20768f75177cfde7ace7bf3", + "ci": "0d929e099f0aeaecd7bbf0d4e0c0bfbf83760ed7097868803dc6fc7947b32355" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "5ef15fc54fb3cd0413255d2ef5bf7e4557047a88e00b83274c9e667a0989ca18" + }, + { + "to": 3, + "id": 3, + "value": "15ca8516d0b92fcfb67ec74fd2c0bc195e27051506ba90c810f700145c20cafb" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "b67cc957b409abf46072441ca39f38f3c4475f83054a70ae6a622226fcfe12112782400d3213d8925b5ee40a1dc79514", + "vk_share": "b9da3e740ab266b479bc0c39b0ea8fda261ef24df18a34310e77b302d20275e7743b6f17cd9b9ed1052ea10f4924e4d0", + "signing_share": "2077c7d5feab13d2404d658b8273056c052fc6e7291d64a35e36c42d3e71462d" + } + }, + { + "id": 3, + "own_share": "495275c3d50c4b19f69c13c0c068ec146a62020e811bf717701527f80d0fb758", + "round1_bcast": { + "commitments": [ + "a3ff23a2c87d82db33ac84d8975bd687b0514600cf8a7ef48ffd76ef2596d424cf873f71c60e095069a19a0c91bd8927", + "972e6e4644c2e5af5883cc3cdbc0380cb9d53ee41854cacc7c19933a444d14d39c02dc410f616b001da236c9835d33bf", + "9440c02c432c6446fe41efea6d8990c7efd6707c7f9f1ec84efc0e2c7dfd5b6b77e9bd015e2056090eb553c270275eb9" + ], + "wi": "6249ec86b032596e931ba95b26e69d45ef323afe2bff6ed171005867df6d7d22", + "ci": "1daa00119cb5a1f83606a8808f2d2688f3712ed34effde90b8b67a2efaf2fe04" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "0faa270f474f9813d29125aaae73dc731db380094c8de9f1ed25a29f2469c52a" + }, + { + "to": 2, + "id": 2, + "value": "49b2cbbff0d5db91526656592bacf1b88de4f0ce62eaeffba64f90e896d36c36" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "b67cc957b409abf46072441ca39f38f3c4475f83054a70ae6a622226fcfe12112782400d3213d8925b5ee40a1dc79514", + "vk_share": "8ed40869c509af07504e08226464a475385154fc26ad4f989c814f36a6ded57e1e424f66c9ffddf2b1d2fa4717cd38b2", + "signing_share": "584e5b054141769819e0f5016557c13bac5f8fd4420c747274748a7853a79eb7" + } + } + ] +} diff --git a/crates/frost/tests/kryptology_fixtures/invalid-proof.json b/crates/frost/tests/kryptology_fixtures/invalid-proof.json new file mode 100644 index 00000000..2e49d3ce --- /dev/null +++ b/crates/frost/tests/kryptology_fixtures/invalid-proof.json @@ -0,0 +1,94 @@ +{ + "scenario": "invalid-proof", + "threshold": 2, + "max_signers": 3, + "ctx": 0, + "participants": [ + { + "id": 1, + "own_share": "36def1bba1e65144492c3e74a74749ff911be1a1d8abebd8276b735d31e80e87", + "round1_bcast": { + "commitments": [ + "8bfdaf557e7a365d9c75cd0b1ee2d4ba7e6f4f100c99f324cc3d5b1c0bb07b4b15f32468f87abce40f1daf1524b90761", + "adb59012564d5e7a935ffc7cc22f4e68692edeb8039097e39fa3e252d56317ec234acc584b36a7818bd770c92f2a0d41" + ], + "wi": "21ebef1eda9e73251c7947ce127b161d00b6e03814c040e216361478f31d5385", + "ci": "4a716c6b8ea9bca97b16f3d3b6f66c74d463520659593e2ee6aa17b5a3e0f002" + }, + "shares_sent": [ + { + "to": 2, + "id": 2, + "value": "3547e9c1608c58b0fd18839ac657fd1fa3e921c55d5ce906bf03fa414235d32c" + }, + { + "to": 3, + "id": 3, + "value": "33b0e1c71f32601db104c8c0e568b03fb6b661e8e20de635569c8125528397d1" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "971812af8f5b42b493625bcc4fa1e44acda41c94c6cbe9763674765365fa4bd8ca747c081c8e7c30a4acf089210d3b9a", + "vk_share": "b600b36660a6b68dea3d05c86028ff5056415b492a50e5188b73424d4b31e79dc3f8db3d185d5ea2f6c6367d4546c188", + "signing_share": "1e73c557eb8d575f0d45e760a34e456c3d10fac38a476a32af6162805a7391f5" + } + }, + { + "id": 2, + "own_share": "2c395cc0763fbbbc7862a4983bdd875285f07816b7ed371e52ad95598cfcd16d", + "round1_bcast": { + "commitments": [ + "83a588bc21634d57e260d6f39772f2ecfa7ffe6a2f7403cad88495ef831afef86da67d2b1b5cfa1945dfab4d8f647122", + "a01895c79ae4ecb6175b39a9050f3e796f326a7e816fca5525dd99338bb5639c0a161158996b868bb348d6d3222ae596" + ], + "wi": "726ac2fe13a7be09cadd2affa70f3c29d555b2e90db1f9734ae7acd71cf84f3b", + "ci": "66050fb2b10ebbaa84a07a45bac02142c495454e82b8541ab237ac82d904cc4c" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "31fa0036b6f62d76308ff64cc56c2103769089397bd308035687784b345d2924" + }, + { + "to": 3, + "id": 3, + "value": "2678b94a35894a02c03552e3b24eeda1955066f3f40766394ed3b267e59c79b6" + } + ], + "expected_round2": { + "kind": "invalid_proof", + "culprit": 1 + } + }, + { + "id": 3, + "own_share": "13adcf75fcad7d4892795687ce8272920a6e7a57abdd04fcd5ba212c414accd1", + "round1_bcast": { + "commitments": [ + "b457326e06db3e90e97bbce3d373687b0ac75d0c5d216a20f77c100d7b13d08bea2f5c071957291270cb8ce12d852c51", + "876205abfa05a57325946eae42fe461ca14272a2eb166590c96a6b208c8efe4c2ba4bb8fbc3633c8fcd3108e74d9e8d3" + ], + "wi": "3407279b3f7da5c153ade91e296a8824b8df875511bfbb80efa1d22fb255aca5", + "ci": "12a29d0aebf1d63ca8f9979c9f50fb91eef6cde0ad47d329f2a22b7ab16f3d25" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "29887ab8bc4e55ecc6c38aa7403cb26e892233eb35c6d256316e76d6f42e5a4b" + }, + { + "to": 2, + "id": 2, + "value": "1e9b25175c7de99aac9e7097875f928049c8572170d1eba983944c019abc938e" + } + ], + "expected_round2": { + "kind": "invalid_proof", + "culprit": 1 + } + } + ] +} diff --git a/crates/frost/tests/kryptology_fixtures/malformed-share-id.json b/crates/frost/tests/kryptology_fixtures/malformed-share-id.json new file mode 100644 index 00000000..cd0c4f17 --- /dev/null +++ b/crates/frost/tests/kryptology_fixtures/malformed-share-id.json @@ -0,0 +1,96 @@ +{ + "scenario": "malformed-share-id", + "threshold": 2, + "max_signers": 3, + "ctx": 0, + "participants": [ + { + "id": 1, + "own_share": "5a003ff621865347e52d252e6d1be691b9431caf11585cdbbb4b770e848f1d5a", + "round1_bcast": { + "commitments": [ + "883e25f48cabc48002e04f7eb58482dbeed7aea6cb0af4160f1d39ce830036f534ee3def786e342d0aa0fe3504840b5a", + "a9ca86507b03fe948b81b2eedf8f692fa08a60ebf2fab5042fd4a314ad39e0aed933ebab3bdbd2167d39a4ea6ea4de7f" + ], + "wi": "3d72a1abdf3e71f5fbd0b18e1b4d7ccf21947d045ef88054474b6c62203b6ed0", + "ci": "032ee5e8bf5c4bf510daa1d434b9cfc448a935a8df10d8393ee9d32d3e9798e9" + }, + "shares_sent": [ + { + "to": 2, + "id": 3, + "value": "3b77bd501fc44e3302a2d22082d7c779e8780986ecfba8e817b37a08026365fd" + }, + { + "to": 3, + "id": 3, + "value": "1cef3aaa1e02491e20187f129893a86217acf65ec89ef4f4741b7d018037aea0" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "ab2761905e0a0400fc6b5b9b8a309bc68102cbaa9f54801f3bf1bd61072c6e21dca51d4cdb26ee16dd96583dadaf3a45", + "vk_share": "a248443c0eab2131e2430700a02fd0ea797856ffc407fb8c980aa4ea015455c3cad0799e4e16de3053db73fe1d17a2cc", + "signing_share": "2091fc8168c8e615be05d2382f9b940f95711e63c0d775d4ae9b4518feef321e" + } + }, + { + "id": 2, + "own_share": "1e8152a8e8d089fcb7eb4c8d88696c5a8ccaa96969e7873562ba53373d7c5b06", + "round1_bcast": { + "commitments": [ + "b87c923a2070b0dd6072593237b60cdc944c229c4b5b0b14b6124dbeda3ec7b572c844b26de0dbf462205c775dd2d041", + "a544de0ac6d1e324d877bc9430f57cd5a06127feb13d51de3be1c45b824e0816f066bd8bbec1ff40264001a7466de305" + ], + "wi": "22317987a9fde571a811960e109559c92c03cf2fb961935334be97f1636c0e8e", + "ci": "2d22b1dfeeb27df560dfb9fad565d747340d01df9e678d8be7a7edc64ba1e25b" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "4fcda7ddf2e27adef98bb60ceb024a961b06f73dd312b9f77bff6d95bcb3451c" + }, + { + "to": 3, + "id": 3, + "value": "6122a4c7085c1662a984bb162f726624524bff9800bab072497538d7be4570f1" + } + ], + "expected_round2": { + "kind": "invalid_share", + "culprit": 1 + } + }, + { + "id": 3, + "own_share": "681887db99f3584ee8d5b3c3ea980beae36f032b7a9f793acb2a0c985e90cf10", + "round1_bcast": { + "commitments": [ + "90b0825a65430965219c3d1356b065ac3fbc59c98917d82842ec614f933f87d6c54e96368febbb1e06136f40cc4ebd69", + "88e449375604235bf195f04bfdbc15811def80697916e0c0cbd78b85d7509004d722ac46d522a93f0e8d0f53cd4271c7" + ], + "wi": "33bae1ae3df865782681200578a54d295985c8015a8957942e8d8be7a53e9b0a", + "ci": "4998088840ea240616e56aef6150813b30958419e3eabe5e52ed8b139eae4626" + }, + "shares_sent": [ + { + "to": 1, + "id": 1, + "value": "5e9f6353a79b127f45c0a70ceac112f268a2527cdc6916ff77506072bdaccfaa" + }, + { + "to": 2, + "id": 2, + "value": "635bf597a0c73567174b2d686aac8f6ea608aad42b84481d213d36858e1ecf5d" + } + ], + "expected_round2": { + "kind": "success", + "verification_key": "ab2761905e0a0400fc6b5b9b8a309bc68102cbaa9f54801f3bf1bd61072c6e21dca51d4cdb26ee16dd96583dadaf3a45", + "vk_share": "95e4e1281513eafc4129a144fe689c120f8a81c1e77b62a510321be725fa081706d183dc1e4d74a59dfe9c00a2e1129d", + "signing_share": "723cbff996b43a877f3915e4a8fc426bf9aa551f43fac2a288bac2729d0deea0" + } + } + ] +} diff --git a/crates/frost/tests/kryptology_interop.rs b/crates/frost/tests/kryptology_interop.rs new file mode 100644 index 00000000..432e8ebd --- /dev/null +++ b/crates/frost/tests/kryptology_interop.rs @@ -0,0 +1,241 @@ +#![allow(missing_docs)] + +use std::collections::BTreeMap; + +use pluto_frost::kryptology; +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +struct FixtureParticipant { + id: u32, + #[serde(deserialize_with = "hex_serde::hex_32")] + own_share: [u8; 32], + round1_bcast: FixtureRound1Bcast, + shares_sent: Vec, + expected_round2: ExpectedRound2, +} + +#[derive(Clone, Deserialize)] +struct FixtureRound1Bcast { + #[serde(deserialize_with = "hex_serde::hex_48_vec")] + commitments: Vec<[u8; 48]>, + #[serde(deserialize_with = "hex_serde::hex_32")] + wi: [u8; 32], + #[serde(deserialize_with = "hex_serde::hex_32")] + ci: [u8; 32], +} + +#[derive(Clone, Deserialize)] +struct FixtureShamirShare { + to: u32, + id: u32, + #[serde(deserialize_with = "hex_serde::hex_32")] + value: [u8; 32], +} + +#[derive(Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ExpectedRound2 { + Success { + #[serde(deserialize_with = "hex_serde::hex_48")] + verification_key: [u8; 48], + #[serde(deserialize_with = "hex_serde::hex_48")] + vk_share: [u8; 48], + #[serde(deserialize_with = "hex_serde::hex_32")] + signing_share: [u8; 32], + }, + InvalidShare { + culprit: u32, + }, + InvalidProof { + culprit: u32, + }, +} + +#[derive(Deserialize)] +struct FixtureScenario { + threshold: u16, + max_signers: u16, + ctx: u8, + participants: Vec, +} + +impl From<&FixtureRound1Bcast> for kryptology::Round1Bcast { + fn from(f: &FixtureRound1Bcast) -> Self { + Self { + commitments: f.commitments.clone(), + wi: f.wi, + ci: f.ci, + } + } +} + +#[test] +fn kryptology_fixture_round2_interop_2_of_3_ctx_0() { + replay_fixture( + include_str!("./kryptology_fixtures/2-of-3-ctx-0.json"), + true, + ); +} + +#[test] +fn kryptology_fixture_round2_interop_3_of_3_ctx_0() { + replay_fixture( + include_str!("./kryptology_fixtures/3-of-3-ctx-0.json"), + true, + ); +} + +#[test] +fn kryptology_fixture_round2_interop_malformed_share_id() { + replay_fixture( + include_str!("./kryptology_fixtures/malformed-share-id.json"), + false, + ); +} + +#[test] +fn kryptology_fixture_round2_interop_invalid_proof() { + replay_fixture( + include_str!("./kryptology_fixtures/invalid-proof.json"), + false, + ); +} + +fn replay_fixture(json: &str, require_group_signature: bool) { + let scenario: FixtureScenario = serde_json::from_str(json).expect("invalid fixture JSON"); + + let mut key_packages = BTreeMap::new(); + let mut public_key_packages = Vec::new(); + + for participant in &scenario.participants { + let id = participant.id; + let received_bcasts = scenario + .participants + .iter() + .filter(|&sender| sender.id != id) + .map(|sender| { + ( + sender.id, + kryptology::Round1Bcast::from(&sender.round1_bcast), + ) + }) + .collect(); + + let received_shares = scenario + .participants + .iter() + .filter(|&sender| sender.id != id) + .map(|sender| { + let s = sender + .shares_sent + .iter() + .find(|s| s.to == id) + .expect("share for recipient"); + ( + sender.id, + kryptology::ShamirShare { + id: s.id, + value: s.value, + }, + ) + }) + .collect(); + + let secret = kryptology::Round1Secret::from_raw( + participant.id, + scenario.ctx, + scenario.threshold, + scenario.max_signers, + &participant.own_share, + &participant.round1_bcast.commitments, + ) + .expect("Round1Secret::from_raw should succeed"); + let result = kryptology::round2(secret, &received_bcasts, &received_shares); + + match &participant.expected_round2 { + ExpectedRound2::Success { + verification_key, + vk_share, + signing_share, + } => { + let (round2_bcast, key_package, public_key_package) = + result.expect("round2 should succeed"); + assert_eq!(round2_bcast.verification_key, *verification_key); + assert_eq!(round2_bcast.vk_share, *vk_share); + assert_eq!( + kryptology::scalar_to_be(&key_package.signing_share().to_scalar()), + *signing_share, + ); + + key_packages.insert(id, key_package); + public_key_packages.push(public_key_package); + } + ExpectedRound2::InvalidShare { culprit } => { + let err = result.expect_err("round2 should fail"); + assert!( + matches!(err, kryptology::DkgError::InvalidShare { culprit: c } if c == *culprit), + "expected InvalidShare(culprit={culprit}), got {err:?}" + ); + } + ExpectedRound2::InvalidProof { culprit } => { + let err = result.expect_err("round2 should fail"); + assert!( + matches!(err, kryptology::DkgError::InvalidProof { culprit: c } if c == *culprit), + "expected InvalidProof(culprit={culprit}), got {err:?}" + ); + } + } + } + + if !require_group_signature { + return; + } + + let vk = public_key_packages[0].verifying_key(); + for package in &public_key_packages[1..] { + assert_eq!(vk, package.verifying_key()); + } + + let message = b"kryptology fixture signing"; + + let partial_sigs: Vec<_> = key_packages + .iter() + .map(|(&id, kp)| kryptology::BlsPartialSignature::from_key_package(id, kp, message)) + .collect(); + + let signature = + kryptology::BlsSignature::from_partial_signatures(scenario.threshold, &partial_sigs) + .expect("BLS signature combination should succeed"); + + assert!( + signature.verify(vk, message), + "fixture-derived BLS threshold signature should verify" + ); +} + +mod hex_serde { + use serde::Deserialize; + + fn decode_hex(s: &str) -> Result<[u8; N], String> { + hex::decode(s) + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| format!("expected {N} bytes")) + } + + pub fn hex_32<'de, D: serde::Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> { + decode_hex(<&str>::deserialize(d)?).map_err(serde::de::Error::custom) + } + + pub fn hex_48<'de, D: serde::Deserializer<'de>>(d: D) -> Result<[u8; 48], D::Error> { + decode_hex(<&str>::deserialize(d)?).map_err(serde::de::Error::custom) + } + + pub fn hex_48_vec<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Vec::::deserialize(d)? + .iter() + .map(|s| decode_hex(s).map_err(serde::de::Error::custom)) + .collect() + } +} diff --git a/crates/frost/tests/kryptology_round_trip.rs b/crates/frost/tests/kryptology_round_trip.rs new file mode 100644 index 00000000..e0335f46 --- /dev/null +++ b/crates/frost/tests/kryptology_round_trip.rs @@ -0,0 +1,113 @@ +#![allow(missing_docs)] + +use std::collections::BTreeMap; + +use pluto_frost::kryptology; +use rand::{SeedableRng, rngs::StdRng}; + +/// FROST DKG + BLS threshold signing (Ethereum 2.0 compatible). +/// This matches Go's signing flow: non-interactive BLS partial signatures +/// combined via Lagrange interpolation, verified with standard BLS pairings. +/// +/// See: https://github.com/coinbase/kryptology/blob/1dcc062313d99f2e56ce6abc2003ef63c52dd4a5/test/frost_dkg/bls/main.go#L23 +#[test] +fn kryptology_bls_round_trip_2_of_4_ctx_0() { + let mut rng = StdRng::seed_from_u64(20260410); + let threshold = 2u16; + let max_signers = 4u16; + let ctx = 0u8; + + let mut round1_bcasts = BTreeMap::new(); + let mut round1_shares: BTreeMap> = BTreeMap::new(); + let mut round1_secrets = BTreeMap::new(); + + for id in 1..=u32::from(max_signers) { + let (bcast, shares, secret) = kryptology::round1(id, threshold, max_signers, ctx, &mut rng) + .expect("round1 should succeed for each participant"); + + assert_eq!(shares.len(), (max_signers - 1) as usize); + for (&recipient_id, share) in &shares { + assert_eq!(share.id, recipient_id); + } + + round1_bcasts.insert(id, bcast); + round1_secrets.insert(id, secret); + + for (&recipient_id, share) in &shares { + round1_shares + .entry(recipient_id) + .or_default() + .insert(id, share.clone()); + } + } + + assert_eq!(round1_bcasts.len(), max_signers as usize); + assert_eq!(round1_shares.len(), max_signers as usize); + + let mut round2_bcasts = BTreeMap::new(); + let mut key_packages = BTreeMap::new(); + let mut public_key_packages = BTreeMap::new(); + + for id in 1..=u32::from(max_signers) { + let received_bcasts: BTreeMap = round1_bcasts + .iter() + .filter(|&(sender_id, _)| *sender_id != id) + .map(|(&sender_id, bcast)| (sender_id, bcast.clone())) + .collect(); + let received_shares = round1_shares + .remove(&id) + .expect("each participant should receive shares from all peers"); + let secret = round1_secrets + .remove(&id) + .expect("round1 secret should exist for each participant"); + + assert_eq!(received_bcasts.len(), (max_signers - 1) as usize); + assert_eq!(received_shares.len(), (max_signers - 1) as usize); + + let (round2_bcast, key_package, public_key_package) = + kryptology::round2(secret, &received_bcasts, &received_shares) + .expect("round2 should succeed for each participant"); + + round2_bcasts.insert(id, round2_bcast); + key_packages.insert(id, key_package); + public_key_packages.insert(id, public_key_package); + } + + let group_key = public_key_packages[&1].verifying_key(); + for (&id, public_key_package) in &public_key_packages { + assert_eq!( + public_key_package.verifying_key(), + group_key, + "participant {id} derived a different group verification key" + ); + } + + let verification_key_bytes = round2_bcasts[&1].verification_key; + for (&id, round2_bcast) in &round2_bcasts { + assert_eq!( + round2_bcast.verification_key, verification_key_bytes, + "participant {id} broadcast a different round2 verification key" + ); + } + + // BLS threshold signing (matches Go's main.go) + let message = b"All my bitcoin is stored here"; + let signing_participants = [1u32, 2u32]; + + let partial_sigs: Vec<_> = signing_participants + .iter() + .map(|&id| { + kryptology::BlsPartialSignature::from_key_package(id, &key_packages[&id], message) + }) + .collect(); + + assert_eq!(partial_sigs.len(), threshold as usize); + + let signature = kryptology::BlsSignature::from_partial_signatures(threshold, &partial_sigs) + .expect("BLS signature combination should succeed"); + + assert!( + signature.verify(group_key, message), + "BLS threshold signature should verify against the group public key" + ); +}