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

Pro 2758 #145

Merged
merged 4 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
17 changes: 17 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ Parameters:

- `/getAllWhitelist/v2` - This url accepts optionally one parameter and returns all the addresses which are whitelisted for the apiKey/policyId.
1. policyId - Optional policy id.

- `/saveKey` - This url is used to save a new api key. This url uses content type as `text/plain`.
1. apiKey - The new api key to be created.
2. supportedNetworks - Base64 encoded string which follows config.json.default structure.
3. erc20Paymasters - Base64 encoded string which represents the list of custom ERC20 paymasters.
4. multiTokenPaymasters - Base64 encoded string which represents the list of custom multiToken paymasters.
5. multiTokenOracles - Base64 encoded string which represents the list of custom multiToken oracles.
6. sponsorName - Name of the sponsorer.
7. logoUrl - Url of the logo.
8. transactionLimit - Limit for number of transactions.
9. noOfTransactionsInAMonth - Number of transaction allowed in a month.
10. indexerEndpoint - Endpoint for indexer, defaults to DEFAULT_INDEXER_ENDPOINT environment property.



- `/deleteKey` - This url accepts one parameter and deletes the api key record. This url uses content type as `text/plain`.
1. apiKey - The api key to be deleted.

## Local Docker Networks

Expand Down
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arka",
"version": "1.6.4",
"version": "1.6.5",
"description": "ARKA - (Albanian for Cashier's case) is the first open source Paymaster as a service software",
"type": "module",
"directories": {
Expand Down Expand Up @@ -29,6 +29,7 @@
"dependencies": {
"@account-abstraction/contracts": "0.6.0",
"@account-abstraction/utils": "0.5.0",
"@aws-crypto/client-node": "^4.0.1",
"@aws-sdk/client-secrets-manager": "3.450.0",
"@fastify/cors": "8.4.1",
"@ponder/core": "0.2.7",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/constants/ErrorMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export default {
FAILED_TO_DELETE_CONTRACT_WHITELIST: 'Failed to delete the record on contract whitelist',
NO_CONTRACT_WHITELIST_FOUND: 'No contract whitelist found for the given chainId, apiKey and contractAddress passed',
RECORD_ALREADY_EXISTS_CONTRACT_WHITELIST: 'Record already exists for the chainId, apiKey and contractAddress passed',
BALANCE_EXCEEDS_THRESHOLD: 'Balance exceeds threshold to delete key',
INVALID_SIGNATURE_OR_TIMESTAMP: 'Invalid signature or timestamp',
}

export function generateErrorMessage(template: string, values: { [key: string]: string | number }): string {
Expand Down
15 changes: 12 additions & 3 deletions backend/src/plugins/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ const ConfigSchema = Type.Strict(
EP7_TOKEN_VGL: Type.String() || '90000',
EP7_TOKEN_PGL: Type.String() || '150000',
EPV_06: Type.Array(Type.String()) || ['0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'],
EPV_07: Type.Array(Type.String()) || ['0x0000000071727De22E5E9d8BAf0edAc6f37da032']
EPV_07: Type.Array(Type.String()) || ['0x0000000071727De22E5E9d8BAf0edAc6f37da032'],
DELETE_KEY_RECOVER_WINDOW: Type.Number(),
KMS_KEY_ID: Type.String() || undefined,
USE_KMS: Type.Boolean() || false
})
);

Expand Down Expand Up @@ -61,7 +64,10 @@ const configPlugin: FastifyPluginAsync = async (server) => {
EP7_TOKEN_VGL: process.env.EP7_TOKEN_VGL,
EP7_TOKEN_PGL: process.env.EP7_TOKEN_PGL,
EPV_06: process.env.EPV_06?.split(','),
EPV_07: process.env.EPV_07?.split(',')
EPV_07: process.env.EPV_07?.split(','),
DELETE_KEY_RECOVER_WINDOW: process.env.DELETE_KEY_RECOVER_WINDOW,
KMS_KEY_ID: process.env.KMS_KEY_ID,
USE_KMS: process.env.USE_KMS,
}

const valid = validate(envVar);
Expand Down Expand Up @@ -90,7 +96,10 @@ const configPlugin: FastifyPluginAsync = async (server) => {
EP7_TOKEN_VGL: process.env.EP7_TOKEN_VGL ?? '90000',
EP7_TOKEN_PGL: process.env.EP7_TOKEN_PGL ?? '150000',
EPV_06: process.env.EPV_06?.split(',') ?? ['0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789'],
EPV_07: process.env.EPV_07?.split(',') ?? ['0x0000000071727De22E5E9d8BAf0edAc6f37da032']
EPV_07: process.env.EPV_07?.split(',') ?? ['0x0000000071727De22E5E9d8BAf0edAc6f37da032'],
DELETE_KEY_RECOVER_WINDOW: parseInt(process.env.DELETE_KEY_RECOVER_WINDOW || '7'),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ch4r10t33r using 7 days because only 7-30 days values are allowed for scheduled deletion.

KMS_KEY_ID: process.env.KMS_KEY_ID ?? '',
USE_KMS: process.env.USE_KMS === 'true'
}

server.log.info(config, "config:");
Expand Down
195 changes: 157 additions & 38 deletions backend/src/routes/admin-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,29 @@ import { CronTime } from 'cron';
import { ethers } from "ethers";
import ErrorMessage from "../constants/ErrorMessage.js";
import ReturnCode from "../constants/ReturnCode.js";
import { encode, decode } from "../utils/crypto.js";
import { encode, decode, verifySignature } from "../utils/crypto.js";
import SupportedNetworks from "../../config.json" assert { type: "json" };
import { APIKey } from "../models/api-key.js";
import { ArkaConfigUpdateData } from "../types/arka-config-dto.js";
import { ApiKeyDto } from "../types/apikey-dto.js";
import { CreateSecretCommand, DeleteSecretCommand, GetSecretValueCommand, SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import EtherspotAbi from "../abi/EtherspotAbi.js";
import { AuthDto } from "../types/auth-dto.js";
import { IncomingHttpHeaders } from "http";

const adminRoutes: FastifyPluginAsync = async (server) => {

const prefixSecretId = 'arka_';

let client: SecretsManagerClient;

const unsafeMode: boolean = process.env.UNSAFE_MODE == "true" ? true : false;

if (!unsafeMode) {
client = new SecretsManagerClient();
}


server.post('/adminLogin', async function (request, reply) {
try {
if(!server.config.UNSAFE_MODE) {
Expand Down Expand Up @@ -76,50 +92,76 @@ const adminRoutes: FastifyPluginAsync = async (server) => {

server.post('/saveKey', async function (request, reply) {
try {
if(!server.config.UNSAFE_MODE) {
return reply.code(ReturnCode.NOT_AUTHORIZED).send({ error: ErrorMessage.NOT_AUTHORIZED });
}
const body: any = JSON.parse(request.body as string) as ApiKeyDto;
const body = JSON.parse(request.body as string) as ApiKeyDto;
if (!body) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.EMPTY_BODY });
if (!body.apiKey || !body.privateKey)
if (!body.apiKey)
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_DATA });

if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*-_&])[A-Za-z\d@$!%*-_&]{8,}$/.test(body.apiKey))
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.API_KEY_VALIDATION_FAILED })
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.API_KEY_VALIDATION_FAILED });

