Skip to content

Commit

Permalink
Pro 2758 (#145)
Browse files Browse the repository at this point in the history
* feat: safe mode for save and delete key, kms integration

* update: readme changes

* fix: minor change for hmac sig validation

* update: adding db calls for safe mode, on save and delete key endpoints
  • Loading branch information
nikhilkumar1612 authored Oct 14, 2024
1 parent 1ad7625 commit 2d8bde9
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 42 deletions.
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'),
KMS_KEY_ID: process.env.KMS_KEY_ID ?? '',
USE_KMS: process.env.USE_KMS === 'true'
}

server.log.info(config, "config:");
Expand Down
213 changes: 175 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,92 @@ 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
}),
});

await client.send(createCommand);

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,
});
} 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 +231,100 @@ 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 });

await server.apiKeyRepository.delete(body.apiKey);
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'];
}

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);

await server.apiKeyRepository.delete(body.apiKey);
} 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;
}
Loading

0 comments on commit 2d8bde9

Please sign in to comment.