[H16] DKG Round-1 rejects all honest packages unless exactly threshold(N)+1 registered Round-0 — deterministic bridge halt for N≥7

Summary

Two gates in the pallets/exodus DKG Round-1 flow disagree on how many VSS commitments a
valid Round-1 package must carry, and honest output satisfies both only on a knife’s edge:

  • validate_round1_package_size (pallets/exodus/src/lib.rs:2728-2740, tx-pool gate) pins
    the package byte-length to round1_package_size(byzantium_threshold(N)) — exactly
    threshold(N) coefficients, where N = total authorities.
  • register_round1_package (lib.rs:992-999, on-chain execution) demands
    coefficients.len() == R - 1, where R = authorities that registered Round-0.
  • The offchain worker (dkg_run_round0 → dkg_part1, lib.rs:3746-3786) always builds
    packages with min_signers = get_byzantium_quorum_threshold(N) coefficients.

So a ceremony survives Round-1 iff exactly threshold(N)+1 authorities registered Round-0.
Consequences: with N ≥ 7 and full participation (the normal case), every honest Round-1
package is rejected
— the DKG can never complete and the bridge threshold key can never be
established/rotated. With N = 4–6 the DKG works only with 100% Round-0 participation (zero
fault tolerance — one offline/censored authority kills the ceremony despite the BFT quorum
being met). With N = 3 it can never succeed at all. Failed ceremonies hit restart_dkg() and
livelock, and all threshold signing is rejected while any DKG is pending
(lib.rs:3084, 3136, 3184, 3221) — so a stuck (rotation) DKG permanently halts bridge-outs,
rotations and governance signing, not just the rotation. The pallet’s test suite uses exactly
4 authorities with full participation (mock.rs:44), the one configuration where the two
gates agree, which is why this survived.

Severity

High — CVSS 3.1: 7.5 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

No attacker needed for the N ≥ 7 case (deterministic on the happy path); for N ≤ 6 a single
authority kept out of the Round-0 window (offline, censored, or DoS’d) forces a full restart.
Availability impact is total: the bridge cannot start (initial DKG) or permanently halts on
the first authority-set rotation, because signing stays blocked across the livelock.

Affected code

// lib.rs:2728-2740 — pool gate: package length pinned to threshold(N) coefficients
let authorities = QualificationAuthorities::<T>::get(&network_curve);
let expected_count = get_byzantium_quorum_threshold(authorities.len());
let expected_package_len = round1_package_size(
    expected_count,
    network_curve.element_bytes_len(),
    network_curve.signature_bytes_len(),
    network_curve.header_bytes_len(),
);
if round1.package.len() != expected_package_len {
    return Err(InvalidTransaction::BadProof);
}
// lib.rs:992-999 — execution gate: demands R-1 coefficients
state.count_ones::<AuthIndex>().checked_sub(1)
    .map(|needed_commitment_count| {
        estimated_commitment_count
            .eq(&needed_commitment_count)
            .then(|| ())
    })
    .ok_or(Error::<T>::Round1PackageInvalidLength)?;
// lib.rs:3746-3750 — OCW always builds threshold(N)-coefficient packages
let max_signers = QualificationAuthorities::<T>::decode_len(&network_curve)...;
let min_signers = get_byzantium_quorum_threshold(max_signers);
// dkg_part1(authority_index, max_signers, min_signers, &mut rng)
// → frost dkg::part1 creates exactly min_signers coefficients
// lib.rs:3084 (representative) — signing halted while DKG pending
if QualificationDkgState::<T>::get(&network_curve).is_dkg_pending() {
    return Err(InvalidTransaction::BadProof);
}
// same guard in validate_exodus_nonce_commitment (3136),
// validate_exodus_group_commitment (3184), and the OCW driver (3221)

where get_byzantium_quorum_threshold(n) = n - (n-1)/3 (pallets/helpers/src/lib.rs:37-46).

Steps to reproduce

Code-path analysis only (no toolchain needed — the condition is deterministic arithmetic):

  1. git clone https://git.ghostchain.io/ghostchain/ghost-node && git rev-parse HEAD →
    0162da4604d9931c16d12a0cec40dd85951368c5
  2. Take N = 7: threshold(7) = 7 - 6/3 = 5. Every honest Round-1 package carries 5
    coefficients (and the pool gate’s byte-length check enforces exactly that).
  3. All 7 authorities register Round-0 → phase advances (7 ≥ 5).
  4. Execution gate requires 5 == 7 - 1 = 6 → false → Err(Round1PackageInvalidLength) for
    every package, honest or otherwise.
  5. prepare_round2_storage intersects qualification with the empty Round-1 set →
    restart_dkg() → new dkg_index, back to Vacant. The next ceremony fails identically.