const wallet = new ethers.Wallet(body.privateKey);
const mnemonic = ethers.utils.entropyToMnemonic(
ethers.utils.randomBytes(16)
);
const wallet = ethers.Wallet.fromMnemonic(mnemonic);
const privateKey = wallet.privateKey;
const publicAddress = await wallet.getAddress();

// Use Sequelize to find the API key
const result = await server.apiKeyRepository.findOneByWalletAddress(publicAddress);
if(!unsafeMode) {
const { 'x-signature': signature, 'x-timestamp': timestamp } = request.headers as IncomingHttpHeaders & AuthDto;
if(!signature || !timestamp)
return reply.code(ReturnCode.NOT_AUTHORIZED).send({ error: ErrorMessage.INVALID_SIGNATURE_OR_TIMESTAMP });
if(!verifySignature(signature, request.body as string, timestamp, server.config.HMAC_SECRET))
return reply.code(ReturnCode.NOT_AUTHORIZED).send({ error: ErrorMessage.INVALID_SIGNATURE_OR_TIMESTAMP });

if (result) {
request.log.error('Duplicate record found');
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.DUPLICATE_RECORD });
}
const command = new GetSecretValueCommand({SecretId: prefixSecretId + body.apiKey})
const secrets = await client.send(command).catch((err) => err);

await server.apiKeyRepository.create({
apiKey: body.apiKey,
walletAddress: publicAddress,
privateKey: encode(body.privateKey, server.config.HMAC_SECRET),
supportedNetworks: body.supportedNetworks,
erc20Paymasters: body.erc20Paymasters,
multiTokenPaymasters: body.multiTokenPaymasters ?? null,
multiTokenOracles: body.multiTokenOracles ?? null,
sponsorName: body.sponsorName ?? null,
logoUrl: body.logoUrl ?? null,
transactionLimit: body.transactionLimit ?? 0,
noOfTransactionsInAMonth: body.noOfTransactionsInAMonth ?? 10,
indexerEndpoint: body.indexerEndpoint ?? process.env.DEFAULT_INDEXER_ENDPOINT,
bundlerApiKey: body.bundlerApiKey ?? null,
});
if(!(secrets instanceof Error)) {
request.log.error('Duplicate record found');
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.DUPLICATE_RECORD });
}

