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 toround1_package_size(byzantium_threshold(N))— exactly
threshold(N)coefficients, whereN= total authorities.register_round1_package(lib.rs:992-999, on-chain execution) demands
coefficients.len() == R - 1, whereR= authorities that registered Round-0.- The offchain worker (
dkg_run_round0→dkg_part1,lib.rs:3746-3786) always builds
packages withmin_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):
git clone https://git.ghostchain.io/ghostchain/ghost-node && git rev-parse HEAD→
0162da4604d9931c16d12a0cec40dd85951368c5- 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). - All 7 authorities register Round-0 → phase advances (7 ≥ 5).
- Execution gate requires
5 == 7 - 1 = 6→ false →Err(Round1PackageInvalidLength)for
every package, honest or otherwise. prepare_round2_storageintersects qualification with the empty Round-1 set →
restart_dkg()→ newdkg_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 derivemin_signersfrom 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.