Participation table (R = Round-0 registrants; need R - 1 == threshold(N), threshold(N) ≤ R ≤ N):

N threshold(N) R required outcome
3 3 4 impossible — DKG never succeeds
4–6 N-1 N only with 100% Round-0 turnout
7 5 6 only with exactly 6/7; full turnout always fails
8 6 7 only with exactly 7/8; full turnout always fails
10 7 8 only with exactly 8/10; full turnout always fails

Variant: N = 4, one authority offline during the Round-0 window → R = 3 (meets the 3/4 BFT
quorum, phase advances) → execution requires 3 == 2 → false → total collapse and restart,
even though the BFT threshold was satisfied.

Impact

  • Initial DKG: with N ≥ 7 the bridge threshold key can never be created (chain starts,
    bridge never activates); with N ≤ 6 any single Round-0 absence kills the ceremony.
  • Rotation DKG (validator-set change): the failed ceremony livelocks with
    is_dkg_pending() == true, and every signing gate rejects while pending — so a routine
    rotation becomes a permanent, total bridge halt (bridge-outs, rotations, governance).
  • Griefing: for N ≤ 6, censoring/DoS-ing one authority’s Round-0 transaction during the
    round window is enough to force a restart; each failed ceremony burns a full DKG
    round-period with signing halted throughout.
  • Fail-closed (no fund theft), but complete and potentially permanent loss of bridge
    availability — the exact property the BFT threshold was designed to protect.

Remediation

Pick one threshold and enforce it in a single place:

  • If the intent is the BFT threshold over the full set: change the execution check to
    estimated == get_byzantium_quorum_threshold(QualificationAuthorities::decode_len()),
    matching the pool gate and the OCW.
  • If the intent is adaptive R - 1: make the OCW derive min_signers from the observed
    Round-0 count and relax the pool byte-length check accordingly (it currently pins
    threshold(N)).
  • Add DKG tests for N ∈ {3, 7, 10} and for partial Round-0 participation — the current suite
    only covers N = 4 full-participation, exactly the blind spot.
  • Consider bounding total DKG-pending time (fallback) so a failed ceremony cannot halt
    threshold signing indefinitely.

Thank you for your report! This is actually my favorite one so far, because it works on multiple layers.

At first glance, it makes perfect sense - two different checks, two different length requirements. But after spending a couple of hours investigating why nothing fails when I bump the participant count up to 20 during local tests, the realization was shocking.

It’s a bug inside a bug. If you look carefully at the check, the error path is only triggered when checked_sub itself fails - that is, only when count_ones == 0. The actual coefficient-count comparison is never enforced, because the expression has the wrong shape:

state.count_ones::<AuthIndex>().checked_sub(1)
    .map(|needed_commitment_count| {
        estimated_commitment_count
            .eq(&needed_commitment_count)
            .then(|| ())
    })
    .ok_or(Error::<T>::Round1PackageInvalidLength)?;

Code could be found here

.map(...) returns Option<Option<()>>, and .ok_or(...)? only inspects the outer Option. The inner Option<()> produced by .then(|| ()) - the result of the coefficient-count comparison - is silently discarded. So the gate is effectively dead code. It never rejects a package for having the wrong coefficient count. It only fires when count_ones == 0.

On the one hand, this is definitely a real defect - the intended invariant (estimated_commitment_count == count_ones - 1) is not enforced. On the other hand, because of the way it’s written, the bug has no runtime impact today: honest and dishonest packages alike pass through. And that is the reason why it is not affecting the tests.

Any thoughts on this? Maybe I’m wrong here?


You’re not wrong — you’re right, and I verified it independently. Nice catch.

I compiled the exact expression shape in isolation:

count_ones.checked_sub(1)
    .map(|needed| estimated.eq(&needed).then(|| ()))
    .ok_or(Error::Round1PackageInvalidLength)?;

checked_sub yields Option<u32>, .map() lifts it to Option<Option<()>>, and .ok_or()? only inspects the outer Option — the ? unwraps to an Option<()> that is discarded as a statement. The error fires if and only if count_ones == 0; the coefficient-count comparison is silently dropped in every other case. My test confirms: mismatch → Ok(()), match → Ok(()), zero registrants → Err.

So the headline claim in my report is wrong: honest Round-1 packages are never rejected by this gate, and there is no deterministic DKG halt. I retract the High severity on H16.


But re-auditing from the corrected understanding turned up something real — a different bug behind the same dead check. The gate’s intent was to enforce a coefficient count, and because it enforces nothing, this passes:

Trailing-byte smuggling → persistent DKG liveness failure (Medium)

  1. FROST’s Package::deserialize calls postcard::from_bytes directly (frost-core-2.2.0/src/serialization.rs:244), which does not require the input to be fully consumed. I verified empirically: 8 clean bytes vs 108 padded bytes both deserialize to the same 3 coefficients.
  2. A malicious registered validator M runs dkg_part1 locally with min_signers = K < threshold(N) — producing a valid package with a valid Schnorr proof of knowledge over K commitments — then appends trailing padding so the total length equals round1_package_size(threshold(N)).
  3. Gate-by-gate: pool byte-length (lib.rs:2734) passes by construction; dispatchable byte-length (lib.rs:968) passes; M’s own signature over their own bytes passes; round-0 hash binding passes (M registered blake2_256(padded_bytes) themselves); dkg_verify_proof_of_knowledge passes (deserializes fine, PoK valid over K coefficients); the dead check passes trivially.
  4. In dkg_run_round2, every honest validator calls dkg_part2 with min_signers = threshold(N) in their secret. FROST’s part2 enforces package.commitment.0.len() == secret.min_signers (frost-core-2.2.0/src/keys/dkg.rs:509) → Err(IncorrectNumberOfCommitments) on every honest validator. The ? at lib.rs:3981 aborts the OCW round — nobody submits round-2. Round2Packages stays empty → restart_dkg(), and the next ceremony is attacked identically: one unsigned transaction per ceremony for a persistent halt.
  5. While any DKG is pending, is_dkg_pending() gates reject all threshold signing (lib.rs:3084, 3136, 3184, 3221) — bridge-outs, rotations, and governance signing all stop. No round-1 complaint path exists (register_round3_complaints is round-3-only), and FROST fails closed (error, not a corrupt key), so this is liveness-only.

Why worse than simple non-participation: an offline validator is excluded and the DKG proceeds without them. Here M stays qualified while poisoning the computation for everyone — converting a tolerated fault into a systemic liveness failure.

Severity: Medium. Requires a malicious/compromised registered validator (insider, not permissionless); liveness-only; the anomaly is visible on-chain via the event’s coefficients_count; governance removal of the validator is a (slow) recovery path.

Fix (do NOT just “activate” the dead check — its R - 1 invariant is wrong for honest packages and would introduce the halt I originally, incorrectly, claimed): reject trailing bytes at parse time (e.g. enforce no remainder after postcard deserialization, or assert package.len() == round1_package_size(parsed_count) after dkg_verify_proof_of_knowledge); and/or make dkg_run_round2 skip rather than abort on IncorrectNumberOfCommitments, so one bad package degrades to exclusion instead of global failure.

Happy to be corrected further if I’m still missing something.


Verification: standalone Rust tests of the combinator shape and of postcard trailing-byte behavior; source review of lib.rs @ 0162da4 (round-1 gates, dkg_run_round2, restart path) and frost-core-2.2.0 (serialization.rs, keys/dkg.rs).

I believe this dead check could be removed. First of all, because Round #0 was added later (the rounds weren’t created in sequence - a fun fact), and during register_round1_package this legacy check remained. Round #1 was originally the first one, and during it the active participants were activated. After Round #0 was introduced, a bit of a mess came into play.

In any case, sizes are already checked in validate_round1_package_size, so it should be fine to run the length check only once and then run the Proof of Knowledge on the package.

Could you describe your latest attack vector in more detail? I understand that a malicious validator might be able to abuse postcard, but how could they bypass the length check, the hash equality check, and the proof of knowledge all at the same time?

Thanks for the question. I have a concrete reproducer for the trailing-byte case. The key point is that the attacker does not need to bypass the three checks independently. The checks operate on different representations of the same submitted package.

For example, with N = 7:

  • The normal Round-0 threshold is 5.
  • An honest Round-1 package serializes to 236 bytes and contains 5 coefficients.
  • A malicious package can contain only 2 coefficients and serialize to 137 bytes.
  • The attacker then appends arbitrary trailing bytes until the submitted package is exactly 236 bytes.

The checks behave as follows:

  1. Length check

validate_round1_package_size checks the length of the submitted byte slice. Since the padded package is 236 bytes, it matches the expected size and passes the check.

  1. Hash equality check

The attacker registers blake2_256(padded_bytes) during Round 0. In Round 1, lib.rs:980 hashes the raw submitted bytes again. Since the submitted bytes are the same padded byte sequence, the registered hash and the Round-1 hash match.

  1. Proof of Knowledge

frost-core deserializes the padded byte slice into a round1::Package. In the reproducer, the decoded package still contains only 2 coefficients because the trailing bytes are ignored by postcard::from_bytes. The PoK then verifies against that decoded commitment. This behavior is documented for Postcard 1.1.3. (from_bytes in postcard - Rust)

  1. Round-2 failure