const createCommand = new CreateSecretCommand({
Name: prefixSecretId + body.apiKey,
SecretString: JSON.stringify({
PRIVATE_KEY: privateKey,
PUBLIC_ADDRESS: publicAddress,
MNEMONIC: mnemonic
}),
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to also create a record on the connected db since the sponsorship olivies are handled by that so even in unsafeMode as false create one record on was secrets as above and also include database record creation for the same which also includes addition of policy record by default for the same apiKey

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vignesha22 does this mean we have to delete the record also on /deleteKey endpoint ?

await client.send(createCommand);
} else {
const result = await server.apiKeyRepository.findOneByApiKey(body.apiKey);
if (result) {
request.log.error('Duplicate record found');
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.DUPLICATE_RECORD });
}

await server.apiKeyRepository.create({
apiKey: body.apiKey,
walletAddress: publicAddress,
privateKey: encode(privateKey, server.config.HMAC_SECRET),
supportedNetworks: body.supportedNetworks,
erc20Paymasters: body.erc20Paymasters,
multiTokenPaymasters: body.multiTokenPaymasters ?? null,
multiTokenOracles: body.multiTokenOracles ?? null,
sponsorName: body.sponsorName ?? null,
logoUrl: body.logoUrl ?? null,
transactionLimit: body.transactionLimit ?? 0,
noOfTransactionsInAMonth: body.noOfTransactionsInAMonth ?? 10,
indexerEndpoint: body.indexerEndpoint ?? process.env.DEFAULT_INDEXER_ENDPOINT ?? null,
bundlerApiKey: body.bundlerApiKey ?? null,
});
}

return reply.code(ReturnCode.SUCCESS).send({ error: null, message: 'Successfully saved' });
} catch (err: any) {
request.log.error(err);
return reply.code(ReturnCode.FAILURE).send({ error: err.message ?? ErrorMessage.FAILED_TO_PROCESS });
}
})
});

server.post('/updateKey', async function (request, reply) {
try {
Expand Down Expand Up @@ -173,21 +215,98 @@ const adminRoutes: FastifyPluginAsync = async (server) => {

server.post('/deleteKey', async function (request, reply) {
try {
if(!server.config.UNSAFE_MODE) {
return reply.code(ReturnCode.NOT_AUTHORIZED).send({ error: ErrorMessage.NOT_AUTHORIZED });
}
const body: any = JSON.parse(request.body as string);
if (!body) return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.EMPTY_BODY });
if (!body)
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.EMPTY_BODY });
if (!body.apiKey)
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_DATA });
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*-_&])[A-Za-z\d@$!%*-_&]{8,}$/.test(body.apiKey))
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.API_KEY_VALIDATION_FAILED });

const apiKeyInstance = await server.apiKeyRepository.findOneByApiKey(body.apiKey);
if (!apiKeyInstance)
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.RECORD_NOT_FOUND });
let bundlerApiKey = body.apiKey;
let supportedNetworks;
if(!unsafeMode) {
const { 'x-signature': signature, 'x-timestamp': timestamp } = request.headers as IncomingHttpHeaders & AuthDto;
if(!signature || !timestamp || isNaN(+timestamp))
return reply.code(ReturnCode.NOT_AUTHORIZED).send({ error: ErrorMessage.INVALID_SIGNATURE_OR_TIMESTAMP });
if(!verifySignature(signature, request.body as string, timestamp, server.config.HMAC_SECRET))
return reply.code(ReturnCode.NOT_AUTHORIZED).send({ error: ErrorMessage.INVALID_SIGNATURE_OR_TIMESTAMP });
const getSecretCommand = new GetSecretValueCommand({SecretId: prefixSecretId + body.apiKey});
const secretValue = await client.send(getSecretCommand)
if(secretValue instanceof Error)
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.RECORD_NOT_FOUND });

const secrets = JSON.parse(secretValue.SecretString ?? '{}');

if (!secrets['PRIVATE_KEY']) {
server.log.info("Invalid Api Key provided")
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.INVALID_API_KEY })
}
if (secrets['BUNDLER_API_KEY']) {
bundlerApiKey = secrets['BUNDLER_API_KEY'];
}

await server.apiKeyRepository.delete(body.apiKey);
if(secrets['SUPPORTED_NETWORKS']) {
const buffer = Buffer.from(secrets['SUPPORTED_NETWORKS'], 'base64');
supportedNetworks = JSON.parse(buffer.toString())
}
supportedNetworks = supportedNetworks ?? SupportedNetworks;

const privateKey = secrets['PRIVATE_KEY'];

