Skip to content

Commit 5e0e53f

Browse files
authored
fix(sequencer): invalidate malicious yParity attestation checkpoints (A-1401) (#24537)
## Context A-1351 (#24515) fixed the honest-proposer and relay path for non-canonical yParity attestation signatures, but a malicious selected proposer can still hand-craft `propose()` calldata carrying a non-proposer signature slot with `v ∈ {0,1}` (or an all-zero `(r,s,v)` in a bitmap-marked signature slot). L1 accepts this at propose time (it only recovers the proposer's own slot) and stores the attestations hash over the raw bytes. Afterwards, either: - the slot recovers to the correct member, so honest nodes deem the checkpoint valid and nobody invalidates it — but epoch proving reverts at `ECDSA.recover` (which rejects `v ∉ {27,28}`), a silent stall; or - the slot is detected as invalid, but the invalidation repack via `packAttestations` is not byte-faithful, so the recomputed hash diverges from the stored one and `InvalidateLib` reverts — wedging the chain until prune. This is the malicious-proposer sibling of A-1351; it survives that fix. Tracks aztec-claude#650. ## Approach TS-only — no L1 changes, since this targets a fresh deployment. Two parts: - **Detection** — `getAttestationInfoFromPayload` no longer passes `allowYParityAsV: true`, so TS signature validity matches L1's `ECDSA.recover`. A non-canonical slot is now classified as an invalid attestation, so honest nodes detect the bad checkpoint instead of silently accepting it. - **Byte-faithful invalidation** — the raw packed `CommitteeAttestations` tuple from the checkpoint calldata is threaded verbatim (as `verbatimAttestations`) through the negative `ValidateCheckpointResult` into `buildInvalidateCheckpointRequest`, which submits those exact bytes rather than repacking. With the original bytes, `invalidateBadAttestation` reproduces the stored hash and L1's `tryRecover` returns `address(0) ≠ member`, so invalidation succeeds. `verbatimAttestations` is a required field on the negative result and is serialized unconditionally — a repack fallback is intentionally absent, since silently repacking would re-introduce the wedge. The gossip sender-recovery path (`recoverCoordinationSigner`) deliberately stays lenient (`allowYParityAsV`) so an honest node can still attribute a yParity-encoded message and canonicalize it on ingress; only the on-chain checkpoint validation is made strict. A comment documents the split so it is not accidentally unified. ## Tests An e2e regression under the invalidation suite (`invalidate_block.parallel.test.ts`, "proposer invalidates checkpoint with a yParity attestation slot") demonstrates the attack red→green: a malicious proposer lands a checkpoint whose non-proposer attestation slots carry raw yParity bytes, and the honest next proposer invalidates it — which only succeeds when the invalidation submits the verbatim calldata bytes (a repack diverges from the on-chain `attestationsHash` and reverts). Plus unit coverage for the strict attestation-info classification, the byte-faithful passthrough, and the result round-trip. An attester-side variant (a committee member emitting yParity attestations) is not separately tested: the honest proposer's `orderAttestations` normalization (A-1351) canonicalizes the byte before the L1 bundle, so it never reaches L1 and produces the same on-chain outcome as — and is subsumed by — the malicious-proposer case above. Fixes A-1401
1 parent 2ec670f commit 5e0e53f

26 files changed

Lines changed: 483 additions & 41 deletions

yarn-project/archiver/src/l1/calldata_retriever.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ describe('CalldataRetriever', () => {
505505
archiveRoot: Fr.random(),
506506
header: CheckpointHeader.random(),
507507
attestations: [],
508+
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
508509
blockHash,
509510
feeAssetPriceModifier: 0n,
510511
};

yarn-project/archiver/src/l1/calldata_retriever.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ type CheckpointData = {
3434
archiveRoot: Fr;
3535
header: CheckpointHeader;
3636
attestations: CommitteeAttestation[];
37+
/**
38+
* The exact packed `CommitteeAttestations` tuple as it appears in the propose calldata, preserved
39+
* verbatim (never re-derived from {@link attestations}) so invalidation evidence stays byte-faithful to
40+
* the on-chain `attestationsHash`.
41+
*/
42+
verbatimAttestations: ViemCommitteeAttestations;
3743
blockHash: string;
3844
feeAssetPriceModifier: bigint;
3945
};
@@ -406,14 +412,14 @@ export class CalldataRetriever {
406412
return undefined;
407413
}
408414

409-
const [decodedArgs, packedAttestations] = args! as readonly [
415+
const [decodedArgs, verbatimAttestations] = args! as readonly [
410416
{ archive: Hex; oracleInput: { feeAssetPriceModifier: bigint }; header: ViemHeader },
411417
ViemCommitteeAttestations,
412418
...unknown[],
413419
];
414420

415421
// Verify attestationsHash
416-
const computedAttestationsHash = this.computeAttestationsHash(packedAttestations);
422+
const computedAttestationsHash = this.computeAttestationsHash(verbatimAttestations);
417423
if (
418424
!Buffer.from(hexToBytes(computedAttestationsHash)).equals(
419425
Buffer.from(hexToBytes(expectedHashes.attestationsHash)),
@@ -441,15 +447,15 @@ export class CalldataRetriever {
441447
return undefined;
442448
}
443449

444-
const attestations = CommitteeAttestation.fromPacked(packedAttestations, this.targetCommitteeSize);
450+
const attestations = CommitteeAttestation.fromPacked(verbatimAttestations, this.targetCommitteeSize);
445451

446452
this.logger.trace(`Validated and decoded propose calldata for checkpoint ${checkpointNumber}`, {
447453
checkpointNumber,
448454
archive: decodedArgs.archive,
449455
header: decodedArgs.header,
450456
l1BlockHash: blockHash,
451457
attestations,
452-
packedAttestations,
458+
verbatimAttestations,
453459
targetCommitteeSize: this.targetCommitteeSize,
454460
});
455461

@@ -458,6 +464,7 @@ export class CalldataRetriever {
458464
archiveRoot,
459465
header,
460466
attestations,
467+
verbatimAttestations,
461468
blockHash,
462469
feeAssetPriceModifier,
463470
};
@@ -467,8 +474,8 @@ export class CalldataRetriever {
467474
}
468475

469476
/** Computes the keccak256 hash of ABI-encoded CommitteeAttestations. */
470-
private computeAttestationsHash(packedAttestations: ViemCommitteeAttestations): Hex {
471-
return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations]));
477+
private computeAttestationsHash(verbatimAttestations: ViemCommitteeAttestations): Hex {
478+
return keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [verbatimAttestations]));
472479
}
473480

474481
/** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */

yarn-project/archiver/src/l1/data_retrieval.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
InboxContract,
1313
MessageSentLog,
1414
RollupContract,
15+
ViemCommitteeAttestations,
1516
ViemHeader,
1617
} from '@aztec/ethereum/contracts';
1718
import type { ViemPublicClient, ViemPublicDebugClient } from '@aztec/ethereum/types';
@@ -56,6 +57,11 @@ export type RetrievedCheckpointFromCalldata = RetrievedCheckpointBase & {
5657
blobHashes: Buffer[];
5758
/** Parent beacon block root from the L1 block, used for blob fetching. */
5859
parentBeaconBlockRoot: string | undefined;
60+
/**
61+
* The exact packed `CommitteeAttestations` tuple from the propose calldata, carried verbatim so that
62+
* attestation validation can attach byte-faithful invalidation evidence to a negative result.
63+
*/
64+
verbatimAttestations: ViemCommitteeAttestations;
5965
};
6066

