diff --git a/backend/README.md b/backend/README.md index 5284cbf..cfb99a9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/package.json b/backend/package.json index f0c38ed..05aebe2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { @@ -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", diff --git a/backend/src/constants/ErrorMessage.ts b/backend/src/constants/ErrorMessage.ts index 370e723..38f6b09 100644 --- a/backend/src/constants/ErrorMessage.ts +++ b/backend/src/constants/ErrorMessage.ts @@ -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 { diff --git a/backend/src/plugins/config.ts b/backend/src/plugins/config.ts index d13299b..71097af 100644 --- a/backend/src/plugins/config.ts +++ b/backend/src/plugins/config.ts @@ -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 }) ); @@ -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); @@ -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:"); diff --git a/backend/src/routes/admin-routes.ts b/backend/src/routes/admin-routes.ts index f7ed789..83a7528 100644 --- a/backend/src/routes/admin-routes.ts +++ b/backend/src/routes/admin-routes.ts @@ -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) { @@ -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 { @@ -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) { diff --git a/backend/src/types/auth-dto.ts b/backend/src/types/auth-dto.ts new file mode 100644 index 0000000..c173f49 --- /dev/null +++ b/backend/src/types/auth-dto.ts @@ -0,0 +1,4 @@ +export interface AuthDto { + 'x-signature': string; + 'x-timestamp': string; +} diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 297f1a8..302527f 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -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 @@ -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)'); @@ -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; +}