Skip to content

Commit f562789

Browse files
UltraDAGcomClaude Opus 4.6 (1M context)
andcommitted
fix(name-registry): require owner signature on sponsored registrations (GHSA-hf8w-rcvm-rgqr)
RegisterNameTx::verify_signature short-circuited with `return true` whenever fee_payer was present, skipping the owner's ed25519 signature check entirely. Because `from` is a free-form Address field bound only by that signature, any attacker with a funded address could forge `from = victim_address`, leave the owner signature as zeros, attach their own valid fee_payer, and permanently register an arbitrary name to the victim. The registry enforces one-name-per-address, so this squats the victim's identity slot forever. Also enables relay-treasury drain if a public sponsor ever runs. Fix: verify owner's signature unconditionally. pub_key must derive to `from`, ed25519 signature over signable_bytes must verify — regardless of fee_payer. Fee_payer signature is still verified in apply_register_name_tx, where it authorizes the fee debit (not the name assignment). Matches the standard meta-tx pattern: user signs intent, sponsor signs envelope. Regression tests in crates/ultradag-coin/tests/name_registry_sponsored_auth.rs: - sponsored_registration_rejects_forged_from_without_owner_signature (exact reporter PoC) - sponsored_registration_rejects_mismatched_pubkey - sponsored_registration_accepts_owner_signed_tx - non_sponsored_registration_still_requires_owner_signature Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent da9dda5 commit f562789

2 files changed

Lines changed: 172 additions & 6 deletions

File tree

