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

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

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
Loading