Skip to content

Commit d72d14b

Browse files
Ectarioonbjerg
andauthored
feat(cheatcodes): Add vm.signWithNonce(privateKey, digest, nonce) cheatcode (Crypto) (foundry-rs#11267)
* feat: adding sign_wih_nonce in cheatcodes crates * test: adding sign with nonce solidity unit tests * style: formatting * test: fixing solidity invalid nonce test * style: fix formatting in Sign.t.sol * ci: fix cheatcode specs test problem * Update crates/cheatcodes/spec/src/vm.rs review modification Co-authored-by: onbjerg <[email protected]> * Update crates/cheatcodes/src/crypto.rs review modification (better format) Co-authored-by: onbjerg <[email protected]> * refactor: changing cheatcode name (append Unsafe to it) --------- Co-authored-by: onbjerg <[email protected]>
1 parent 158a192 commit d72d14b

File tree

5 files changed

+241
-1
lines changed

5 files changed

+241
-1
lines changed

crates/cheatcodes/assets/cheatcodes.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cheatcodes/spec/src/vm.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2698,6 +2698,11 @@ interface Vm {
26982698
#[cheatcode(group = Crypto)]
26992699
function sign(uint256 privateKey, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s);
27002700

2701+
/// Signs `digest` with `privateKey` on the secp256k1 curve, using the given `nonce`
2702+
/// as the raw ephemeral k value in ECDSA (instead of deriving it deterministically).
2703+
#[cheatcode(group = Crypto)]
2704+
function signWithNonceUnsafe(uint256 privateKey, bytes32 digest, uint256 nonce) external pure returns (uint8 v, bytes32 r, bytes32 s);
2705+
27012706
/// Signs `digest` with `privateKey` using the secp256k1 curve.
27022707
///
27032708
/// Returns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the

crates/cheatcodes/src/crypto.rs

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ use alloy_signer_local::{
1212
};
1313
use alloy_sol_types::SolValue;
1414
use k256::{
15-
ecdsa::SigningKey,
15+
FieldBytes, Scalar,
16+
ecdsa::{SigningKey, hazmat},
1617
elliptic_curve::{bigint::ArrayEncoding, sec1::ToEncodedPoint},
1718
};
19+
1820
use p256::ecdsa::{
1921
Signature as P256Signature, SigningKey as P256SigningKey, signature::hazmat::PrehashSigner,
2022
};
@@ -51,6 +53,16 @@ impl Cheatcode for sign_0Call {
5153
}
5254
}
5355

56+
impl Cheatcode for signWithNonceUnsafeCall {
57+
fn apply(&self, _state: &mut Cheatcodes) -> Result {
58+
let pk: U256 = self.privateKey;
59+
let digest: B256 = self.digest;
60+
let nonce: U256 = self.nonce;
61+
let sig: alloy_primitives::Signature = sign_with_nonce(&pk, &digest, &nonce)?;
62+
Ok(encode_full_sig(sig))
63+
}
64+
}
65+
5466
impl Cheatcode for signCompact_0Call {
5567
fn apply(&self, _state: &mut Cheatcodes) -> Result {
5668
let Self { wallet, digest } = self;
@@ -241,6 +253,86 @@ fn sign(private_key: &U256, digest: &B256) -> Result<alloy_primitives::Signature
241253
Ok(sig)
242254
}
243255

256+
/// Signs `digest` on secp256k1 using a user-supplied ephemeral nonce `k` (no RFC6979).
257+
/// - `private_key` and `nonce` must be in (0, n)
258+
/// - `digest` is a 32-byte prehash.
259+
///
260+
/// # Warning
261+
///
262+
/// Use [`sign_with_nonce`] with extreme caution!
263+
/// Reusing the same nonce (`k`) with the same private key in ECDSA will leak the private key.
264+
/// Always generate `nonce` with a cryptographically secure RNG, and never reuse it across
265+
/// signatures.
266+
fn sign_with_nonce(
267+
private_key: &U256,
268+
digest: &B256,
269+
nonce: &U256,
270+
) -> Result<alloy_primitives::Signature> {
271+
let d_scalar: Scalar =
272+
<Scalar as k256::elliptic_curve::PrimeField>::from_repr(private_key.to_be_bytes().into())
273+
.into_option()
274+
.ok_or_else(|| fmt_err!("invalid private key scalar"))?;
275+
if bool::from(d_scalar.is_zero()) {
276+
return Err(fmt_err!("private key cannot be 0"));
277+
}
278+
279+
let k_scalar: Scalar =
280+
<Scalar as k256::elliptic_curve::PrimeField>::from_repr(nonce.to_be_bytes().into())
281+
.into_option()
282+
.ok_or_else(|| fmt_err!("invalid nonce scalar"))?;
283+
if bool::from(k_scalar.is_zero()) {
284+
return Err(fmt_err!("nonce cannot be 0"));
285+
}
286+
287+
let mut z = [0u8; 32];
288+
z.copy_from_slice(digest.as_slice());
289+
let z_fb: FieldBytes = FieldBytes::from(z);
290+
291+
// Hazmat signing using the scalar `d` (SignPrimitive is implemented for `Scalar`)
292+
// Note: returns (Signature, Option<RecoveryId>)
293+
let (sig_raw, recid_opt) =
294+
<Scalar as hazmat::SignPrimitive<k256::Secp256k1>>::try_sign_prehashed(
295+
&d_scalar, k_scalar, &z_fb,
296+
)
297+
.map_err(|e| fmt_err!("sign_prehashed failed: {e}"))?;
298+
299+
// Enforce low-s; if mirrored, parity flips (we’ll account for it below if we use recid)
300+
let (sig_low, flipped) =
301+
if let Some(norm) = sig_raw.normalize_s() { (norm, true) } else { (sig_raw, false) };
302+
303+
let r_u256 = U256::from_be_bytes(sig_low.r().to_bytes().into());
304+
let s_u256 = U256::from_be_bytes(sig_low.s().to_bytes().into());
305+
306+
// Determine v parity in {0,1}
307+
let v_parity = if let Some(id) = recid_opt {
308+
let mut v = id.to_byte() & 1;
309+
if flipped {
310+
v ^= 1;
311+
}
312+
v
313+
} else {
314+
// Fallback: choose parity by recovery to expected address
315+
let expected_addr = {
316+
let sk: SigningKey = parse_private_key(private_key)?;
317+
alloy_signer::utils::secret_key_to_address(&sk)
318+
};
319+
// Try v = 0
320+
let cand0 = alloy_primitives::Signature::new(r_u256, s_u256, false);
321+
if cand0.recover_address_from_prehash(digest).ok() == Some(expected_addr) {
322+
return Ok(cand0);
323+
}
324+
// Try v = 1
325+
let cand1 = alloy_primitives::Signature::new(r_u256, s_u256, true);
326+
if cand1.recover_address_from_prehash(digest).ok() == Some(expected_addr) {
327+
return Ok(cand1);
328+
}
329+
return Err(fmt_err!("failed to determine recovery id for signature"));
330+
};
331+
332+
let y_parity = v_parity != 0;
333+
Ok(alloy_primitives::Signature::new(r_u256, s_u256, y_parity))
334+
}
335+
244336
fn sign_with_wallet(
245337
state: &mut Cheatcodes,
246338
signer: Option<Address>,
@@ -393,6 +485,7 @@ fn derive_wallets<W: Wordlist>(
393485
mod tests {
394486
use super::*;
395487
use alloy_primitives::{FixedBytes, hex::FromHex};
488+
use k256::elliptic_curve::Curve;
396489
use p256::ecdsa::signature::hazmat::PrehashVerifier;
397490

398491
#[test]
@@ -438,4 +531,69 @@ mod tests {
438531
let result = sign_p256(&U256::ZERO, &digest);
439532
assert_eq!(result.err().unwrap().to_string(), "private key cannot be 0");
440533
}
534+
535+
#[test]
536+
fn test_sign_with_nonce_varies_and_recovers() {
537+
// Given a fixed private key and digest
538+
let pk_u256: U256 = U256::from(1u64);
539+
let digest = FixedBytes::from_hex(
540+
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
541+
)
542+
.unwrap();
543+
544+
// Two distinct nonces
545+
let n1: U256 = U256::from(123u64);
546+
let n2: U256 = U256::from(456u64);
547+
548+
// Sign with both nonces
549+
let sig1 = sign_with_nonce(&pk_u256, &digest, &n1).expect("sig1");
550+
let sig2 = sign_with_nonce(&pk_u256, &digest, &n2).expect("sig2");
551+
552+
// (r,s) must differ when nonce differs
553+
assert!(
554+
sig1.r() != sig2.r() || sig1.s() != sig2.s(),
555+
"signatures should differ with different nonces"
556+
);
557+
558+
// ecrecover must yield the address for both signatures
559+
let sk = parse_private_key(&pk_u256).unwrap();
560+
let expected = alloy_signer::utils::secret_key_to_address(&sk);
561+
562+
assert_eq!(sig1.recover_address_from_prehash(&digest).unwrap(), expected);
563+
assert_eq!(sig2.recover_address_from_prehash(&digest).unwrap(), expected);
564+
}
565+
566+
#[test]
567+
fn test_sign_with_nonce_zero_nonce_errors() {
568+
// nonce = 0 should be rejected
569+
let pk_u256: U256 = U256::from(1u64);
570+
let digest = FixedBytes::from_hex(
571+
"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
572+
)
573+
.unwrap();
574+
let n0: U256 = U256::ZERO;
575+
576+
let err = sign_with_nonce(&pk_u256, &digest, &n0).unwrap_err();
577+
let msg = err.to_string();
578+
assert!(msg.contains("nonce cannot be 0"), "unexpected error: {msg}");
579+
}
580+
581+
#[test]
582+
fn test_sign_with_nonce_nonce_ge_order_errors() {
583+
// nonce >= n should be rejected
584+
use k256::Secp256k1;
585+
// Curve order n as U256
586+
let n_u256 = U256::from_be_slice(&Secp256k1::ORDER.to_be_byte_array());
587+
588+
let pk_u256: U256 = U256::from(1u64);
589+
let digest = FixedBytes::from_hex(
590+
"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
591+
)
592+
.unwrap();
593+
594+
// Try exactly n (>= n invalid)
595+
let err = sign_with_nonce(&pk_u256, &digest, &n_u256).unwrap_err();
596+
let msg = err.to_string();
597+
assert!(msg.contains("invalid nonce scalar"), "unexpected error: {msg}");
598+
}
441599
}

testdata/cheats/Vm.sol

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

testdata/default/cheats/Sign.t.sol

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,60 @@ contract SignTest is DSTest {
4444
function testSignCompactMessage(uint248 pk, bytes memory message) public {
4545
testSignCompactDigest(pk, keccak256(message));
4646
}
47+
48+
/// secp256k1 subgroup order n
49+
function _secp256k1Order() internal pure returns (uint256) {
50+
return 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
51+
}
52+
53+
function testsignWithNonceUnsafeDigestDifferentNonces(uint248 pk, bytes32 digest) public {
54+
vm.assume(pk != 0);
55+
uint256 n1 = 123;
56+
uint256 n2 = 456;
57+
vm.assume(n1 != 0 && n2 != 0 && n1 != n2);
58+
(uint8 v1, bytes32 r1, bytes32 s1) = vm.signWithNonceUnsafe(pk, digest, n1);
59+
(uint8 v2, bytes32 r2, bytes32 s2) = vm.signWithNonceUnsafe(pk, digest, n2);
60+
assertTrue(r1 != r2 || s1 != s2, "signatures should differ for different nonces");
61+
address expected = vm.addr(pk);
62+
assertEq(ecrecover(digest, v1, r1, s1), expected, "recover for nonce n1 failed");
63+
assertEq(ecrecover(digest, v2, r2, s2), expected, "recover for nonce n2 failed");
64+
}
65+
66+
function testsignWithNonceUnsafeDigestSameNonceDeterministic(uint248 pk, bytes32 digest) public {
67+
vm.assume(pk != 0);
68+
uint256 n = 777;
69+
vm.assume(n != 0);
70+
(uint8 v1, bytes32 r1, bytes32 s1) = vm.signWithNonceUnsafe(pk, digest, n);
71+
(uint8 v2, bytes32 r2, bytes32 s2) = vm.signWithNonceUnsafe(pk, digest, n);
72+
assertEq(v1, v2, "v should match");
73+
assertEq(r1, r2, "r should match");
74+
assertEq(s1, s2, "s should match");
75+
address expected = vm.addr(pk);
76+
assertEq(ecrecover(digest, v1, r1, s1), expected, "recover failed");
77+
}
78+
79+
function testsignWithNonceUnsafeInvalidNoncesRevert() public {
80+
uint256 pk = 1;
81+
bytes32 digest = 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;
82+
(bool ok, bytes memory data) =
83+
HEVM_ADDRESS.call(abi.encodeWithSelector(Vm.signWithNonceUnsafe.selector, pk, digest, 0));
84+
assertTrue(!ok, "expected revert on nonce=0");
85+
assertEq(_revertString(data), "vm.signWithNonceUnsafe: nonce cannot be 0");
86+
uint256 n = _secp256k1Order();
87+
(ok, data) = HEVM_ADDRESS.call(abi.encodeWithSelector(Vm.signWithNonceUnsafe.selector, pk, digest, n));
88+
assertTrue(!ok, "expected revert on nonce >= n");
89+
assertEq(_revertString(data), "vm.signWithNonceUnsafe: invalid nonce scalar");
90+
}
91+
92+
/// Decode revert payload
93+
/// by stripping the 4-byte selector and ABI-decoding the tail as `string`.
94+
function _revertString(bytes memory data) internal pure returns (string memory) {
95+
if (data.length < 4) return "";
96+
// copy data[4:] into a new bytes
97+
bytes memory tail = new bytes(data.length - 4);
98+
for (uint256 i = 0; i < tail.length; i++) {
99+
tail[i] = data[i + 4];
100+
}
101+
return abi.decode(tail, (string));
102+
}
47103
}

0 commit comments

Comments
 (0)