Skip to content

fix(crypto/frost): BLS/FROST primitive parity and edge-case correctness#516

Open
varex83agent wants to merge 3 commits into
mainfrom
bohdan/crypto-bls-frost-parity
Open

fix(crypto/frost): BLS/FROST primitive parity and edge-case correctness#516
varex83agent wants to merge 3 commits into
mainfrom
bohdan/crypto-bls-frost-parity

Conversation

@varex83agent

Copy link
Copy Markdown
Collaborator

Summary

Brings the BLS and FROST primitives into functional parity with Charon
(Go, tag v1.7.1) and the ObolNetwork kryptology fork on a set of
edge/soundness cases. Implements the five tasks from the
03-crypto-bls-frost-parity-and-correctness plan. All findings were
re-verified against the Go source before implementing.

Changes

T01 — k1util::recover recovery-id domain (crates/k1util)
Charon's Recover only accepts recovery bytes {0, 1, 27, 28}; the Rust
port deferred to RecoveryId::from_byte, which also accepts the x-reduced
ids 2 and 3 (recovering a different key). Now rejects everything
outside the Charon domain up front, and the error carries the original
byte. verify_65 inherits the fix.

T02 — FROST round2 bcast bounds + self-broadcast (crates/frost)
Matches ObolNetwork/kryptology@v0.1.0 dkg_round2.go: the bcast upper
bound is max_signers (so a Charon-shaped broadcast map that includes the
node's own Round1Bcast is accepted), the shares upper bound stays
max_signers-1, and a self entry in the bcast map is skipped rather than
rejected with InvalidParticipantId.

T03 — FROST round2 zero-share guard (crates/frost)
Adds an explicit zero-valued Shamir share guard mirroring kryptology's
ShamirShare.Validate (id == 0 / zero scalar) before the Feldman VSS
equation.

T04 — Go Herumi empty/single aggregate parity (crates/crypto)
aggregate(&[]) now returns the serialized G2 identity signature (never an
error), and a single input is deserialized + re-serialized (canonicalized)
instead of returned verbatim, matching tbls/herumi.go.

T05 — threshold_split contract, error mapping, misspelling (crates/crypto)
Maps the usize::try_from failure to a new dedicated Error::ThresholdOverflow,
documents the retained threshold > total hardening (a divergence from
Charon's threshold <= 1-only check), fixes the misspelled threashlod
local, and unifies BlstSecretKey::from_bytes error mapping to
Error::InvalidSecretKey across secret_to_public_key,
threshold_split_insecure, and sign.

Testing

  • cargo test --workspace --all-features — all pass
  • cargo clippy --workspace --all-targets --all-features -- -D warnings — clean
  • cargo +nightly fmt --all --check — clean

New/updated tests: recovery-id domain table test; frost round2
self-broadcast acceptance, bcast/shares bound rejection, and zero-share /
zero-id rejection tests; empty-aggregate identity + Go-fixture parity,
single-input canonicalization, malformed-single-input rejection; threshold
variant assertions, threshold == total validity, and consistent
invalid-secret-key error across all four entry points.

🤖 Generated with Claude Code

varex83agent and others added 3 commits July 2, 2026 15:12
Charon's Recover rejects any recovery byte outside {0, 1, 27, 28} before
attempting recovery (app/k1util/k1util.go @ v1.7.1). The Rust port only
normalized 27/28 -> 0/1 and then deferred to RecoveryId::from_byte, which
accepts the x-reduced ids 2 and 3 (recovering a different key). Reject
everything outside the Charon domain up front; the error now carries the
original (pre-normalization) byte, matching Charon's z.Any("id", ...).
verify_65 inherits the fix.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
…h kryptology

Bring round2 to functional parity with ObolNetwork/kryptology@v0.1.0
(pkg/dkg/frost/dkg_round2.go), the fork Charon v1.7.1 uses:

- bcast upper bound is now max_signers (feldman.Limit), not max_signers-1,
  so a Charon-shaped broadcast map that includes this node's own Round1Bcast
  is accepted; the p2psend/shares upper bound stays max_signers-1.
- A self entry (sender_id == secret.id) in the broadcast map is skipped
  (`continue`) rather than rejected with InvalidParticipantId, matching Go's
  `if id == dp.Id { continue }`. Self is thereby excluded from share lookup
  and share_sum; own_share_scalar and the own commitment are still added once.
- Add an explicit zero-valued Shamir share guard mirroring kryptology's
  ShamirShare.Validate (pkg/sharing/shamir.go:27-39), which rejects id == 0
  and a zero scalar before the Feldman VSS equation.

Replaces round2_rejects_self_broadcast with round2_skips_self_broadcast and
adds bound/zero-share regression tests.

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
…plit errors

Align BLS primitives in blst_impl with Charon's Herumi backend (tbls/herumi.go
@ v1.7.1):

- aggregate: an empty input is no longer an error; return the serialized G2
  point at infinity (identity signature), matching Herumi's Aggregate. A
  single input is deserialized + re-serialized (canonicalized) instead of
  returned verbatim, so a malformed single input is now rejected.
- threshold_split_insecure: map the usize::try_from(threshold) failure to a
  new dedicated Error::ThresholdOverflow instead of the misleading
  InvalidThreshold; document the (retained) threshold > total hardening that
  diverges from Charon's threshold <= 1-only check; rename the misspelled
  `threashlod` local.
- Unify BlstSecretKey::from_bytes error mapping across secret_to_public_key,
  threshold_split_insecure, and sign to Error::InvalidSecretKey, matching
  recover_secret (previously three surfaced the generic BlsError).

Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com>
@varex83 varex83 requested review from emlautarom1 and iamquang95 July 2, 2026 13:23
invalid_recovery_byte: original_recovery_byte,
})?;

let pubkey = ecdsa::VerifyingKey::recover_from_prehash(hash, &signature, recovery_id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recover() still rejects high-S signatures that charon accepts.

Go side — charon Recover (app/k1util/k1util.go:120) delegates to decred's ecdsa.RecoverCompact (dcrd/dcrec/secp256k1/v4@v4.4.0 ecdsa/signature.go:796). Its only S validation is signature.go:898-905: reject S >= group order and S == 0. No low-S rule — a high-S signature recovers fine.

Rust side — this call, ecdsa::VerifyingKey::recover_from_prehash, ends with a self-verification ([ecdsa-0.16.9 recovery.rs:313 (https://github.com/RustCrypto/signatures/blob/ecdsa/v0.16.9/ecdsa/src/recovery.rs#L313)) that hits k256's verify_prehashed, which rejects high-S outright (k256-0.13.4 src/ecdsa.rs:203-205: if sig.s().is_high() { return Err })

@emlautarom1 emlautarom1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nits only

Comment on lines +344 to +347
struct Case {
v: u8,
accepted: bool,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Too noisy in code, not explicit enough in actual test runs. Prefer to use test_case to parametrize the test.

Comment on lines +416 to +420
// Bounds mirror ObolNetwork/kryptology@v0.1.0 dkg_round2.go.
// feldman.Limit == max_signers.
// bcast: threshold-1 <= len <= max_signers (may include this node's
// own bcast) p2psend: threshold-1 <= len <= max_signers - 1 (never
// includes self)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatting here is broken

Comment on lines 193 to 196
let recovery_id =
RecoveryId::from_byte(recovery_byte).ok_or(K1UtilError::InvalidSignatureRecoveryId {
invalid_recovery_byte: recovery_byte,
invalid_recovery_byte: original_recovery_byte,
})?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This infallible, so use .expect instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants