Skip to content

[audit-06] fix: [TRST-M-3] Add nonce-based replay protection #1203

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: ma/indexing-payments-audit-fixes-05-M-2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/horizon/contracts/interfaces/IRecurringCollector.sol
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
* except for the first collection
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
* @param nonce The nonce for preventing replay attacks (must be current nonce + 1)
* @param metadata Arbitrary metadata to extend functionality if a data service requires it
*/
struct RecurringCollectionAgreementUpdate {
Expand All @@ -100,6 +101,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
uint256 maxOngoingTokensPerSecond;
uint32 minSecondsPerCollection;
uint32 maxSecondsPerCollection;
uint32 nonce;
bytes metadata;
}

Expand All @@ -118,6 +120,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
* except for the first collection
* @param minSecondsPerCollection The minimum amount of seconds that must pass between collections
* @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections
* @param updateNonce The current nonce for updates (prevents replay attacks)
* @param canceledAt The timestamp when the agreement was canceled
* @param state The state of the agreement
*/
Expand All @@ -132,6 +135,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
uint256 maxOngoingTokensPerSecond;
uint32 minSecondsPerCollection;
uint32 maxSecondsPerCollection;
uint32 updateNonce;
uint64 canceledAt;
AgreementState state;
}
Expand Down Expand Up @@ -357,6 +361,14 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector {
*/
error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds);

/**
* @notice Thrown when calling update() with an invalid nonce
* @param agreementId The agreement ID
* @param expected The expected nonce
* @param provided The provided nonce
*/
error RecurringCollectorInvalidUpdateNonce(bytes16 agreementId, uint32 expected, uint32 provided);

