|
| 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