6167
export async function retrievedToPublishedCheckpoint({

yarn-project/archiver/src/modules/validation.test.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,48 @@ import { times } from '@aztec/foundation/collection';
55
import { Secp256k1Signer, flipSignature } from '@aztec/foundation/crypto/secp256k1-signer';
66
import { Signature } from '@aztec/foundation/eth-signature';
77
import { type Logger, createLogger } from '@aztec/foundation/log';
8-
import { CommitteeAttestation, EthAddress } from '@aztec/stdlib/block';
9-
import { Checkpoint } from '@aztec/stdlib/checkpoint';
8+
import {
9+
CommitteeAttestation,
10+
CommitteeAttestationsAndSigners,
11+
EthAddress,
12+
type ValidateCheckpointResult,
13+
} from '@aztec/stdlib/block';
14+
import { Checkpoint, type PublishedCheckpoint } from '@aztec/stdlib/checkpoint';
15+
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';
16+
import { ConsensusPayload, type CoordinationSignatureContext } from '@aztec/stdlib/p2p';
1017
import { TEST_COORDINATION_SIGNATURE_CONTEXT } from '@aztec/stdlib/testing';
1118

1219
import { type MockProxy, mock } from 'jest-mock-extended';
1320
import assert from 'node:assert';
1421

1522
import { makeSignedPublishedCheckpoint } from '../test/mock_structs.js';
16-
import { getAttestationInfoFromPublishedCheckpoint, validateCheckpointAttestations } from './validation.js';
23+
import { getAttestationInfoFromPublishedCheckpoint, validateAttestations } from './validation.js';
24+
25+
/**
26+
* Blob-path wrapper over the shared attestation validator, used only by these tests. Production
27+
* invalidation always originates from the calldata path (validateCheckpointAttestationsFromCalldata);
28+
* this repacks a fully decoded checkpoint's attestations to supply the verbatimAttestations field.
29+
*/
30+
function validateCheckpointAttestations(
31+
publishedCheckpoint: PublishedCheckpoint,
32+
epochCache: EpochCache,
33+
constants: Pick<L1RollupConstants, 'epochDuration'>,
34+
signatureContext: CoordinationSignatureContext,
35+
logger?: Logger,
36+
): Promise<ValidateCheckpointResult> {
37+
const { checkpoint, attestations } = publishedCheckpoint;
38+
const payload = ConsensusPayload.fromCheckpoint(checkpoint, signatureContext);
39+
const verbatimAttestations = CommitteeAttestationsAndSigners.packAttestations(attestations);
40+
return validateAttestations(
41+
payload,
42+
attestations,
43+
verbatimAttestations,
44+
checkpoint.toCheckpointInfo(),
45+
epochCache,
46+
constants,
47+
logger,
48+
);
49+
}
1750

1851
describe('validateCheckpointAttestations', () => {
1952
let epochCache: MockProxy<EpochCache>;
@@ -232,6 +265,30 @@ describe('validateCheckpointAttestations', () => {
232265
expect(result.invalidIndex).toBe(2);
233266
});
234267

268+
it('fails if an attestation is in yParity (v in {0, 1}) form even though it recovers to the right member', async () => {
269+
const checkpoint = await makeCheckpoint(signers.slice(0, 4), committee);
270+
271+
// Rewrite index 2's canonical signature to its yParity form: same (r, s), recovery byte 0/1. It still
272+
// recovers to the same committee member off-chain, but L1 ECDSA.recover rejects v not in {27, 28}.
273+
const original = checkpoint.attestations[2];
274+
const yParity = new Signature(original.signature.r, original.signature.s, original.signature.v - 27);
275+
checkpoint.attestations[2] = new CommitteeAttestation(original.address, yParity);
276+
277+
const attestations = getAttestationInfoFromPublishedCheckpoint(checkpoint, TEST_COORDINATION_SIGNATURE_CONTEXT);
278+
expect(attestations[2].status).toBe('invalid-signature');
279+
280+
const result = await validateCheckpointAttestations(
281+
checkpoint,
282+
epochCache,
283+
constants,
284+
TEST_COORDINATION_SIGNATURE_CONTEXT,
285+
logger,
286+
);
287+
assert(!result.valid);
288+
assert(result.reason === 'invalid-attestation');
289+
expect(result.invalidIndex).toBe(2);
290+
});
291+
235292
it('reports correct index when invalid attestation follows provided address', async () => {
236293
const checkpoint = await makeCheckpoint(signers.slice(0, 3), committee);
237294

yarn-project/archiver/src/modules/validation.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { EpochCache } from '@aztec/epoch-cache';
2+
import type { ViemCommitteeAttestations } from '@aztec/ethereum/contracts';
23
import { type CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types';
34
import { compactArray } from '@aztec/foundation/collection';
45
import type { Fr } from '@aztec/foundation/curves/bn254';
@@ -29,29 +30,15 @@ export function getAttestationInfoFromPublishedCheckpoint(
2930
return getAttestationInfoFromPayload(payload, attestations);
3031
}
3132

32-
/**
33-
* Validates the attestations of a checkpoint already retrieved (with its blocks) from blobs.
34-
* Returns true if the attestations are valid and sufficient, false otherwise.
35-
*/
36-
export function validateCheckpointAttestations(
37-
publishedCheckpoint: PublishedCheckpoint,
38-
epochCache: EpochCache,
39-
constants: Pick<L1RollupConstants, 'epochDuration'>,
40-
signatureContext: CoordinationSignatureContext,
41-
logger?: Logger,
42-
): Promise<ValidateCheckpointResult> {
43-
const { checkpoint, attestations } = publishedCheckpoint;
44-
const payload = ConsensusPayload.fromCheckpoint(checkpoint, signatureContext);
45-
return validateAttestations(payload, attestations, checkpoint.toCheckpointInfo(), epochCache, constants, logger);
46-
}
47-
4833
/** The subset of a calldata-only checkpoint needed to validate its committee attestations. */
4934
export type CalldataCheckpointForAttestations = {
5035
checkpointNumber: CheckpointNumber;
5136
archiveRoot: Fr;
5237
feeAssetPriceModifier: bigint;
5338
header: CheckpointHeader;
5439
attestations: CommitteeAttestation[];
40+
/** The exact packed attestations tuple from L1 calldata, carried verbatim for byte-faithful invalidation. */
41+
verbatimAttestations: ViemCommitteeAttestations;
5542
};
5643

5744
/**
@@ -80,17 +67,26 @@ export function validateCheckpointAttestationsFromCalldata(
8067
checkpointNumber: checkpoint.checkpointNumber,
8168
timestamp: checkpoint.header.timestamp,
8269
};
83-
return validateAttestations(payload, checkpoint.attestations, checkpointInfo, epochCache, constants, logger);
70+
return validateAttestations(
71+
payload,
72+
checkpoint.attestations,
73+
checkpoint.verbatimAttestations,
74+
checkpointInfo,
75+
epochCache,
76+
constants,
77+
logger,
78+
);
8479
}
8580

8681
/**
8782
* Core attestation validation over a consensus payload, its attestations, and checkpoint metadata --
8883
* independent of whether the checkpoint's blocks have been decoded from blobs. Returns true if the
8984
* attestations are valid and sufficient, false otherwise.
9085
*/
91-
async function validateAttestations(
86+
export async function validateAttestations(
9287
payload: ConsensusPayload,
9388
attestations: CommitteeAttestation[],
89+
verbatimAttestations: ViemCommitteeAttestations,
9490
checkpointInfo: CheckpointInfo,
9591
epochCache: EpochCache,
9692
constants: Pick<L1RollupConstants, 'epochDuration'>,
@@ -137,6 +133,7 @@ async function validateAttestations(
137133
epoch,
138134
attestors,
139135
attestations,
136+
verbatimAttestations,
140137
});
141138

142139
for (let i = 0; i < attestorInfos.length; i++) {

yarn-project/archiver/src/store/block_store.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1989,6 +1989,7 @@ describe('BlockStore', () => {
19891989
seed: 456n,
19901990
attestors: [EthAddress.random()],
19911991
attestations: [CommitteeAttestation.random()],
1992+
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
19921993
reason: 'insufficient-attestations',
19931994
};
19941995

@@ -2007,6 +2008,7 @@ describe('BlockStore', () => {
20072008
epoch: EpochNumber(789),
20082009
seed: 101n,
20092010
attestations: [CommitteeAttestation.random()],
2011+
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
20102012
reason: 'invalid-attestation',
20112013
invalidIndex: 5,
20122014
};
@@ -2027,6 +2029,7 @@ describe('BlockStore', () => {
20272029
seed: 888n,
20282030
attestors: [EthAddress.random()],
20292031
attestations: [CommitteeAttestation.random()],
2032+
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
20302033
reason: 'insufficient-attestations',
20312034
};
20322035

@@ -2046,6 +2049,7 @@ describe('BlockStore', () => {
20462049
seed: 0n,
20472050
attestors: [],
20482051
attestations: [],
2052+
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
20492053
reason: 'insufficient-attestations',
20502054
};
20512055

yarn-project/archiver/src/test/fake_l1_state.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ export class FakeL1State {
682682
getHashedSignaturePayloadTypedData(attestationsAndSigners),
683683
);
684684

685-
const packedAttestations = attestationsAndSigners.getPackedAttestations();
685+
const verbatimAttestations = attestationsAndSigners.getPackedAttestations();
686686

687687
const rollupInput = encodeFunctionData({
688688
abi: RollupAbi,
@@ -693,7 +693,7 @@ export class FakeL1State {
693693
archive,
694694
oracleInput: { feeAssetPriceModifier: 0n },
695695
},
696-
packedAttestations,
696+
verbatimAttestations,
697697
attestationsAndSigners.getSigners().map(signer => signer.toString()),
698698
attestationsAndSignersSignature.toViemSignature(),
699699
blobInput,
@@ -716,7 +716,7 @@ export class FakeL1State {
716716

717717
// Compute attestationsHash (same logic as CalldataRetriever)
718718
const attestationsHash = Buffer32.fromString(
719-
keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [packedAttestations])),
719+
keccak256(encodeAbiParameters([this.getCommitteeAttestationsStructDef()], [verbatimAttestations])),
720720
);
721721

722722
// Compute payloadDigest (same logic as CalldataRetriever)

yarn-project/aztec-node/src/aztec-node/node_public_calls_simulator.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ function makeInvalidStatus(firstInvalid: CheckpointNumber): ValidateCheckpointRe
452452
seed: 0n,
453453
attestors: [],
454454
attestations: [],
455+
verbatimAttestations: { signatureIndices: '0x', signaturesOrAddresses: '0x' },
455456
reason: 'insufficient-attestations',
456457
};
457458
}

yarn-project/end-to-end/src/multi-node/invalid-attestations/invalidate_block.parallel.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,19 @@ describe('multi-node/invalid-attestations/invalidate_block', () => {
818818
});
819819
});
820820

821+
// Injects every non-proposer attestation slot in yParity (v ∈ {0, 1}) form in the packed L1 tuple. L1
822+
// accepts it at propose() (attestation signatures are not verified there), but the checkpoint can never be
823+
// proven (ValidatorSelectionLib.verifyAttestations → ECDSA.recover rejects v ∉ {27, 28}). A repack of the
824+
// invalidation evidence would canonicalize the bytes back to 27/28 and diverge from the on-chain
825+
// attestationsHash, reverting the invalidation. Verifies an honest proposer detects the bad checkpoint and
826+
// invalidates it byte-faithfully from the raw calldata. Regression for A-1401.
827+
it('proposer invalidates checkpoint with a yParity attestation slot', async () => {
828+
await runInvalidationTest({
829+
attackConfig: { injectYParityAttestation: true },
830+
disableConfig: { injectYParityAttestation: false },
831+
});
832+
});
833+
821834
// Injects shuffled attestation ordering (accepted offchain but rejected by L1 which requires the
822835
// committee order). Waits for the bad checkpoint then verifies a good proposer invalidates it.
823836
// Regression for the node accepting attestations that did not conform to the committee order,

yarn-project/sequencer-client/src/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const DefaultSequencerConfig = {
6262
injectFakeAttestation: false,
6363
injectHighSValueAttestation: false,
6464
injectUnrecoverableSignatureAttestation: false,
65+
injectYParityAttestation: false,
6566
fishermanMode: false,
6667
shuffleAttestationOrdering: false,
6768
skipPushProposedBlocksToArchiver: false,
@@ -225,6 +226,10 @@ export const sequencerConfigMappings: ConfigMappingsType<SequencerConfig> = {
225226
description: 'Inject an attestation with an unrecoverable signature (for testing only)',
226227
...booleanConfigHelper(DefaultSequencerConfig.injectUnrecoverableSignatureAttestation),
227228
},
229+
injectYParityAttestation: {
230+
description: 'Inject a non-proposer attestation slot in yParity form in the packed L1 tuple (for testing only)',
231+
...booleanConfigHelper(DefaultSequencerConfig.injectYParityAttestation),
232+
},
228233
fishermanMode: {
229234
env: 'FISHERMAN_MODE',
230235
description:

0 commit comments

Comments
 (0)