When the decoded package reaches FROST dkg::part2, it reports min_signers = 2, while the local secret package expects 5. frost-core compares those values and returns IncorrectNumberOfCommitments. (dkg.rs - source)

The reproducer reaches this exact sequence:

236B padded → length check PASS → deserialize to 2 coefficients → PoK PASS → hash equality PASS → part2 → IncorrectNumberOfCommitments.

So the issue is not that the attacker breaks the length check, Blake2, and the PoK at the same time. The issue is that the checks authenticate different representations:

  • the length and hash checks use the raw byte representation;
  • the later validation derives the commitment count from the deserialized representation.

As a counterfactual, if the hash were computed over the canonical serialization after deserialization, the padded 236-byte representation would not have the same hash as the canonical 137-byte package. The reproducer demonstrates this distinction as well.

That is the trailing-byte attack vector I was referring to.

Brilliant finding!

I checked on my own and you are absolutely correct - postcard v1.1.3 is there as a silent dependency of frost-core. And you’re 100% right that we don’t need to drop the result of dkg_verify_proof_of_knowledge; we need to check it once more against the BFT threshold over all possible validators - the same set that was used to generate the package.

But since postcard will behave the same way for all the other DKG rounds and the FROST signing process, are they vulnerable to the same type of attack? For example, an attacker could bypass the length check for Round #2 - but that doesn’t make sense for them, because the justification process would then be triggered against them. Still, maybe you see the same vector for other rounds or for the signing process?

Thanks. I dug deeper into the other DKG rounds and the signing path.

I agree with your intuition for the standard Round-2 case. If the package successfully deserializes but is otherwise invalid, the complaint/justification path can identify the sender and the attacker can be removed. I also don’t think the Round-1 trailing-byte construction generalizes cleanly to the other rounds.

I did find a different Round-2 vector that avoids the justification path entirely.

The difference is a SCALE decode failure at the individual recipient-slot level.

A malicious registered validator can construct its Round-2 bundle with a valid outer structure, including the recipient bitmap and the expected total blob length, while placing malformed SCALE bytes in the recipient slots. The on-chain validation checks the outer blob shape and the authority signature, but does not decode each EncryptionData slot at that point.

Later, gather_incoming_offchain_packages decodes the individual slots using filter_map(... decode(...).ok()). If decoding a slot fails, that entry is silently dropped instead of returning an error. Since the complaint/decryption path only iterates over the successfully decoded map, the attacker is no longer present in that validator’s input and no complaint is generated against them.

I updated the reproducer so that all recipient slots are malformed, rather than only one victim slot. It now shows the same result for every honest validator: the attacker’s entry is absent from the gathered map, so no complaint can be filed.

The next issue is the Round-5 invariant. The Round-1 map still contains the attacker, while the decrypted Round-2 map does not. frost-core 2.2.0 then returns IncorrectNumberOfPackages from dkg::part3 when the two maps have different cardinalities. The reproducer gets exactly that result for all three honest validators, while the complete Round-2 map passes as a positive control.

So these are two different cases:

decryptable-but-invalid package
→ complaint
→ justification
→ attacker can be removed

versus:

SCALE-invalid slot
→ silently dropped during gathering
→ attacker never reaches the complaint path
→ no justification
→ attacker remains in the Round-1 state
→ Round 5 returns IncorrectNumberOfPackages

That is why I don’t think the justification safety net applies to this construction.

One caveat: I have runtime-proven the decode failure, the silent drop, the attacker’s absence from the complaint input, and the part3 -> IncorrectNumberOfPackages chain. I have not yet executed the actual pallet extrinsic in a mock runtime or run the complete off-chain-worker flow through restart_dkg(). Those parts are source-verified only, so I don’t want to overstate the current evidence.

My current conclusion is that the original trailing-byte issue is Round-1-specific, but Round 2 has a separate malformed-slot liveness vector that bypasses the justification mechanism for a different reason.

One more brilliant catch here!

I looked over the code just now. The function that actually collects the complaints is ecdh_decrypt_round2_packages, but it blindly iterates over whatever is inside round2_packages - the output of gather_incoming_offchain_packages - while silently omitting errors during the loop.

From what I understand, it was done that way for another part of the code, which is here.

In my opinion, the initial idea for a fix would be to swap what it iterates over during ecdh_decrypt_round2_packages - in other words, iterate over round1_packages and then extract the round2_package by index. But that’s just off the top of my head.

Please continue your research! This is definitely one of the best threads we have right now. Moreover, for me personally this is an elevation in any case, because of the initial statement that a malformed validator is trying to break the protocol.