feat(dkg): FROST implementation#328
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)
}
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.
| }; | ||
|
|
||
| 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.
* fix: address comments and reviews in FROST impl * fix: refine error * fix: more tests on frost core * fix: add tests for curve * fix: add and refactor tests on kryptology * fix: simplify tests * fix: address comments
|
Claude finished @iamquang95's task in 2m 55s —— View job Todo List
Review — Current State of
|
| Issue | Where fixed |
|---|---|
| Zeroize on secret types | Round1Secret, SigningShare, KeyPackage, ShamirShare all derive ZeroizeOnDrop; Scalar implements Zeroize |
FrostCoreError / DkgError missing std::error::Error |
Both now use thiserror::Error; error renamed KryptologyError (more appropriate) |
Round1Secret::from_raw public + no validation |
Gated behind #[cfg(test)]/pub(crate); calls validate_round_parameters |
from_key_package id mismatch |
Now reads id from key_package.identifier().to_u32() |
round2 too strict (required exactly max_signers-1) |
Now accepts threshold-1 ..= max_signers-1 (received_bcasts.len() < min_received || > max_received) |
No zero-ci guard |
Added explicit rejection at kryptology.rs:427-429 |
| Missing-share error doesn't identify culprit | Changed to KryptologyError::InvalidShare { culprit: sender_id } |
| Scalar PartialEq not constant-time | Proof check uses ci_check.constant_time_eq(&ci) |
kryptology_hash_to_scalar byte-reversal |
Now uses Scalar::from_be_bytes_wide directly |
Identifier recomputes scalar on every call |
Now stores both u32 and Scalar |
#![allow(non_snake_case)] crate-level |
Removed from lib.rs and not present in any module |
expand_msg_xmd public with panics |
Now private (fn, not pub fn) |
blst_p1_mult/blst_p2_mult nbits=255 unexplained |
Comments added in curve.rs:241 and kryptology.rs:661 |
pub use rand_core re-exports entire crate |
Changed to pub use rand_core::{CryptoRng, RngCore} |
Unnecessary inner scope in from_key_package |
Simplified |
sender_id == secret.id not checked in round2 |
Added check at kryptology.rs:416-418 |
| Test coverage gaps | New tests: round2_accepts_threshold_subset, round2_rejects_missing_share_with_culprit, round2_rejects_self_broadcast, round2_rejects_zero_challenge, from_partial_signatures_rejects_duplicate_signers |
⚠️ Still Outstanding
1. Scalar::random uses biased 32-byte draw — Medium
Scalar::random generates 64 random bytes then calls from_bytes_wide, but blst_scalar_from_le_bytes internally only reads the first 32 bytes regardless of the nbytes argument (the scalar struct holds 4×u64 = 32 bytes). The 64-byte rng.fill_bytes call thus generates 64 bytes but discards the upper half. The resulting 32-byte uniform draw in [0, 2^256) has a ~9% statistical bias toward values in [0, 2^256 mod r) since floor(2^256/r) = 2 and the BLS12-381 scalar order r ≈ 0.453 × 2^256. This affects polynomial coefficients and Schnorr nonces. Rejection sampling (generate 32 bytes, call blst_scalar_fr_check, retry if ≥ r) would be unbiased. Fix this →
2. from_bytes_wide docstring is misleading — Low
// curve.rs:72
/// Reduce 64 little-endian bytes modulo the scalar field order.
pub fn from_bytes_wide(bytes: &[u8; 64]) -> Self {
blst_scalar_from_le_bytes(&mut scalar, bytes.as_ptr(), 64); // reads only [0..32]The docstring promises 64-byte wide reduction but blst only materialises 32 bytes. Should say: "Deserialize from 64 LE bytes; only the first 32 bytes are used due to blst internals." Fix this →
3. Glob re-exports — Low
lib.rs:12-13 still has pub use curve::*; pub use frost_core::*;. Every pub item (including SecretShare, CoefficientCommitment, VerifiableSecretSharingCommitment) is part of the crate's public API. Enumerating explicit re-exports would give cleaner semver stability.
4. Missing non-consecutive signer subset test — Low
Both existing BLS round-trip tests use signers {1,2} or {1,2,3}. A test with {1,3} from a {1,2,3} DKG would confirm that Lagrange interpolation works correctly when participants are non-contiguous. Fix this →
Summary
This PR is in good shape. All high and medium issues from both previous review rounds have been resolved. The two items worth addressing before merge are the Scalar::random bias (#1 above) and the misleading docstring (#2); both are small, self-contained fixes. Items #3 and #4 are quality-of-life and can follow in #364.
|
Seem good, let's merge |
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.