Skip to content
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

Add back signed data validations #176

Merged
merged 1 commit into from
Dec 31, 2023
Merged
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
33 changes: 22 additions & 11 deletions packages/api/src/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { ethers } from 'ethers';
import { omit } from 'lodash';

import { getMockedConfig } from '../test/fixtures';
import { createSignedData, deriveBeaconId, generateRandomBytes, generateRandomWallet } from '../test/utils';
import { createSignedData, generateRandomBytes, generateRandomWallet } from '../test/utils';

import * as cacheModule from './cache';
import * as configModule from './config';
import { batchInsertData, getData, listAirnodeAddresses } from './handlers';
import { logger } from './logger';
import { deriveBeaconId } from './utils';

// eslint-disable-next-line jest/no-hooks
beforeEach(() => {
Expand All @@ -19,38 +20,48 @@ afterEach(() => {
});

describe(batchInsertData.name, () => {
it('does not validate signature (for performance reasons)', async () => {
const airnodeWallet = generateRandomWallet();
const invalidData = await createSignedData({ airnodeWallet, signature: '0xInvalid' });
const batchData = [await createSignedData({ airnodeWallet }), invalidData];
it('validates signature', async () => {
const invalidData = await createSignedData({ signature: '0xInvalid' });

const result = await batchInsertData(undefined, batchData);
const result = await batchInsertData(undefined, [invalidData]);

expect(result).toStrictEqual({
body: JSON.stringify({ count: 2, skipped: 0 }),
body: JSON.stringify({
message: 'Unable to recover signer address',
context: {
detail:
'signature missing v and recoveryParam (argument="signature", value="0xInvalid", code=INVALID_ARGUMENT, version=bytes/5.7.0)',
signedData: invalidData,
},
}),
headers: {
'access-control-allow-methods': '*',
'access-control-allow-origin': '*',
'content-type': 'application/json',
},
statusCode: 201,
statusCode: 400,
});
});

it('does not validate beacon ID (for performance reasons)', async () => {
it('validates beacon ID', async () => {
const data = await createSignedData();
const invalidData = { ...data, beaconId: deriveBeaconId(data.airnode, generateRandomBytes(32)) };

const result = await batchInsertData(undefined, [invalidData]);

expect(result).toStrictEqual({
body: JSON.stringify({ count: 1, skipped: 0 }),
body: JSON.stringify({
message: 'beaconId is invalid',
context: {
signedData: invalidData,
},
}),
headers: {
'access-control-allow-methods': '*',
'access-control-allow-origin': '*',
'content-type': 'application/json',
},
statusCode: 201,
statusCode: 400,
});
});

Expand Down
38 changes: 33 additions & 5 deletions packages/api/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { go } from '@api3/promise-utils';
import { go, goSync } from '@api3/promise-utils';
import { isEmpty, isNil, omit } from 'lodash';

import { getConfig } from './config';
Expand All @@ -7,7 +7,13 @@ import { get, getAll, getAllAirnodeAddresses, prune, putAll } from './in-memory-
import { logger } from './logger';
import { type SignedData, batchSignedDataSchema, evmAddressSchema, type Endpoint } from './schema';
import type { ApiResponse } from './types';
import { extractBearerToken, generateErrorResponse, isBatchUnique } from './utils';
import {
deriveBeaconId,
extractBearerToken,
generateErrorResponse,
isBatchUnique,
recoverSignerAddress,
} from './utils';

// Accepts a batch of signed data that is first validated for consistency and data integrity errors. If there is any
// issue during this step, the whole batch is rejected.
Expand Down Expand Up @@ -59,15 +65,37 @@ export const batchInsertData = async (
// Check whether any duplications exist
if (!isBatchUnique(batchSignedData)) return generateErrorResponse(400, 'No duplications are allowed');

// Ensure the signed data timestamp does not drift too far into the future.
//
// NOTE: We are intentionally not validating the signature or beacon ID for performance reasons.
// Ensure the signed data is valid and timestamp does not drift too far into the future.
for (const signedData of batchSignedData) {
// The on-chain contract prevents time drift by making sure the timestamp is at most 1 hour in the future. System
// time drift is less common, but we mirror the contract implementation.
if (Number.parseInt(signedData.timestamp, 10) > Math.floor(Date.now() / 1000) + 60 * 60) {
return generateErrorResponse(400, 'Request timestamp is too far in the future', { signedData });
}

const goRecoverSigner = goSync(() => recoverSignerAddress(signedData));
if (!goRecoverSigner.success) {
return generateErrorResponse(400, 'Unable to recover signer address', {
detail: goRecoverSigner.error.message,
signedData,
});
}

if (signedData.airnode !== goRecoverSigner.data) {
return generateErrorResponse(400, 'Signature is invalid', { signedData });
}

const goDeriveBeaconId = goSync(() => deriveBeaconId(signedData.airnode, signedData.templateId));
if (!goDeriveBeaconId.success) {
return generateErrorResponse(400, 'Unable to derive beaconId by given airnode and templateId', {
detail: goDeriveBeaconId.error.message,
signedData,
});
}

if (signedData.beaconId !== goDeriveBeaconId.data) {
return generateErrorResponse(400, 'beaconId is invalid', { signedData });
}
}

const newSignedData: SignedData[] = [];
Expand Down
19 changes: 19 additions & 0 deletions packages/api/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ethers } from 'ethers';

import { createResponseHeaders } from './headers';
import type { BatchSignedData, SignedData } from './schema';
import type { ApiResponse } from './types';
Expand Down Expand Up @@ -32,3 +34,20 @@ export const extractBearerToken = (authorizationHeader: string | undefined) => {

return token;
};

export const decodeData = (data: string) => ethers.utils.defaultAbiCoder.decode(['int256'], data);

const packAndHashWithTemplateId = (templateId: string, timestamp: string, data: string) =>
ethers.utils.arrayify(
ethers.utils.keccak256(
ethers.utils.solidityPack(['bytes32', 'uint256', 'bytes'], [templateId, timestamp, data || '0x'])
)
);

export const deriveBeaconId = (airnode: string, templateId: string) =>
ethers.utils.keccak256(ethers.utils.solidityPack(['address', 'bytes32'], [airnode, templateId]));

export const recoverSignerAddress = (data: SignedData): string => {
const digest = packAndHashWithTemplateId(data.templateId, data.timestamp, data.encodedValue);
return ethers.utils.verifyMessage(digest, data.signature);
};
4 changes: 1 addition & 3 deletions packages/api/test/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { ethers } from 'ethers';

import type { SignedData } from '../src/schema';

export const deriveBeaconId = (airnode: string, templateId: string) =>
ethers.utils.keccak256(ethers.utils.solidityPack(['address', 'bytes32'], [airnode, templateId]));
import { deriveBeaconId } from '../src/utils';

export const deriveTemplateId = (endpointId: string, encodedParameters: string) =>
ethers.utils.keccak256(ethers.utils.solidityPack(['bytes32', 'bytes'], [endpointId, encodedParameters]));
Expand Down