Skip to content

Commit d09391e

Browse files
UltraDAGcomClaude Opus 4.6 (1M context)
andcommitted
fix(consensus): fail-closed quorum in permissionless mode (GHSA-rprp-wjrh-hx7g)
The prior adaptive-quorum patch (181b2e8) only neutralized registration-only phantoms. Producer-backed phantoms — fresh keys each signing one vertex — were still counted by active_validator_count() and, in unconfigured mode, the adaptive upper_bound still derived from validators.len(). An attacker with 3 fresh keys against 4 honest validators raised the threshold to ceil(2*7/3)=5, stalling finality forever in honest-only rounds. Root cause: any "count signed producers" scheme is sybil-gameable without stake-weighted gating, because signing is free with a fresh keypair. Fix: ValidatorSet::quorum_threshold and adaptive_quorum_threshold now return usize::MAX when neither configured_validators nor allowed_validators is set. adaptive_quorum_threshold's upper_bound derives ONLY from declared topology — never from validators.len() — so producer-backed phantoms cannot raise the ceiling. Production paths were never exposed: main.rs always sets either --validators N or --validator-key <file>. This change converts a latent liveness bug in undeclared mode into a fail-stop config error. Regression test: producer_backed_phantom_cannot_stall_finality replays the reporter's 4-honest + 3-phantom PoC and asserts last_finalized_round advances past the attack round. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dd656db commit d09391e

4 files changed

Lines changed: 160 additions & 63 deletions

File tree

crates/ultradag-coin/src/consensus/finality.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,16 @@ mod tests {
380380
let mut ft = FinalityTracker::new(3);
381381
assert_eq!(ft.finality_threshold(), usize::MAX);
382382

383+
// Permissionless mode stays fail-closed even after registrations
384+
// (GHSA-rprp-wjrh-hx7g).
383385
ft.register_validator(SecretKey::generate().address());
384386
ft.register_validator(SecretKey::generate().address());
385387
assert_eq!(ft.finality_threshold(), usize::MAX);
386388

387-
ft.register_validator(SecretKey::generate().address());
389+
ft.set_configured_validators(3);
388390
assert_eq!(ft.finality_threshold(), 2);
389391

390-
ft.register_validator(SecretKey::generate().address());
392+
ft.set_configured_validators(4);
391393
assert_eq!(ft.finality_threshold(), 3);
392394
}
393395

