Conversation
- Adjust `&` handling in patterns
- Replaces custom deserialization code
- Use vec directly
- Infallible conversions - Disable arithmetic checks
- Use `assert!` over `debug_assert!` - Reference RFC when required
|
@claude Review |
|
Claude finished @emlautarom1's task in 7m 8s —— View job Code Review —
|
| /// | ||
| /// `ExpandMsgXmd(SHA-256, msg, DST, 48)` -> reverse bytes -> pad to 64 -> | ||
| /// `Scalar::from_bytes_wide`. | ||
| fn kryptology_hash_to_scalar(msg: &[u8]) -> Scalar { |
There was a problem hiding this comment.
It can be done without manually reversing:
fn kryptology_hash_to_scalar(msg: &[u8]) -> Scalar {
let xmd = expand_msg_xmd(msg, KRYPTOLOGY_DST, 48);
Scalar::from_be_bytes_wide(&xmd)
}
and new Scalar function needed:
/// Reduce big-endian bytes modulo the scalar field order.
pub(crate) fn from_be_bytes_wide(bytes: &[u8]) -> Self {
let mut scalar = blst_scalar::default();
let mut fr = blst_fr::default();
unsafe {
blst_scalar_from_be_bytes(&mut scalar, bytes.as_ptr(), bytes.len());
blst_fr_from_scalar(&mut fr, &scalar);
}
Scalar(fr)
}
| rng: &mut R, | ||
| ) -> Result<(Round1Bcast, BTreeMap<u32, ShamirShare>, Round1Secret), DkgError> { | ||
| // Kryptology encodes participant identifiers into a single byte. | ||
| if max_signers > u16::from(u8::MAX) { |
There was a problem hiding this comment.
Round1Secret::from_raw misses the validation logic (lines 282 - 290). What do you think about extracting it to the separate function and using in both places?
| BTreeMap::new(); | ||
| let mut share_sum = Scalar::ZERO; | ||
|
|
||
| for (&sender_id, bcast) in received_bcasts { |
There was a problem hiding this comment.
We could check secret.id == sender_id and return an error to prevent some wrong usage of the round2 function.
| /// 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(); | ||
| { |
| //! Contains the key material types (identifiers, shares, packages) and the | ||
| //! polynomial evaluation functions needed by the kryptology-compatible DKG. | ||
|
|
||
| #![allow(clippy::arithmetic_side_effects)] |
There was a problem hiding this comment.
Would be better to limit it to some specific scopes.
| //! The output types ([`KeyPackage`], [`PublicKeyPackage`]) are standard | ||
| //! frost-core types usable with frost-core's signing protocol. | ||
|
|
||
| #![allow(clippy::arithmetic_side_effects)] |
There was a problem hiding this comment.
Would be better to limit it to some specific scopes.
| 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 { |
There was a problem hiding this comment.
This can be possibly optimized if we would keep u32 value at the construction time:
#[derive(Copy, Clone, Debug)]
pub struct Identifier {
id: u32,
scalar: Scalar,
}
impl Identifier {
pub fn from_u32(id: u32) -> Result<Self, FrostCoreError> {
let scalar = Scalar::from(u64::from(id));
if scalar == Scalar::ZERO {
Err(FrostCoreError::InvalidZeroScalar)
} else {
Ok(Self { id, scalar })
}
}
pub fn to_scalar(&self) -> Scalar {
self.scalar
}
}
impl Ord for Identifier {
fn cmp(&self, other: &Self) -> Ordering {
self.id.cmp(&other.id)
}
}
|
|
||
| /// Errors from the kryptology-compatible DKG. | ||
| #[derive(Debug)] | ||
| pub enum DkgError { |
There was a problem hiding this comment.
Would be nice to cover all errors with tests.
varex83agent
left a comment
There was a problem hiding this comment.
This PR adds the pluto-frost crate implementing a FROST-compatible DKG and BLS threshold signing over BLS12-381, targeting interoperability with Go's Coinbase Kryptology FROST DKG. The cryptographic math has been verified correct: the Horner polynomial evaluation, challenge preimage format, expand_msg_xmd construction (matching RFC 9380 and confirmed against test vectors), blst_p1_mult bit-width (255 is correct for the BLS12-381 scalar field), and the Scalar::ONE Montgomery constant all check out.
Major issues requiring changes before merge:
- Secret key types (
SigningShare,KeyPackage,Round1Secret) have noZeroize/Dropimplementation — signing key material lingers in process memory. round2enforces exactlymax_signers-1received packages; Go's implementation accepts any count in[threshold-1, max_signers]for fault-tolerance, meaning a single offline peer aborts every honest participant in the Rust version.expand_msg_xmdispubwith threeassert!()calls that will panic the process if a downstream caller passes e.g. a DST > 255 bytes.Round1Secret::from_rawperforms no input validation (id,threshold,max_signers), enabling underflow inround2and bypassed peer verification whenmax_signers=1.FrostCoreErrorandDkgErrordo not implementstd::error::Error, breaking usage with?in downstream crates.
Minor/nit findings cover test coverage gaps (no ctx≠0 test, no tampered-share test), API surface issues (glob re-exports, public fields on wire types), and a non-constant-time scalar equality used in proof verification.
| Ordering::Equal => continue, | ||
| other => return other, | ||
| } | ||
| } |
There was a problem hiding this comment.
major: No Zeroize/Drop on secret key types — signing material lingers in memory
SigningShare, KeyPackage, Round1Secret (and Round1Secret::coefficients) all hold secret scalar material (blst_fr = [u64; 4]) but implement neither Zeroize nor a custom Drop. When these values are dropped the heap/stack bytes are never overwritten, leaving long-term signing shares and DKG polynomial coefficients recoverable from memory dumps or swap for the lifetime of the process.
The workspace already depends on zeroize = "1.8.2". Add zeroize.workspace = true to [dependencies] in Cargo.toml, implement Zeroize for Scalar (zero the four u64 limbs), then derive ZeroizeOnDrop for SigningShare, KeyPackage, and Round1Secret (clearing coefficients: Vec<Scalar> on drop).
| /// 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( |
There was a problem hiding this comment.
major: Round1Secret::from_raw skips all input validation
Unlike round1, from_raw never calls validate_num_of_signers or checks id. Consequences:
max_signers = 0:round2computes(secret.max_signers - 1) as usize; in release builds the u16 subtraction wraps to65535, making the package-count check demand 65535 entries and silently returnErr(IncorrectPackageCount).max_signers = 1/threshold = 1:expected = 0, both length checks pass on empty maps, the peer-verification loop is skipped entirely, and the caller receives aKeyPackagewith no proof-of-knowledge or Feldman VSS verification performed.id = 0: surfaces as opaqueDkgError::FrostCoreError(InvalidZeroScalar)deep inround2instead ofDkgError::InvalidParticipantId(0).
Fix: add the same guards as round1 at the top of from_raw:
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)); }There was a problem hiding this comment.
Intended to be used for tests only, probably better to gate it behind a cfg.
There was a problem hiding this comment.
It's better to have the #[cfg(test)]
There was a problem hiding this comment.
Also add comment on the behavior
| }; | ||
|
|
||
| Ok((bcast, shares, secret)) | ||
| } |
There was a problem hiding this comment.
major: round2 requires exactly max_signers-1 packages; Go allows threshold-1..max_signers
Go's Round2 (dkg_round2.go) accepts threshold-1 <= len(bcast) <= max_signers, allowing fault-tolerant DKG where an offline or slow peer does not abort the round for everyone else — as long as at least threshold-1 other peers responded, the participant can complete. The Rust implementation requires exactly max_signers - 1:
let expected = (secret.max_signers - 1) as usize;
if received_bcasts.len() != expected || received_shares.len() != expected {
return Err(DkgError::IncorrectPackageCount);
}This is a functional deviation: a single missing broadcast fails round2 for every honest participant even when enough shares exist to complete DKG. This should either be relaxed to >= (secret.threshold - 1) as usize, or explicitly documented as a deliberate stricter constraint.
| } | ||
|
|
||
| /// RFC 9380 Section 5.3.1 using SHA-256 | ||
| pub fn expand_msg_xmd(msg: &[u8], dst: &[u8], len_in_bytes: usize) -> Vec<u8> { |
There was a problem hiding this comment.
major: expand_msg_xmd is pub with assert!() — panics on invalid caller input
Three assert!() calls will terminate the process if a downstream caller passes invalid parameters (ell > 255, len_in_bytes > 65535, dst.len() > 255). While current internal callers use hardcoded safe values, the function is part of the public API. A networked validator node integrating this crate could be crashed by triggering a code path that indirectly calls this with attacker-influenced lengths.
Options:
- Change the signature to
fn expand_msg_xmd(...) -> Result<Vec<u8>, ExpandMsgError>and replaceassert!withif ... { return Err(...) }. - Make it
pub(crate)since no external caller needs it directly (the Kryptology hash-to-scalar is what callers actually need).
Option 2 is simpler if no external party needs to call expand_msg_xmd directly.
There was a problem hiding this comment.
I think this comment is legit
There was a problem hiding this comment.
No external usage so pub(crate) sounds more reasonable.
| #[derive(Debug)] | ||
| pub enum FrostCoreError { | ||
| /// Participant ID is zero. | ||
| InvalidZeroScalar, |
There was a problem hiding this comment.
major: FrostCoreError and DkgError do not implement std::error::Error
Both error types derive Debug manually but do not implement std::error::Error. This means they:
- Cannot be used with the
?operator in downstream crates - Cannot be wrapped via
#[from] - Cannot be boxed as
Box<dyn std::error::Error>
The project uses thiserror consistently for library error types. Add thiserror.workspace = true to [dependencies] and:
#[derive(Debug, thiserror::Error)]
pub enum FrostCoreError {
#[error("participant ID is zero")]
InvalidZeroScalar,
// ...
}Also, FrostCoreError is an implementation detail — it should be pub(crate) and only DkgError should be part of the public API surface.
| // Verify Feldman share | ||
| let share = received_shares | ||
| .get(&sender_id) | ||
| .ok_or(DkgError::IncorrectPackageCount)?; |
There was a problem hiding this comment.
minor: Misleading IncorrectPackageCount error when a share is missing for a specific sender
let share = received_shares
.get(&sender_id)
.ok_or(DkgError::IncorrectPackageCount)?;IncorrectPackageCount is already used above for the wrong map length. When a sender is present in received_bcasts but absent from received_shares, the correct error is one that identifies the culprit. Use DkgError::InvalidShare { culprit: sender_id } here (or add a dedicated MissingShare { culprit } variant) so the error message attributes the failure to the correct participant.
| //! Go's Coinbase Kryptology FROST DKG, and BLS threshold signing (Ethereum 2.0 | ||
| //! compatible). | ||
|
|
||
| #![allow(non_snake_case)] |
There was a problem hiding this comment.
minor: #![allow(non_snake_case)] at crate root is too broad
wi and ci are already valid snake_case identifiers (all lowercase). If this allow was added for mathematical variable names from the FROST paper (e.g., Ri, Wi) those usages should be individually annotated:
#[allow(non_snake_case)]
fn kryptology_challenge(...) { let Ri = ...; }A crate-level suppression will hide real naming violations in any code added to the crate in the future.
| /// 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 { |
There was a problem hiding this comment.
nit: Unnecessary inner scope and reference alias in from_key_package — looks like an unfinished zeroization attempt:
let scalar = key_package.signing_share().to_scalar();
{
let signing_share: &Scalar = &scalar; // redundant reborrow
let h_msg = hash_to_g2(msg);
BlsPartialSignature { identifier: id, point: p2_mult(&h_msg, signing_share) }
}The inner block drops signing_share but scalar remains live and unzeroized in the outer scope. Simplify:
let scalar = key_package.signing_share().to_scalar();
let h_msg = hash_to_g2(msg);
BlsPartialSignature { identifier: id, point: p2_mult(&h_msg, &scalar) }(A real zeroization should use Zeroizing<Scalar> once Scalar: Zeroize is implemented.)
|
|
||
| pub use curve::*; | ||
| pub use frost_core::*; | ||
| pub use rand_core; |
There was a problem hiding this comment.
nit: pub use rand_core re-exports the entire rand_core crate as stable public API, creating hard version coupling — any semver bump to rand_core in the workspace becomes a breaking change for downstream users of this crate. If the intent is to let callers use RngCore + CryptoRng without adding rand_core as a direct dep, re-export only the specific traits: pub use rand_core::{CryptoRng, RngCore};. Otherwise remove the pub.
| 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); | ||
|
|
There was a problem hiding this comment.
nit: blst_p1_mult and blst_p2_mult are called with nbits=255 — this is correct (the BLS12-381 scalar field order r has exactly 255 significant bits), but future readers will wonder why 255 and not 256. Add a brief comment:
// BLS12-381 scalar field order has 255 significant bits; blst skips the MSB.
blst_p1_mult(&mut out, &self.0, scalar.b.as_ptr(), 255);|
|
||
| /// 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); |
There was a problem hiding this comment.
Should impl Zeroize for Scalar
| /// # Security | ||
| /// | ||
| /// This MUST NOT be sent to other participants. | ||
| pub struct Round1Secret { |
There was a problem hiding this comment.
should derive ZeroizeOnDrop
| /// | ||
| /// See: https://github.com/ZcashFoundation/frost/blob/3ffc19d8f473d5bc4e07ed41bc884bdb42d6c29f/frost-core/src/keys.rs#L82-L87 | ||
| #[derive(Copy, Clone, Debug)] | ||
| pub struct SigningShare(Scalar); |
There was a problem hiding this comment.
should derive ZeroizeOnDrop
| /// 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)] |
There was a problem hiding this comment.
Should derive ZeroizeOnDrop
| /// A Shamir secret share matching Go's `sharing.ShamirShare`. | ||
| /// | ||
| /// The `value` field is in **big-endian** byte order. | ||
| #[derive(Clone, Debug, PartialEq, Eq)] |
There was a problem hiding this comment.
Should derive ZeroizeOnDrop
| let expected = (secret.max_signers - 1) as usize; | ||
| if received_bcasts.len() != expected || received_shares.len() != expected { | ||
| return Err(DkgError::IncorrectPackageCount); | ||
| } |
There was a problem hiding this comment.
This seem stricter than golang implementation of round2 check, they are checking with threshold
https://github.com/coinbase/kryptology/blob/v1.8.0/pkg/dkg/frost/dkg_round2.go#L39-L49
| BTreeMap::new(); | ||
| let mut share_sum = Scalar::ZERO; | ||
|
|
||
| for (&sender_id, bcast) in received_bcasts { |
| /// 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( |
There was a problem hiding this comment.
It's better to have the #[cfg(test)]
| /// 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( |
There was a problem hiding this comment.
Also add comment on the behavior
Partially resolves #254.
Based on https://github.com/ZcashFoundation/frost, we build a library implementing FROST compatible with Coinbase's Kryptology (https://github.com/coinbase/kryptology/blob/v1.8.0/pkg/dkg/frost/README.md). No new dependencies are added to the workspace.
I've created some custom Go test cases in Charon that are not part of this PR that generate some fixtures with intermediate values which gives us some initial confidence that we can interop with Go nodes. Still, full compatibility requires further attention.
This PR requires nuanced review due to the challenging cryptography aspects of the code.