/**
* @dev Accept an indexing agreement.
* @param signedRCA The signed Recurring Collection Agreement which is to be accepted.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
/// @notice The EIP712 typehash for the RecurringCollectionAgreementUpdate struct
bytes32 public constant EIP712_RCAU_TYPEHASH =
keccak256(
"RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,bytes metadata)"
"RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint32 nonce,bytes metadata)"
);

/// @notice Tracks agreements
Expand Down Expand Up @@ -120,6 +120,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
agreement.maxOngoingTokensPerSecond = signedRCA.rca.maxOngoingTokensPerSecond;
agreement.minSecondsPerCollection = signedRCA.rca.minSecondsPerCollection;
agreement.maxSecondsPerCollection = signedRCA.rca.maxSecondsPerCollection;
agreement.updateNonce = 0;

emit AgreementAccepted(
agreement.dataService,
Expand Down Expand Up @@ -193,6 +194,13 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
// check that the voucher is signed by the payer (or proxy)
_requireAuthorizedRCAUSigner(signedRCAU, agreement.payer);

// validate nonce to prevent replay attacks
uint32 expectedNonce = agreement.updateNonce + 1;
require(
signedRCAU.rcau.nonce == expectedNonce,
RecurringCollectorInvalidUpdateNonce(signedRCAU.rcau.agreementId, expectedNonce, signedRCAU.rcau.nonce)
);

_requireValidCollectionWindowParams(
signedRCAU.rcau.endsAt,
signedRCAU.rcau.minSecondsPerCollection,
Expand All @@ -205,6 +213,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
agreement.maxOngoingTokensPerSecond = signedRCAU.rcau.maxOngoingTokensPerSecond;
agreement.minSecondsPerCollection = signedRCAU.rcau.minSecondsPerCollection;
agreement.maxSecondsPerCollection = signedRCAU.rcau.maxSecondsPerCollection;
agreement.updateNonce = signedRCAU.rcau.nonce;

emit AgreementUpdated(
agreement.dataService,
Expand Down Expand Up @@ -482,6 +491,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC
_rcau.maxOngoingTokensPerSecond,
_rcau.minSecondsPerCollection,
_rcau.maxSecondsPerCollection,
_rcau.nonce,
keccak256(_rcau.metadata)
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder {
return signedRCAU;
}

function generateSignedRCAUWithCorrectNonce(
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau,
uint256 signerPrivateKey
) public view returns (IRecurringCollector.SignedRCAU memory) {
// Automatically set the correct nonce based on current agreement state
IRecurringCollector.AgreementData memory agreement = collector.getAgreement(rcau.agreementId);
rcau.nonce = agreement.updateNonce + 1;

return generateSignedRCAU(rcau, signerPrivateKey);
}

function withElapsedAcceptDeadline(
IRecurringCollector.RecurringCollectionAgreement memory rca
) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) {
Expand Down
149 changes: 148 additions & 1 deletion packages/horizon/test/unit/payments/recurring-collector/update.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest {
);
rcau.agreementId = accepted.rca.agreementId;

IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce(
rcau,
signerKey
);
Expand Down Expand Up @@ -124,6 +124,8 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest {
fuzzyTestUpdate.rcau
);
rcau.agreementId = accepted.rca.agreementId;
// Don't use fuzzed nonce - use correct nonce for first update
rcau.nonce = 1;
IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
rcau,
signerKey
Expand Down Expand Up @@ -151,6 +153,151 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest {
assertEq(rcau.maxOngoingTokensPerSecond, agreement.maxOngoingTokensPerSecond);
assertEq(rcau.minSecondsPerCollection, agreement.minSecondsPerCollection);
assertEq(rcau.maxSecondsPerCollection, agreement.maxSecondsPerCollection);
assertEq(rcau.nonce, agreement.updateNonce);
}

function test_Update_Revert_WhenInvalidNonce_TooLow(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
fuzzyTestUpdate.fuzzyTestAccept
);
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU(
fuzzyTestUpdate.rcau
);
rcau.agreementId = accepted.rca.agreementId;
rcau.nonce = 0; // Invalid: should be 1 for first update

IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
rcau,
signerKey
);

bytes memory expectedErr = abi.encodeWithSelector(
IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector,
rcau.agreementId,
1, // expected
0 // provided
);
vm.expectRevert(expectedErr);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU);
}

function test_Update_Revert_WhenInvalidNonce_TooHigh(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
fuzzyTestUpdate.fuzzyTestAccept
);
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU(
fuzzyTestUpdate.rcau
);
rcau.agreementId = accepted.rca.agreementId;
rcau.nonce = 5; // Invalid: should be 1 for first update

IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU(
rcau,
signerKey
);

bytes memory expectedErr = abi.encodeWithSelector(
IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector,
rcau.agreementId,
1, // expected
5 // provided
);
vm.expectRevert(expectedErr);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU);
}

function test_Update_Revert_WhenReplayAttack(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
fuzzyTestUpdate.fuzzyTestAccept
);
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU(
fuzzyTestUpdate.rcau
);
rcau1.agreementId = accepted.rca.agreementId;
rcau1.nonce = 1;

// First update succeeds
IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU(
rcau1,
signerKey
);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU1);

// Second update with different terms and nonce 2 succeeds
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1;
rcau2.nonce = 2;
rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms

IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU(
rcau2,
signerKey
);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU2);

// Attempting to replay first update should fail
bytes memory expectedErr = abi.encodeWithSelector(
IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector,
rcau1.agreementId,
3, // expected (current nonce + 1)
1 // provided (old nonce)
);
vm.expectRevert(expectedErr);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU1);
}

function test_Update_OK_NonceIncrementsCorrectly(FuzzyTestUpdate calldata fuzzyTestUpdate) public {
(IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept(
fuzzyTestUpdate.fuzzyTestAccept
);

// Initial nonce should be 0
IRecurringCollector.AgreementData memory initialAgreement = _recurringCollector.getAgreement(
accepted.rca.agreementId
);
assertEq(initialAgreement.updateNonce, 0);

// First update with nonce 1
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU(
fuzzyTestUpdate.rcau
);
rcau1.agreementId = accepted.rca.agreementId;
rcau1.nonce = 1;

IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU(
rcau1,
signerKey
);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU1);

// Verify nonce incremented to 1
IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement(
accepted.rca.agreementId
);
assertEq(updatedAgreement1.updateNonce, 1);

// Second update with nonce 2
IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1;
rcau2.nonce = 2;
rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms

IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU(
rcau2,
signerKey
);
vm.prank(accepted.rca.dataService);
_recurringCollector.update(signedRCAU2);

// Verify nonce incremented to 2
IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement(
accepted.rca.agreementId
);
assertEq(updatedAgreement2.updateNonce, 2);
}

/* solhint-enable graph/func-name-mixedcase */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,11 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun
Context storage _ctx,
IRecurringCollector.RecurringCollectionAgreement memory _rca
) internal view returns (IRecurringCollector.SignedRCAU memory) {
return
_recurringCollectorHelper.generateSignedRCAU(
_generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca),
_ctx.payer.signerPrivateKey
);
IRecurringCollector.RecurringCollectionAgreementUpdate
memory rcau = _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca);
// Set correct nonce for first update (should be 1)
rcau.nonce = 1;
return _recurringCollectorHelper.generateSignedRCAU(rcau, _ctx.payer.signerPrivateKey);
}

function _generateAcceptableRecurringCollectionAgreementUpdate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA
IRecurringCollector.RecurringCollectionAgreementUpdate
memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca);
acceptableUpdate.metadata = bytes("invalid");
// Set correct nonce for first update (should be 1)
acceptableUpdate.nonce = 1;
IRecurringCollector.SignedRCAU memory unacceptableUpdate = _recurringCollectorHelper.generateSignedRCAU(
acceptableUpdate,
ctx.payer.signerPrivateKey
Expand Down