Skip to content

Commit 144f186

Browse files
committed
fix: Reject ECDSA signatures with high-s values and v != 27/28 (#18032)
As defined in EIP2, we do not support ECDSA signatures with s-values in the upper half of the curve on our contracts: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/51ab591cd7a47446293a0d5e285792f63cbeb1ea/contracts/utils/cryptography/ECDSA.sol#L176-L184 However, we do allow them when verifying them offchain. This can lead to a proposer collecting attestations, verifying their signatures successfully, but then having them rejected when trying to submit a tx. This PR changes the default behaviour of our Secp256k1 ECDSA utils so that we reject a signature as invalid if it's in the high-S values. It also adds a safeguard around web3signer so, if it emits an invalid signature, it flips it to the correct side. This PR also makes `recover` to also reject signatures with v-values other than 27/28, since these are rejected by ECRECOVER. Removing this check would've meant that an attestor could have supplied a signature with yParity bit instead of v, which would've been accepted by the node, posted on chain, and then made the block eligible for invalidation. Fixes A-182
1 parent bb53421 commit 144f186

File tree

6 files changed

+326
-22
lines changed

6 files changed

+326
-22
lines changed

l1-contracts/src/core/libraries/Errors.sol

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,32 @@ library Errors {
211211

212212
// SlashPayloadLib
213213
error SlashPayload_ArraySizeMismatch(uint256 expected, uint256 actual);
214+
215+
// OpenZeppelin dependencies
216+
217+
// ECDSA
218+
error ECDSAInvalidSignature();
219+
error ECDSAInvalidSignatureLength(uint256 length);
220+
error ECDSAInvalidSignatureS(bytes32 s);
221+
222+
// Ownable
223+
error OwnableUnauthorizedAccount(address account);
224+
error OwnableInvalidOwner(address owner);
225+
226+
// Checkpoints
227+
error CheckpointUnorderedInsertion();
228+
229+
// ERC20
230+
error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
231+
error ERC20InvalidSender(address sender);
232+
error ERC20InvalidReceiver(address receiver);
233+
error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
234+
error ERC20InvalidApprover(address approver);
235+
error ERC20InvalidSpender(address spender);
236+
237+
// SafeCast
238+
error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value);
239+
error SafeCastOverflowedIntToUint(int256 value);
240+
error SafeCastOverflowedIntDowncast(uint8 bits, int256 value);
241+
error SafeCastOverflowedUintToInt(uint256 value);
214242
}

yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { range } from '@aztec/foundation/array';
2121
import { Buffer32 } from '@aztec/foundation/buffer';
2222
import { times, timesParallel } from '@aztec/foundation/collection';
2323
import { SecretValue } from '@aztec/foundation/config';
24-
import { SHA256Trunc, Secp256k1Signer, sha256ToField } from '@aztec/foundation/crypto';
24+
import { SHA256Trunc, Secp256k1Signer, flipSignature, sha256ToField } from '@aztec/foundation/crypto';
2525
import { EthAddress } from '@aztec/foundation/eth-address';
2626
import { hexToBuffer } from '@aztec/foundation/string';
2727
import { TestDateProvider } from '@aztec/foundation/timer';
@@ -592,6 +592,52 @@ describe('L1Publisher integration', () => {
592592
);
593593
});
594594

595+
it('rejects flipped proposer signature', async () => {
596+
const block = await buildSingleBlock();
597+
const blockAttestations = validators.map(v => makeBlockAttestationFromBlock(block, v));
598+
const attestations = orderAttestations(blockAttestations, committee!);
599+
600+
const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
601+
expect(canPropose?.slot).toEqual(block.header.getSlot());
602+
await publisher.validateBlockHeader(block.getCheckpointHeader());
603+
604+
const attestationsAndSigners = new CommitteeAttestationsAndSigners(attestations);
605+
const attestationsAndSignersSignature = makeAndSignCommitteeAttestationsAndSigners(
606+
attestationsAndSigners,
607+
validators.find(v => v.address.equals(proposer!))!,
608+
);
609+
610+
await expect(
611+
publisher.enqueueProposeL2Block(block, attestationsAndSigners, flipSignature(attestationsAndSignersSignature)),
612+
).rejects.toThrow(/ECDSAInvalidSignatureS/);
613+
});
614+
615+
it('rejects signature with invalid recovery value', async () => {
616+
const block = await buildSingleBlock();
617+
const blockAttestations = validators.map(v => makeBlockAttestationFromBlock(block, v));
618+
const attestations = orderAttestations(blockAttestations, committee!);
619+
620+
const canPropose = await publisher.canProposeAtNextEthBlock(new Fr(GENESIS_ARCHIVE_ROOT), proposer!);
621+
expect(canPropose?.slot).toEqual(block.header.getSlot());
622+
await publisher.validateBlockHeader(block.getCheckpointHeader());
623+
624+
const attestationsAndSigners = new CommitteeAttestationsAndSigners(attestations);
625+
const attestationsAndSignersSignature = makeAndSignCommitteeAttestationsAndSigners(
626+
attestationsAndSigners,
627+
validators.find(v => v.address.equals(proposer!))!,
628+
);
629+
630+
logger.warn(`Original v value: ${attestationsAndSignersSignature.v}`);
631+
632+
// Move v-value from 27-28 to 0-1
633+
const wrongV = attestationsAndSignersSignature.v - 27;
634+
const wrongSig = new Signature(attestationsAndSignersSignature.r, attestationsAndSignersSignature.s, wrongV);
635+
636+
await expect(publisher.enqueueProposeL2Block(block, attestationsAndSigners, wrongSig)).rejects.toThrow(
637+
/ECDSAInvalidSignature/,
638+
);
639+
});
640+
595641
it('publishes a block invalidating the previous one', async () => {
596642
const badBlock = await buildSingleBlock();
597643

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Buffer32 } from '@aztec/foundation/buffer';
2+
3+
import { generatePrivateKey } from 'viem/accounts';
4+
5+
import type { EthAddress } from '../../eth-address/index.js';
6+
import { Signature } from '../../eth-signature/eth_signature.js';
7+
import { Secp256k1Signer } from './secp256k1_signer.js';
8+
import {
9+
Secp256k1Error,
10+
flipSignature,
11+
makeEthSignDigest,
12+
normalizeSignature,
13+
recoverAddress,
14+
tryRecoverAddress,
15+
} from './utils.js';
16+
17+
describe('ecdsa malleability', () => {
18+
let privateKey: `0x${string}`;
19+
let signer: Secp256k1Signer;
20+
let expectedAddress: EthAddress;
21+
let message: Buffer32;
22+
let digest: Buffer32;
23+
let originalSignature: Signature;
24+
25+
beforeEach(() => {
26+
// Generate a random private key and signer
27+
privateKey = generatePrivateKey();
28+
signer = new Secp256k1Signer(Buffer32.fromBuffer(Buffer.from(privateKey.slice(2), 'hex')));
29+
expectedAddress = signer.address;
30+
31+
// Create a random message and sign it
32+
message = Buffer32.random();
33+
digest = makeEthSignDigest(message);
34+
originalSignature = signer.sign(digest);
35+
});
36+
37+
it('recovers the same address from both original and flipped signatures', () => {
38+
// Recover address from original signature
39+
const recoveredFromOriginal = recoverAddress(digest, originalSignature);
40+
expect(recoveredFromOriginal.toString()).toEqual(expectedAddress.toString());
41+
42+
// Flip the signature
43+
const flippedSignature = flipSignature(originalSignature);
44+
45+
// Ensure the flipped signature is different
46+
expect(flippedSignature.equals(originalSignature)).toBe(false);
47+
expect(flippedSignature.r.equals(originalSignature.r)).toBe(true); // r should be the same
48+
expect(flippedSignature.s.equals(originalSignature.s)).toBe(false); // s should be different
49+
expect(flippedSignature.v).not.toEqual(originalSignature.v); // v should be different
50+
51+
// Recover address from flipped signature (must use allowMalleable: true)
52+
const recoveredFromFlipped = recoverAddress(digest, flippedSignature, { allowMalleable: true });
53+
expect(recoveredFromFlipped.toString()).toEqual(expectedAddress.toString());
54+
expect(() => recoverAddress(digest, flippedSignature)).toThrow(Secp256k1Error);
55+
56+
// Both recovered addresses should match
57+
expect(recoveredFromOriginal.equals(recoveredFromFlipped)).toBe(true);
58+
});
59+
60+
it('flips signature back and forth correctly', () => {
61+
// Flip once
62+
const flipped = flipSignature(originalSignature);
63+
64+
// Flip back
65+
const flippedBack = flipSignature(flipped);
66+
67+
// Should match the original
68+
expect(flippedBack.equals(originalSignature)).toBe(true);
69+
});
70+
71+
it('rejects malleable signatures by default in recoverAddress', () => {
72+
// Original signature should work
73+
expect(() => recoverAddress(digest, originalSignature)).not.toThrow();
74+
75+
// Flip the signature to make it malleable
76+
const flippedSignature = flipSignature(originalSignature);
77+
78+
// Flipped signature should be rejected by default
79+
expect(() => recoverAddress(digest, flippedSignature)).toThrow(Secp256k1Error);
80+
});
81+
82+
it('accepts malleable signatures when allowMalleable is true', () => {
83+
// Flip the signature to make it malleable
84+
const flippedSignature = flipSignature(originalSignature);
85+
86+
// Should work with allowMalleable: true
87+
const recoveredAddress = recoverAddress(digest, flippedSignature, { allowMalleable: true });
88+
expect(recoveredAddress.toString()).toEqual(expectedAddress.toString());
89+
});
90+
91+
it('rejects malleable signatures by default in tryRecoverAddress', () => {
92+
// Original signature should work
93+
expect(tryRecoverAddress(digest, originalSignature)).toBeDefined();
94+
95+
// Flip the signature to make it malleable
96+
const flippedSignature = flipSignature(originalSignature);
97+
98+
// Flipped signature should return undefined by default
99+
expect(tryRecoverAddress(digest, flippedSignature)).toBeUndefined();
100+
});
101+
102+
it('accepts malleable signatures in tryRecoverAddress when allowMalleable is true', () => {
103+
// Flip the signature to make it malleable
104+
const flippedSignature = flipSignature(originalSignature);
105+
106+
// Should work with allowMalleable: true
107+
const recoveredAddress = tryRecoverAddress(digest, flippedSignature, { allowMalleable: true });
108+
expect(recoveredAddress).toBeDefined();
109+
expect(recoveredAddress!.toString()).toEqual(expectedAddress.toString());
110+
});
111+
112+
it('normalizes signature with high s-value correctly', () => {
113+
// Flip the signature to create a high s-value signature
114+
const highSSignature = flipSignature(originalSignature);
115+
116+
// Recover address using the high s-value signature with allowMalleable: true
117+
const recoveredAddress = recoverAddress(digest, highSSignature, { allowMalleable: true });
118+
expect(recoveredAddress.toString()).toEqual(expectedAddress.toString());
119+
120+
// Check that the signature is flipped back to low s-value when normalized
121+
const normalizedSignature = flipSignature(highSSignature);
122+
expect(normalizedSignature.equals(originalSignature)).toBe(true);
123+
});
124+
125+
it('does not alter low s-value signatures when normalizing', () => {
126+
// Recover address using the original low s-value signature
127+
const recoveredAddress = recoverAddress(digest, originalSignature);
128+
expect(recoveredAddress.toString()).toEqual(expectedAddress.toString());
129+
130+
// Normalize the signature (should remain unchanged)
131+
const normalizedSignature = normalizeSignature(originalSignature);
132+
expect(normalizedSignature.equals(originalSignature)).toBe(true);
133+
});
134+
135+
it('rejects signatures with invalid v-value', () => {
136+
const signature = new Signature(originalSignature.r, originalSignature.s, originalSignature.v - 27);
137+
expect(() => recoverAddress(digest, signature)).toThrow(Secp256k1Error);
138+
const recoveredAddress = recoverAddress(digest, signature, { allowYParityAsV: true });
139+
expect(recoveredAddress.toString()).toEqual(expectedAddress.toString());
140+
});
141+
});

yarn-project/foundation/src/crypto/secp256k1-signer/secp256k1_signer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe('Secp256k1Signer', () => {
2222
lightSigner = new Secp256k1Signer(Buffer32.fromBuffer(Buffer.from(privateKey.slice(2), 'hex')));
2323
});
2424

25-
it('Compare implementation against viem', async () => {
25+
it('compares implementation against viem', async () => {
2626
const message = Buffer.from('0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex');
2727
// Use to compare addresses at the end
2828
const accountAddress = viemSigner.address;

0 commit comments

Comments
 (0)