crates/ultradag-coin/src/tx/name_registry.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,15 @@ impl RegisterNameTx {
228228
pub fn total_cost(&self) -> u64 { self.fee }
229229

230230
pub fn verify_signature(&self) -> bool {
231-
// Sponsored registration: fee_payer's signature is the authorization.
232-
// The sender (name owner) doesn't need to sign — they authorized via the relay.
233-
// Fee payer signature is verified in StateEngine::apply_register_name_tx.
234-
if self.fee_payer.is_some() {
235-
return true;
236-
}
231+
// The name owner (`from`) must always sign — even on sponsored
232+
// registrations. Without this, anyone with a funded fee_payer could
233+
// register arbitrary names to arbitrary victim addresses, since
234+
// `from` is a free-form field bound only by this signature
235+
// (GHSA-hf8w-rcvm-rgqr).
236+
//
237+
// The fee_payer's signature (when present) is additionally verified
238+
// in StateEngine::apply_register_name_tx — it authorizes the fee
239+
// debit, not the name assignment.
237240
let expected_addr = Address::from_pubkey(&self.pub_key);
238241
if expected_addr != self.from { return false; }
239242
let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(&self.pub_key) else { return false; };
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! Regression tests for GHSA-hf8w-rcvm-rgqr.
2+
//!
3+
//! RegisterNameTx used to short-circuit `verify_signature()` when a
4+
//! fee_payer was present, making the owner's signature optional. That
5+
//! let any funded attacker register any name to any victim address by
6+
//! forging the `from` field (which is just a free-form byte string
7+
//! without its signature). The fix requires the owner's signature
8+
//! unconditionally; fee_payer only authorizes the fee debit.
9+
10+
use ultradag_coin::address::{SecretKey, Signature};
11+
use ultradag_coin::constants::COIN;
12+
use ultradag_coin::state::StateEngine;
13+
use ultradag_coin::tx::name_registry::RegisterNameTx;
14+
use ultradag_coin::tx::smart_account::FeePayer;
15+
16+
fn engine_with_funded(addrs: &[(&SecretKey, u64)]) -> StateEngine {
17+
let mut engine = StateEngine::new_with_genesis();
18+
for (sk, bal) in addrs {
19+
engine.faucet_credit(&sk.address(), *bal).unwrap();
20+
}
21+
engine
22+
}
23+
24+
fn owner_signed_sponsored_tx(
25+
owner: &SecretKey,
26+
sponsor: &SecretKey,
27+
name: &str,
28+
fee: u64,
29+
owner_nonce: u64,
30+
sponsor_nonce: u64,
31+
) -> RegisterNameTx {
32+
let mut tx = RegisterNameTx {
33+
from: owner.address(),
34+
name: name.to_string(),
35+
duration_years: 1,
36+
fee,
37+
nonce: owner_nonce,
38+
pub_key: owner.verifying_key().to_bytes(),
39+
signature: Signature([0u8; 64]),
40+
fee_payer: None,
41+
};
42+
let signable = tx.signable_bytes();
43+
tx.signature = owner.sign(&signable);
44+
// Fee_payer signs the same payload; engine verifies it on apply.
45+
tx.fee_payer = Some(FeePayer {
46+
address: sponsor.address(),
47+
pub_key: sponsor.verifying_key().to_bytes(),
48+
signature: sponsor.sign(&signable),
49+
nonce: sponsor_nonce,
50+
});
51+
tx
52+
}
53+
54+
#[test]
55+
fn sponsored_registration_rejects_forged_from_without_owner_signature() {
56+
// Exact reporter PoC: attacker with balance, victim with none. Attacker
57+
// crafts a tx claiming `from = victim` with a zero owner signature,
58+
// then attaches a valid fee_payer (themselves). The old code accepted
59+
// this because `verify_signature()` returned true whenever fee_payer
60+
// was present. The fix must reject it at the signature layer.
61+
let victim = SecretKey::generate();
62+
let attacker = SecretKey::generate();
63+
let engine = engine_with_funded(&[(&attacker, 10_000 * COIN)]);
64+
assert_eq!(engine.balance(&victim.address()), 0);
65+
66+
let mut tx = RegisterNameTx {
67+
from: victim.address(),
68+
name: "hijack".to_string(),
69+
duration_years: 1,
70+
fee: 0,
71+
nonce: 0,
72+
pub_key: victim.verifying_key().to_bytes(),
73+
signature: Signature([0u8; 64]),
74+
fee_payer: None,
75+
};
76+
let signable = tx.signable_bytes();
77+
tx.fee_payer = Some(FeePayer {
78+
address: attacker.address(),
79+
pub_key: attacker.verifying_key().to_bytes(),
80+
signature: attacker.sign(&signable),
81+
nonce: 0,
82+
});
83+
84+
assert!(
85+
!tx.verify_signature(),
86+
"GHSA-hf8w-rcvm-rgqr: forged-from sponsored tx must fail signature verification"
87+
);
88+
}
89+
90+
#[test]
91+
fn sponsored_registration_rejects_mismatched_pubkey() {
92+
// Variant: attacker sets from=victim but supplies their OWN pub_key,
93+
// signs with their own key (so ed25519 verification against the pub_key
94+
// succeeds). The address derived from pub_key must match `from`, else
95+
// the check rejects the tx.
96+
let victim = SecretKey::generate();
97+
let attacker = SecretKey::generate();
98+
let signable_key = SecretKey::generate();
99+
100+
let mut tx = RegisterNameTx {
101+
from: victim.address(),
102+
name: "poc".to_string(),
103+
duration_years: 1,
104+
fee: 1000 * COIN,
105+
nonce: 0,
106+
pub_key: signable_key.verifying_key().to_bytes(), // not victim's key
107+
signature: Signature([0u8; 64]),
108+
fee_payer: None,
109+
};
110+
let signable = tx.signable_bytes();
111+
tx.signature = signable_key.sign(&signable);
112+
tx.fee_payer = Some(FeePayer {
113+
address: attacker.address(),
114+
pub_key: attacker.verifying_key().to_bytes(),
115+
signature: attacker.sign(&signable),
116+
nonce: 0,
117+
});
118+
119+
assert!(
120+
!tx.verify_signature(),
121+
"pub_key address mismatch with `from` must fail verification"
122+
);
123+
}
124+
125+
#[test]
126+
fn sponsored_registration_accepts_owner_signed_tx() {
127+
// Legitimate meta-tx flow: owner signs intent, sponsor signs envelope.
128+
// Owner has zero UDAG; sponsor pays the fee. Both signatures valid.
129+
let owner = SecretKey::generate();
130+
let sponsor = SecretKey::generate();
131+
let mut engine = engine_with_funded(&[(&sponsor, 10_000 * COIN)]);
132+
assert_eq!(engine.balance(&owner.address()), 0);
133+
134+
let tx = owner_signed_sponsored_tx(&owner, &sponsor, "alice", 100 * COIN, 0, 0);
135+
assert!(tx.verify_signature(), "owner-signed sponsored tx must verify");
136+
engine
137+
.apply_register_name_tx(&tx)
138+
.expect("owner-signed sponsored tx must apply");
139+
assert_eq!(engine.resolve_name("alice"), Some(owner.address()));
140+
// Fee debited from sponsor, not owner.
141+
assert!(engine.balance(&sponsor.address()) < 10_000 * COIN);
142+
assert_eq!(engine.balance(&owner.address()), 0);
143+
}
144+
145+
#[test]
146+
fn non_sponsored_registration_still_requires_owner_signature() {
147+
// Sanity: the non-sponsored path remains unchanged — zero-signature
148+
// tx must still be rejected.
149+
let owner = SecretKey::generate();
150+
let mut tx = RegisterNameTx {
151+
from: owner.address(),
152+
name: "bob".to_string(),
153+
duration_years: 1,
154+
fee: 100 * COIN,
155+
nonce: 0,
156+
pub_key: owner.verifying_key().to_bytes(),
157+
signature: Signature([0u8; 64]),
158+
fee_payer: None,
159+
};
160+
assert!(!tx.verify_signature(), "unsigned tx must fail");
161+
tx.signature = owner.sign(&tx.signable_bytes());
162+
assert!(tx.verify_signature(), "owner-signed tx must verify");
163+
}

0 commit comments

Comments
 (0)