// native balance check.
const nativeBalancePromiseArr = [];
const nativePaymasterDepositPromiseArr = [];
for(const network of supportedNetworks) {
const provider = new ethers.providers.JsonRpcProvider(network.bundler + '?api-key=' + bundlerApiKey);
const wallet = new ethers.Wallet(privateKey, provider)
nativeBalancePromiseArr.push(wallet.getBalance());

const contract = new ethers.Contract(
network.contracts.etherspotPaymasterAddress,
EtherspotAbi,
wallet
);
nativePaymasterDepositPromiseArr.push(contract.getSponsorBalance(wallet.address));
}

let error = false;

await Promise.allSettled([...nativeBalancePromiseArr, ...nativePaymasterDepositPromiseArr]).then((data) => {
const threshold = ethers.utils.parseEther('0.0001');
for(const item of data) {
if(
item.status === 'fulfilled' &&
item.value?.gt(threshold)
) {
error = true;
return;
}
if(item.status === 'rejected') {
request.log.error(
`Error occurred while fetching balance/sponsor balance for apiKey: ${body.apiKey}, reason: ${JSON.stringify(item.reason)}`
);
}
}
});

if(error) {
return reply.code(400).send({error: ErrorMessage.BALANCE_EXCEEDS_THRESHOLD });
}

const deleteCommand = new DeleteSecretCommand({
SecretId: prefixSecretId + body.apiKey,
RecoveryWindowInDays: server.config.DELETE_KEY_RECOVER_WINDOW,
});

await client.send(deleteCommand);
} else {
const apiKeyInstance = await server.apiKeyRepository.findOneByApiKey(body.apiKey);
if (!apiKeyInstance)
return reply.code(ReturnCode.FAILURE).send({ error: ErrorMessage.RECORD_NOT_FOUND });

await server.apiKeyRepository.delete(body.apiKey);
}

return reply.code(ReturnCode.SUCCESS).send({ error: null, message: 'Successfully deleted' });
} catch (err: any) {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/types/auth-dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface AuthDto {
'x-signature': string;
'x-timestamp': string;
}
49 changes: 49 additions & 0 deletions backend/src/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import crypto, { BinaryToTextEncoding } from 'crypto';
import { KmsKeyringNode, buildClient, CommitmentPolicy } from '@aws-crypto/client-node';

function createDigest(encodedData: string, format: BinaryToTextEncoding, hmacSecret: string) {
return crypto
Expand All @@ -13,6 +14,19 @@ export function encode(sourceData: string, hmacSecret: string) {
return `${encodedData}!${createDigest(encodedData, 'base64', hmacSecret)}`;
}

export async function encodeSafe(sourceData: string, hmacSecret: string) {
if(process.env.USE_KMS === 'false') {
const json = JSON.stringify(sourceData);
const encodedData = Buffer.from(json).toString('base64');
return `${encodedData}!${createDigest(encodedData, 'base64', hmacSecret)}`;
} else {
const client = new KmsKeyringNode({generatorKeyId: process.env.KMS_KEY_ID});
const buildEncrypt = buildClient(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT).encrypt;
const { result } = await buildEncrypt(client, sourceData);
return result.toString('base64');
}
}

export function decode(value: string, hmacSecret: string) {
const [encodedData, sourceDigest] = value.split('!');
if (!encodedData || !sourceDigest) throw new Error('invalid value(s)');
Expand All @@ -26,3 +40,38 @@ export function decode(value: string, hmacSecret: string) {
if (!digestsEqual) throw new Error('invalid value(s)');
return decodedData;
}

export async function decodeSafe(value: string, hmacSecret: string) {
if(!process.env.USE_KMS) {
const [encodedData, sourceDigest] = value.split('!');
if (!encodedData || !sourceDigest) throw new Error('invalid value(s)');
const json = Buffer.from(encodedData, 'base64').toString('utf8');
const decodedData = JSON.parse(json);
const checkDigest = crypto.createHmac('sha256', hmacSecret).update(encodedData).digest();
const digestsEqual = crypto.timingSafeEqual(
Buffer.from(sourceDigest, 'base64'),
checkDigest
);
if (!digestsEqual) throw new Error('invalid value(s)');
return decodedData;
} else {
const client = new KmsKeyringNode({generatorKeyId: process.env.KMS_KEY_ID});
const buildDecrypt = buildClient(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT).decrypt;
const { plaintext } = await buildDecrypt(client, Buffer.from(value, 'base64'));
return plaintext.toString('utf8');
}
}

export function verifySignature(signature: string, data: string, timestamp: string, hmacSecret: string) {
// unauthorize signature if signed before 10s or signed in future.
const now = Date.now();
if(
now < parseInt(timestamp) ||
now - parseInt(timestamp) > 10000
) {
return false;
}
const computedSignature = createDigest(data + timestamp, 'hex', hmacSecret);

return signature === computedSignature;
}
Loading