@@ -401,6 +403,7 @@ mod tests {
401403
fn finality_with_three_validators() {
402404
let mut dag = BlockDag::new();
403405
let mut ft = FinalityTracker::new(2);
406+
ft.set_configured_validators(3);
404407

405408
let sk1 = SecretKey::generate();
406409
let sk2 = SecretKey::generate();
@@ -432,6 +435,7 @@ mod tests {
432435
fn transitive_descendant_counts() {
433436
let mut dag = BlockDag::new();
434437
let mut ft = FinalityTracker::new(2);
438+
ft.set_configured_validators(3);
435439

436440
let sk1 = SecretKey::generate();
437441
let sk2 = SecretKey::generate();
@@ -460,6 +464,7 @@ mod tests {
460464
fn find_newly_finalized_batch() {
461465
let mut dag = BlockDag::new();
462466
let mut ft = FinalityTracker::new(2);
467+
ft.set_configured_validators(3);
463468

464469
let sk1 = SecretKey::generate();
465470
let sk2 = SecretKey::generate();

crates/ultradag-coin/src/consensus/validator_set.rs

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -101,38 +101,47 @@ impl ValidatorSet {
101101
self.validators.is_empty()
102102
}
103103

104+
/// Returns true when the validator topology has been declared by the
105+
/// operator — either a fixed configured count or an explicit allowlist.
106+
///
107+
/// SECURITY: Fully permissionless mode (neither set) is unsafe. In that
108+
/// mode an attacker can mint fresh keys, sign a single vertex per key,
109+
/// and inflate both `validators.len()` and the "active producer" count,
110+
/// manipulating the quorum threshold (GHSA-rprp-wjrh-hx7g). Without a
111+
/// declared topology there is no sybil-resistant way to count validators,
112+
/// so the thresholds fail closed (return `usize::MAX`).
113+
pub fn is_topology_configured(&self) -> bool {
114+
self.configured_validators.is_some() || self.allowed_validators.is_some()
115+
}
116+
104117
/// BFT quorum threshold: ceil(2n/3).
105-
///
106-
/// SECURITY: When `configured_validators` is set, uses that as `n` to prevent
107-
/// phantom registrations from inflating the threshold (VULN-02 fix).
108-
///
109-
/// Returns usize::MAX if fewer than min_validators are known.
110-
/// When `configured_validators` is set, uses that count for the min check
111-
/// (the operator has declared the expected validator count).
112-
///
113-
/// # Panics
114-
/// This function should not panic under normal operation. If arithmetic overflow
115-
/// occurs, it indicates a critical bug and the node should halt.
118+
///
119+
/// SECURITY: Fails closed (`usize::MAX`) when the validator topology is
120+
/// not declared via `configured_validators` or `allowed_validators`.
121+
/// Dynamic mode was exploitable: producer-backed phantom validators
122+
/// inflated the threshold and stalled finality (GHSA-rprp-wjrh-hx7g).
123+
/// Operators MUST set `--validators N` or `--validator-key <file>`.
124+
///
125+
/// When `configured_validators` is set, uses that as `n`. Otherwise,
126+
/// when only an allowlist is set, uses the allowlist size as `n`
127+
/// (allowlisted addresses cannot be forged).
128+
///
129+
/// Returns `usize::MAX` if fewer than `min_validators` are known.
116130
pub fn quorum_threshold(&self) -> usize {
117-
let effective_count = match self.configured_validators {
118-
Some(configured) => {
119-
// Use configured count to prevent attacker from inflating threshold
120-
// by registering fake validators (VULN-02 fix)
121-
configured
122-
}
123-
None => {
124-
// Dynamic count mode: vulnerable to phantom validator inflation
125-
// Operators should always use configured_validators in production
126-
self.validators.len()
131+
let effective_count = match (self.configured_validators, &self.allowed_validators) {
132+
(Some(configured), _) => configured,
133+
(None, Some(allowed)) => allowed.len(),
134+
(None, None) => {
135+
// Fail closed: permissionless mode cannot distinguish real
136+
// validators from phantoms. See `is_topology_configured`.
137+
return usize::MAX;
127138
}
128139
};
129-
130-
// Enforce minimum validator requirement
140+
131141
if effective_count < self.min_validators {
132142
return usize::MAX;
133143
}
134-
135-
// ceil(2n/3) calculation with overflow protection
144+
136145
(2 * effective_count).div_ceil(3)
137146
}
138147

@@ -171,7 +180,22 @@ impl ValidatorSet {
171180
/// (those who have signed vertices) count toward the active set. Phantom
172181
/// registrations cannot reduce the quorum.
173182
pub fn adaptive_quorum_threshold(&self, current_round: u64) -> usize {
174-
let upper_bound = self.configured_validators.unwrap_or_else(|| self.validators.len());
183+
// SECURITY: Fail closed in permissionless mode. Without a declared
184+
// topology, an attacker can mint keys and produce signed vertices
185+
// to inflate `active_validator_count`, manipulating the threshold
186+
// (GHSA-rprp-wjrh-hx7g).
187+
if !self.is_topology_configured() {
188+
return usize::MAX;
189+
}
190+
191+
// Upper bound derives ONLY from operator-declared topology, never
192+
// from the on-the-fly `validators.len()`. Producer-backed phantoms
193+
// cannot raise this ceiling.
194+
let upper_bound = match (self.configured_validators, &self.allowed_validators) {
195+
(Some(configured), _) => configured,
196+
(None, Some(allowed)) => allowed.len(),
197+
(None, None) => unreachable!("guarded by is_topology_configured above"),
198+
};
175199
let active = self.active_validator_count(current_round);
176200

177201
// Compute the static threshold as the safety baseline.
@@ -237,6 +261,10 @@ mod tests {
237261
for _ in 0..4 {
238262
vs.register(SecretKey::generate().address());
239263
}
264+
// Permissionless mode fails closed.
265+
assert_eq!(vs.quorum_threshold(), usize::MAX);
266+
267+
vs.set_configured_validators(4);
240268
// ceil(8/3) = 3
241269
assert_eq!(vs.quorum_threshold(), 3);
242270
assert!(vs.has_quorum(3));
@@ -246,13 +274,43 @@ mod tests {
246274
#[test]
247275
fn threshold_with_3_validators() {
248276
let mut vs = ValidatorSet::new(3);
277+
vs.set_configured_validators(3);
249278
for _ in 0..3 {
250279
vs.register(SecretKey::generate().address());
251280
}
252281
// ceil(6/3) = 2
253282
assert_eq!(vs.quorum_threshold(), 2);
254283
}
255284

285+
#[test]
286+
fn permissionless_mode_fails_closed() {
287+
// SECURITY: without configured count or allowlist, thresholds must
288+
// return usize::MAX so finality cannot progress. Registering and/or
289+
// "producing" phantom validators must not unstall the network.
290+
let mut vs = ValidatorSet::new(3);
291+
for _ in 0..10 {
292+
let addr = SecretKey::generate().address();
293+
vs.register(addr);
294+
vs.record_production(addr, 42);
295+
}
296+
assert_eq!(vs.quorum_threshold(), usize::MAX);
297+
assert_eq!(vs.adaptive_quorum_threshold(42), usize::MAX);
298+
assert!(!vs.has_quorum(100));
299+
}
300+
301+
#[test]
302+
fn allowlist_alone_enables_threshold() {
303+
let mut vs = ValidatorSet::new(3);
304+
let sks: Vec<SecretKey> = (0..4).map(|_| SecretKey::generate()).collect();
305+
let allowed: HashSet<Address> = sks.iter().map(|s| s.address()).collect();
306+
vs.set_allowed_validators(allowed);
307+
for sk in &sks {
308+
vs.register(sk.address());
309+
}
310+
// Allowlist size 4 -> ceil(8/3) = 3
311+
assert_eq!(vs.quorum_threshold(), 3);
312+
}
313+
256314
#[test]
257315
fn register_is_idempotent() {
258316
let mut vs = ValidatorSet::new(1);

crates/ultradag-coin/tests/phantom_validator.rs

Lines changed: 65 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -142,64 +142,95 @@ fn phantom_validator_does_not_break_finality_with_configured_count() {
142142
}
143143

144144
#[test]
145-
fn without_configured_count_phantom_breaks_finality() {
146-
// This test shows the bug: without configured_validators,
147-
// phantom registrations inflate the threshold beyond reach.
145+
fn permissionless_mode_refuses_finality() {
146+
// SECURITY (GHSA-rprp-wjrh-hx7g): in fully permissionless mode (no
147+
// configured_validators, no allowlist), finality must fail closed.
148+
// An attacker minting fresh keys and producing signed vertices cannot
149+
// be distinguished from honest validators, so any threshold derived
150+
// from "validators we've seen" is sybil-gameable. The tracker now
151+
// returns threshold=usize::MAX in this mode — operators must set
152+
// --validators or --validator-key.
148153
let sks: Vec<SecretKey> = (0..4).map(|_| SecretKey::generate()).collect();
149154

150155
let mut dag = BlockDag::new();
151156
let mut finality = FinalityTracker::new(3);
152-
// NO set_configured_validators — dynamic mode
157+
// NO set_configured_validators, NO set_allowed_validators — permissionless.
153158

154159
for sk in &sks {
155160
finality.register_validator(sk.address());
156161
}
157162

158163
build_dag(&sks, 20, &mut dag);
159164

165+
let mut total = 0;
160166
loop {
161167
let newly = finality.find_newly_finalized(&dag);
162168
if newly.is_empty() { break; }
169+
total += newly.len();
163170
}
164-
let finalized_before = finality.finalized_count();
165-
assert!(finalized_before > 0);
166171

167-
// Register 4 phantom validators (simulating stale persistence load)
168-
for _ in 0..4 {
169-
finality.register_validator(SecretKey::generate().address());
172+
assert_eq!(finality.finality_threshold(), usize::MAX);
173+
assert_eq!(total, 0, "permissionless mode must not finalize anything");
174+
}
175+
176+
#[test]
177+
fn producer_backed_phantom_cannot_stall_finality() {
178+
// Regression test for GHSA-rprp-wjrh-hx7g (Sumitshah00, 2026-04-13).
179+
//
180+
// BEFORE FIX: with 4 honest validators + unconfigured mode, an attacker
181+
// that produces 3 signed vertices from 3 fresh keys inflated the
182+
// adaptive threshold to ceil(2*7/3)=5. Only 4 honest producers remained,
183+
// so finality stalled forever at round < attack_round.
184+
//
185+
// AFTER FIX: operators must declare topology. With set_configured_validators(4),
186+
// the upper bound is pinned at 4, phantoms cannot raise the threshold, and
187+
// honest-only rounds finalize cleanly past the attack round.
188+
let honest: Vec<SecretKey> = (0..4).map(|_| SecretKey::generate()).collect();
189+
let mut dag = BlockDag::new();
190+
let mut finality = FinalityTracker::new(3);
191+
finality.set_configured_validators(4);
192+
for sk in &honest {
193+
finality.register_validator(sk.address());
194+
}
195+
196+
build_dag(&honest, 20, &mut dag);
197+
loop {
198+
if finality.find_newly_finalized(&dag).is_empty() { break; }
199+
}
200+
let finalized_before = finality.last_finalized_round();
201+
assert!(finalized_before > 0, "baseline rounds should finalize");
202+
203+
// Attack: 3 phantom producers each sign exactly one vertex in round 20.
204+
let phantoms: Vec<SecretKey> = (0..3).map(|_| SecretKey::generate()).collect();
205+
let attack_round = 20u64;
206+
let attack_parents = dag.tips();
207+
let mut nonce = 10_000u64;
208+
for sk in &phantoms {
209+
nonce += 1;
210+
dag.insert(make_vertex(nonce, attack_round, attack_parents.clone(), sk));
211+
finality.register_validator(sk.address());
170212
}
171213

172-
// Now validator_count=8, threshold=ceil(16/3)=6
173-
assert_eq!(finality.validator_count(), 8);
174-
assert_eq!(finality.finality_threshold(), 6);
214+
// Threshold stays pinned to configured count (4) -> ceil(8/3) = 3.
215+
assert_eq!(finality.finality_threshold(), 3,
216+
"configured topology must pin threshold despite phantom producers");
175217

176-
// Continue with only 4 real validators
177-
let mut nonce = 2000u64;
178-
for round in 20..30 {
218+
// Honest-only rounds after the attack.
219+
for round in (attack_round + 1)..(attack_round + 11) {
179220
let tips = dag.tips();
180-
for sk in &sks {
221+
for sk in &honest {
181222
nonce += 1;
182-
let v = make_vertex(nonce, round, tips.clone(), sk);
183-
dag.insert(v);
223+
dag.insert(make_vertex(nonce, round, tips.clone(), sk));
184224
}
185225
}
186-
187-
// Finality CANNOT progress — only 4 validators can produce descendants,
188-
// but threshold requires 6
189-
let mut new_finalized = 0;
190226
loop {
191-
let newly = finality.find_newly_finalized(&dag);
192-
if newly.is_empty() { break; }
193-
new_finalized += newly.len();
227+
if finality.find_newly_finalized(&dag).is_empty() { break; }
194228
}
195229

196-
// UPDATE (adaptive quorum fix): the adaptive quorum threshold now mitigates
197-
// this phantom inflation attack even WITHOUT set_configured_validators. The
198-
// adaptive threshold uses the count of validators who have actually produced
199-
// vertices (4), not the inflated registration count (8). So finality progresses
200-
// normally despite the phantom registrations.
201-
//
202-
// Phantom validators cannot degrade finality because they cannot produce
203-
// cryptographically-signed vertices.
204-
assert!(new_finalized > 0, "adaptive quorum should allow finality despite phantom registrations");
230+
assert!(
231+
finality.last_finalized_round() > attack_round,
232+
"finality must progress past attack_round={} (got {})",
233+
attack_round,
234+
finality.last_finalized_round()
235+
);
205236
}

crates/ultradag-network/tests/adversarial_integration_tests.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ impl AdversarialNode {
3131
let address = sk.address();
3232
let mut state = StateEngine::new();
3333
state.set_configured_validator_count(total_validators);
34+
let mut finality = FinalityTracker::new(3);
35+
finality.set_configured_validators(total_validators as usize);
3436
Self {
3537
id,
3638
state,
3739
dag: BlockDag::new(),
38-
finality: FinalityTracker::new(3),
40+
finality,
3941
secret_key: sk,
4042
address,
4143
}
@@ -193,6 +195,7 @@ async fn test_crash_restart_state_convergence() {
193195
nodes[3].state = fresh_state;
194196
nodes[3].dag = BlockDag::new();
195197
nodes[3].finality = FinalityTracker::new(3);
198+
nodes[3].finality.set_configured_validators(4);
196199

197200
// Replay all vertices from node 0's DAG into node 3
198201
let all_addresses: Vec<Address> = nodes.iter().map(|n| n.address).collect();

0 commit comments

Comments
 (0)