Security is paramount in CoinPay as we handle cryptocurrency transactions and private keys. This document outlines our security architecture, best practices, and threat mitigation strategies.
- Defense in Depth: Multiple layers of security controls
- Least Privilege: Minimal access rights for users and services
- Zero Trust: Verify every request, never assume trust
- Encryption Everywhere: Data encrypted at rest and in transit
- Audit Everything: Comprehensive logging and monitoring
- Private keys for forwarding addresses
- Merchant credentials and API keys
- Customer payment information
- Platform fee wallet private keys
- Database access credentials
- Private key theft or exposure
- Unauthorized access to merchant accounts
- Man-in-the-middle attacks
- SQL injection and XSS attacks
- Replay attacks on blockchain transactions
- Webhook spoofing
- DDoS attacks
// HD Wallet generation using BIP39/BIP44
import * as bip39 from 'bip39';
import { HDKey } from '@scure/bip32';
// Generate mnemonic (store securely, never in database)
const mnemonic = bip39.generateMnemonic(256); // 24 words
// Derive keys for each blockchain
const seed = await bip39.mnemonicToSeed(mnemonic);
const hdkey = HDKey.fromMasterSeed(seed);
// Bitcoin: m/44'/0'/0'/0/index
// Ethereum: m/44'/60'/0'/0/index
// Solana: m/44'/501'/0'/0/index- Encryption: AES-256-GCM with authenticated encryption
- Key Derivation: PBKDF2 with 100,000 iterations
- Salt: Unique per key, stored with ciphertext
- Master Key: Stored in environment variable, never in database
import crypto from 'crypto';
function encryptPrivateKey(privateKey: string, masterKey: string): string {
const salt = crypto.randomBytes(16);
const iv = crypto.randomBytes(12);
// Derive encryption key from master key
const key = crypto.pbkdf2Sync(masterKey, salt, 100000, 32, 'sha256');
// Encrypt with AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(privateKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Return: salt:iv:authTag:ciphertext
return `${salt.toString('hex')}:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
function decryptPrivateKey(encryptedKey: string, masterKey: string): string {
const [saltHex, ivHex, authTagHex, encrypted] = encryptedKey.split(':');
const salt = Buffer.from(saltHex, 'hex');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
// Derive decryption key
const key = crypto.pbkdf2Sync(masterKey, salt, 100000, 32, 'sha256');
// Decrypt
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}- Rotate master encryption key every 90 days
- Re-encrypt all private keys with new master key
- Keep old key for 30 days for recovery
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}- Minimum 12 characters
- Must include: uppercase, lowercase, number, special character
- Cannot contain email or username
- Check against common password lists
- Enforce password history (last 5 passwords)
import jwt from 'jsonwebtoken';
interface TokenPayload {
merchantId: string;
email: string;
iat: number;
exp: number;
}
function generateToken(merchantId: string, email: string): string {
return jwt.sign(
{ merchantId, email },
process.env.JWT_SECRET!,
{
expiresIn: '24h',
issuer: 'coinpayportal.com',
audience: 'coinpayportal-api'
}
);
}
function verifyToken(token: string): TokenPayload {
return jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'coinpayportal.com',
audience: 'coinpayportal-api'
}) as TokenPayload;
}// Per IP address
const ipRateLimit = {
windowMs: 60 * 1000, // 1 minute
max: 100 // 100 requests per minute
};
// Per authenticated user
const userRateLimit = {
windowMs: 60 * 60 * 1000, // 1 hour
max: 1000 // 1000 requests per hour
};
// Sensitive endpoints (login, register)
const authRateLimit = {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5 // 5 attempts per 15 minutes
};const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
};import { z } from 'zod';
const createPaymentSchema = z.object({
businessId: z.string().uuid(),
amount: z.number().positive().max(1000000),
currency: z.enum(['USD', 'EUR', 'GBP']),
blockchain: z.enum(['btc', 'bch', 'eth', 'matic', 'sol', 'usdc_eth', 'usdc_matic', 'usdc_sol']),
merchantWalletAddress: z.string().regex(/^(0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|[a-zA-Z0-9]{32,44})$/),
metadata: z.record(z.any()).optional()
});
// Validate request
const validatedData = createPaymentSchema.parse(requestBody);Using Supabase with parameterized queries:
// GOOD: Parameterized query
const { data, error } = await supabase
.from('payments')
.select('*')
.eq('business_id', businessId)
.eq('status', status);
// BAD: String concatenation (NEVER DO THIS)
// const query = `SELECT * FROM payments WHERE business_id = '${businessId}'`;import DOMPurify from 'isomorphic-dompurify';
function sanitizeInput(input: string): string {
return DOMPurify.sanitize(input, {
ALLOWED_TAGS: [], // No HTML tags allowed
ALLOWED_ATTR: []
});
}
// Sanitize all user inputs
const sanitizedName = sanitizeInput(req.body.name);// Prevent replay attacks by tracking nonces
class NonceManager {
private nonces: Map<string, number> = new Map();
async getNextNonce(address: string): Promise<number> {
const currentNonce = await provider.getTransactionCount(address, 'pending');
const cachedNonce = this.nonces.get(address) || 0;
const nonce = Math.max(currentNonce, cachedNonce);
this.nonces.set(address, nonce + 1);
return nonce;
}
}const MAX_GAS_PRICE = ethers.parseUnits('500', 'gwei'); // 500 gwei max
async function sendTransaction(tx: Transaction) {
const gasPrice = await provider.getFeeData();
if (gasPrice.gasPrice && gasPrice.gasPrice > MAX_GAS_PRICE) {
throw new Error('Gas price too high, transaction aborted');
}
return wallet.sendTransaction(tx);
}const CONFIRMATION_REQUIREMENTS = {
btc: 3,
bch: 6,
eth: 12,
pol: 128,
sol: 32
};
async function waitForConfirmations(
txHash: string,
blockchain: string
): Promise<boolean> {
const required = CONFIRMATION_REQUIREMENTS[blockchain];
let confirmations = 0;
while (confirmations < required) {
const receipt = await provider.getTransactionReceipt(txHash);
if (!receipt) {
await sleep(5000);
continue;
}
const currentBlock = await provider.getBlockNumber();
confirmations = currentBlock - receipt.blockNumber + 1;
if (confirmations < required) {
await sleep(10000);
}
}
return true;
}import { isAddress } from 'ethers';
import * as bitcoin from 'bitcoinjs-lib';
import bs58check from 'bs58check';
function validateAddress(address: string, blockchain: string): boolean {
switch (blockchain) {
case 'eth':
case 'matic':
case 'usdc_eth':
case 'usdc_matic':
return isAddress(address);
case 'btc':
case 'bch':
try {
bitcoin.address.toOutputScript(address);
return true;
} catch {
return false;
}
case 'sol':
case 'usdc_sol':
try {
const decoded = bs58check.decode(address);
return decoded.length === 32;
} catch {
return false;
}
default:
return false;
}
}import crypto from 'crypto';
function generateWebhookSignature(
payload: object,
secret: string
): string {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(JSON.stringify(payload));
return hmac.digest('hex');
}
function verifyWebhookSignature(
payload: object,
signature: string,
secret: string
): boolean {
const expectedSignature = generateWebhookSignature(payload, secret);
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}async function deliverWebhook(
url: string,
payload: object,
secret: string
): Promise<void> {
const signature = generateWebhookSignature(payload, secret);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CoinPay-Signature': signature,
'X-CoinPay-Timestamp': Date.now().toString(),
'User-Agent': 'CoinPay-Webhook/1.0'
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
throw new Error(`Webhook delivery failed: ${response.status}`);
}
}-- Enable RLS on all tables
ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
ALTER TABLE businesses ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
-- Merchants can only see their own data
CREATE POLICY "Merchants view own data"
ON merchants FOR SELECT
USING (auth.uid() = id);
-- Merchants can only access their own businesses
CREATE POLICY "Merchants view own businesses"
ON businesses FOR SELECT
USING (merchant_id = auth.uid());
-- Merchants can only see payments for their businesses
CREATE POLICY "Merchants view own payments"
ON payments FOR SELECT
USING (
business_id IN (
SELECT id FROM businesses WHERE merchant_id = auth.uid()
)
);// Use connection pooling
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
db: {
schema: 'public'
},
auth: {
autoRefreshToken: true,
persistSession: false
},
global: {
headers: {
'x-application-name': 'coinpayportal'
}
}
}
);# .env.example (NEVER commit actual .env)
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Encryption (Generate with: openssl rand -hex 32)
ENCRYPTION_KEY=your-32-byte-hex-key
# JWT (Generate with: openssl rand -base64 64)
JWT_SECRET=your-jwt-secret
# RPC Providers
BITCOIN_RPC_URL=https://your-bitcoin-rpc
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
# Platform Fee Wallets (NEVER commit these)
PLATFORM_FEE_WALLET_BTC=your-btc-address
PLATFORM_FEE_WALLET_ETH=your-eth-address
PLATFORM_FEE_WALLET_POL=your-pol-address
PLATFORM_FEE_WALLET_SOL=your-sol-address
# Tatum API
TATUM_API_KEY=your-tatum-api-key
# Webhook
WEBHOOK_SIGNING_SECRET=your-webhook-secret
# CORS
ALLOWED_ORIGINS=https://yourdomain.com,https://app.yourdomain.comFor production, use a secret management service:
// AWS Secrets Manager example
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
async function getSecret(secretName: string): Promise<string> {
const client = new SecretsManagerClient({ region: 'us-east-1' });
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
);
return response.SecretString!;
}
// Use in production
const encryptionKey = process.env.NODE_ENV === 'production'
? await getSecret('coinpayportal/encryption-key')
: process.env.ENCRYPTION_KEY!;// Log security events
interface SecurityEvent {
type: 'auth_failure' | 'rate_limit' | 'suspicious_activity' | 'key_access';
severity: 'low' | 'medium' | 'high' | 'critical';
merchantId?: string;
ip: string;
details: object;
timestamp: Date;
}
async function logSecurityEvent(event: SecurityEvent): Promise<void> {
await supabase.from('security_logs').insert(event);
if (event.severity === 'critical') {
// Alert security team
await sendAlert(event);
}
}- Detection: Automated monitoring alerts
- Containment: Disable affected accounts/keys
- Investigation: Review logs and determine scope
- Eradication: Remove threat and patch vulnerabilities
- Recovery: Restore services and rotate credentials
- Post-Incident: Document and improve processes
- All secrets in environment variables
- Input validation on all endpoints
- Parameterized database queries
- HTTPS only in production
- CORS properly configured
- Rate limiting implemented
- Error messages don't leak sensitive info
- Environment variables secured
- Database backups enabled
- SSL/TLS certificates valid
- Firewall rules configured
- Monitoring and alerting active
- Incident response plan documented
- Regular security audits
- Dependency updates automated
- Key rotation schedule followed
- Access logs reviewed
- Penetration testing performed
- Security training completed
- GDPR compliance for EU customers
- PCI DSS not required (non-custodial)
- SOC 2 Type II certification (future)
- All payment state changes logged
- API access logged with timestamps
- Failed authentication attempts tracked
- Key access logged and monitored
- Security Issues: security@coinpayportal.com
- Bug Bounty: https://coinpayportal.com/security/bounty
- PGP Key: Available at https://coinpayportal.com/security/pgp