feat: security audit fixes
This commit is contained in:
@@ -6,8 +6,12 @@ import { getBalance, getTransactions } from '../services/wallet-ops.service';
|
||||
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
||||
import { signAndBroadcast } from '../services/wallet-signer.service';
|
||||
import { auditLog, auditLogStrict } from '../lib/audit-log';
|
||||
import { signAndBroadcast, signAndBroadcastRawEvm } from '../services/wallet-signer.service';
|
||||
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
||||
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
||||
import { acquireSendLock } from '../lib/send-lock';
|
||||
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
|
||||
import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
||||
@@ -83,6 +87,12 @@ export const WalletController = {
|
||||
})),
|
||||
trx,
|
||||
);
|
||||
// Дублируем ETH-адрес в users.erc20 — это поле прода-схемы
|
||||
// (custodial wallet's ETH address, доступный через простой SELECT без JOIN'а на wallets).
|
||||
const ethWallet = derived.find((w) => w.chain === 'ETH');
|
||||
if (ethWallet) {
|
||||
await UserModel.setErc20Address(userId, ethWallet.address, trx);
|
||||
}
|
||||
return derived;
|
||||
});
|
||||
|
||||
@@ -163,22 +173,35 @@ export const WalletController = {
|
||||
return;
|
||||
}
|
||||
|
||||
const mnemonic = decryptMnemonic(blob);
|
||||
|
||||
// CRITICAL operation — fail-secure audit (если запись fail'ит — не отдаём mnemonic).
|
||||
// CRITICAL operation — durable audit BEFORE decrypt (fail-secure).
|
||||
// Если INSERT fails — отказываем decrypt'у.
|
||||
let auditId: string;
|
||||
try {
|
||||
await auditLogStrict({
|
||||
auditId = await auditLogStrict({
|
||||
event: 'mnemonic.reveal',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'success',
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit log MUST succeed for mnemonic.reveal: ${auditErr.message}`);
|
||||
logger.error(`Audit DB INSERT MUST succeed for mnemonic.reveal: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
let mnemonic: string;
|
||||
try {
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
} catch (decryptErr: any) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'DECRYPT_FAILED');
|
||||
throw decryptErr;
|
||||
}
|
||||
await completeAudit(auditId, 'success');
|
||||
|
||||
// H12: no caching (BFCache / proxy / SW могут leak seed)
|
||||
res.set({
|
||||
'Cache-Control': 'no-store, no-cache, max-age=0, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
});
|
||||
res.json({ success: true, data: { mnemonic } });
|
||||
} catch (err: any) {
|
||||
logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || err.message}`);
|
||||
@@ -268,7 +291,7 @@ export const WalletController = {
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, amount, token } = req.body ?? {};
|
||||
const { to, amount, token, feeTier } = req.body ?? {};
|
||||
|
||||
if (!isValidAddress(chain, String(to))) {
|
||||
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
|
||||
@@ -288,6 +311,33 @@ export const WalletController = {
|
||||
normalizedToken = token.toUpperCase();
|
||||
}
|
||||
|
||||
let normalizedFeeTier: FeeTier | undefined;
|
||||
if (feeTier !== undefined && feeTier !== null) {
|
||||
if (feeTier !== 'slow' && feeTier !== 'normal' && feeTier !== 'fast') {
|
||||
res.status(400).json({ success: false, error: 'Invalid feeTier (must be slow|normal|fast)' });
|
||||
return;
|
||||
}
|
||||
normalizedFeeTier = feeTier;
|
||||
}
|
||||
|
||||
// C3 — idempotency. Если client передал Idempotency-Key — проверяем retry.
|
||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
||||
if (idempKey) {
|
||||
try {
|
||||
const claim = await claimIdempotency(userId, idempKey, req.body);
|
||||
if (!claim.fresh && claim.cached) {
|
||||
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// C3 — per-user-per-chain mutex против nonce race / mempool collision
|
||||
const releaseLock = await acquireSendLock(userId, chain);
|
||||
|
||||
let mnemonic: string | null = null;
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
@@ -302,37 +352,40 @@ export const WalletController = {
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
|
||||
const result = await signAndBroadcast({
|
||||
chain,
|
||||
mnemonic,
|
||||
to: String(to),
|
||||
amount: String(amount),
|
||||
token: normalizedToken,
|
||||
expectedFromAddress: wallet.address,
|
||||
});
|
||||
|
||||
// CRITICAL operation — fail-secure audit
|
||||
// CRITICAL — audit row BEFORE broadcast (fail-secure: если DB не примет — не подписываем).
|
||||
let auditId: string;
|
||||
try {
|
||||
await auditLogStrict({
|
||||
auditId = await auditLogStrict({
|
||||
event: 'wallet.send',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'success',
|
||||
meta: { chain, hasToken: !!normalizedToken, txid: result.txid },
|
||||
meta: { chain, hasToken: !!normalizedToken, to: String(to) },
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit log MUST succeed for wallet.send (txid=${result.txid}): ${auditErr.message}`);
|
||||
// Tx уже broadcast'нут — нельзя отменить. Возвращаем txid но с warning о audit.
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { txid: result.txid, chain },
|
||||
warning: 'Transaction broadcast succeeded but audit log write failed',
|
||||
});
|
||||
logger.error(`Audit DB INSERT MUST succeed for wallet.send: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
|
||||
let result: { txid: string };
|
||||
try {
|
||||
result = await signAndBroadcast({
|
||||
chain,
|
||||
mnemonic,
|
||||
to: String(to),
|
||||
amount: String(amount),
|
||||
token: normalizedToken,
|
||||
expectedFromAddress: wallet.address,
|
||||
feeTier: normalizedFeeTier,
|
||||
});
|
||||
} catch (sendErr: any) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED');
|
||||
throw sendErr;
|
||||
}
|
||||
|
||||
await completeAudit(auditId, 'success', { txid: result.txid });
|
||||
res.json({ success: true, data: { txid: result.txid, chain } });
|
||||
} catch (err: any) {
|
||||
logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||
@@ -344,14 +397,210 @@ export const WalletController = {
|
||||
meta: { chain },
|
||||
errorCode: 'BROADCAST_FAILED',
|
||||
});
|
||||
const msg = err?.message?.toLowerCase?.().includes('insufficient')
|
||||
? 'Insufficient balance'
|
||||
: err?.message?.toLowerCase?.().includes('not supported')
|
||||
? 'Token/chain combination not supported'
|
||||
: 'Failed to broadcast transaction';
|
||||
// Разделяем причины — иначе юзер видит generic "Insufficient balance" и думает что
|
||||
// дело в самом токене, хотя на самом деле не хватает native для газа.
|
||||
const lower = err?.message?.toLowerCase?.() ?? '';
|
||||
let msg: string;
|
||||
if (lower.includes('insufficient native balance for gas')) {
|
||||
msg = 'Insufficient native balance for gas (need BNB/ETH/TRX/SOL to pay tx fee, not just the token)';
|
||||
} else if (lower.includes('insufficient token balance')) {
|
||||
msg = 'Insufficient token balance';
|
||||
} else if (lower.includes('insufficient')) {
|
||||
msg = err.message; // ethers / chain-specific insufficient — pass through, чтобы юзер видел деталь
|
||||
} else if (lower.includes('not supported')) {
|
||||
msg = 'Token/chain combination not supported';
|
||||
} else {
|
||||
msg = 'Failed to broadcast transaction';
|
||||
}
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
} finally {
|
||||
mnemonic = null;
|
||||
releaseLock();
|
||||
// C3 — cache response для idempotency retry
|
||||
if (idempKey) {
|
||||
const status = res.statusCode;
|
||||
// Best-effort serialize — Express's `res.json` уже flushed body.
|
||||
// Для retry мы фиксируем только status. Body берётся из audit_log row.
|
||||
saveIdempotencyResponse(userId, idempKey, status, JSON.stringify({ cached: true, status }))
|
||||
.catch((e: any) => logger.warn(`saveIdempotencyResponse failed: ${e.message}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/wallets/:chain/gas-suggestions — slow/normal/fast tiers, парсятся из eth_feeHistory.
|
||||
* Только ETH/BSC (другие чейны не EVM).
|
||||
*/
|
||||
async getGasSuggestions(req: Request, res: Response) {
|
||||
const chain = String(req.params.chain).toUpperCase();
|
||||
if (chain !== 'ETH' && chain !== 'BSC') {
|
||||
res.status(400).json({ success: false, error: 'Gas suggestions available only for ETH/BSC' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tiers = await getEvmFeeTiers(chain);
|
||||
res.json({ success: true, data: tiers });
|
||||
} catch (err: any) {
|
||||
logger.error(`getGasSuggestions ${chain} failed: ${err.stack || err.message}`);
|
||||
res.status(502).json({ success: false, error: 'Upstream RPC error' });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/:chain/sign-raw-evm-tx — подписывает произвольную EVM-tx (для Relay/Swap unsigned tx из /execute).
|
||||
* ⚠️ chain должен быть ETH или BSC. Подписывает arbitrary `to`+`data` — в production
|
||||
* нужно whitelist'ить `to` или требовать Relay attestation.
|
||||
*/
|
||||
async signRawEvmTx(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const chain = String(req.params.chain).toUpperCase();
|
||||
|
||||
if (chain !== 'ETH' && chain !== 'BSC') {
|
||||
res.status(400).json({ success: false, error: 'Only ETH and BSC supported for raw EVM signing' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCryptoReady()) {
|
||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, data, value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas, feeTier } = req.body ?? {};
|
||||
|
||||
let normalizedFeeTier: FeeTier | undefined;
|
||||
if (feeTier !== undefined && feeTier !== null) {
|
||||
if (feeTier !== 'slow' && feeTier !== 'normal' && feeTier !== 'fast') {
|
||||
res.status(400).json({ success: false, error: 'Invalid feeTier (must be slow|normal|fast)' });
|
||||
return;
|
||||
}
|
||||
normalizedFeeTier = feeTier;
|
||||
}
|
||||
|
||||
// Базовая структурная валидация — детальные cap'ы внутри signer'а.
|
||||
if (typeof to !== 'string' || !/^0x[a-fA-F0-9]{40}$/.test(to)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid "to" address' });
|
||||
return;
|
||||
}
|
||||
if (typeof data !== 'string' || !/^0x[a-fA-F0-9]*$/.test(data)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid "data" (must be 0x-hex)' });
|
||||
return;
|
||||
}
|
||||
const numericFields = { value, chainId, gas, maxFeePerGas, maxPriorityFeePerGas };
|
||||
for (const [k, v] of Object.entries(numericFields)) {
|
||||
if (v === undefined || v === null || !/^\d+$/.test(String(v))) {
|
||||
res.status(400).json({ success: false, error: `Invalid "${k}" (must be positive integer)` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// C1, C2, H2 — sign-raw policy: `to` allowlist (Relay routers only) +
|
||||
// selector blacklist (approve/permit etc.) + value/gas caps. Throws on violation.
|
||||
try {
|
||||
applyEvmTxPolicy({
|
||||
chainId: Number(chainId),
|
||||
to,
|
||||
data,
|
||||
value: String(value),
|
||||
gas: String(gas),
|
||||
maxFeePerGas: String(maxFeePerGas),
|
||||
});
|
||||
} catch (policyErr: any) {
|
||||
res.status(400).json({ success: false, error: policyErr.message });
|
||||
return;
|
||||
}
|
||||
|
||||
// C3 — idempotency
|
||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
||||
if (idempKey) {
|
||||
try {
|
||||
const claim = await claimIdempotency(userId, idempKey, req.body);
|
||||
if (!claim.fresh && claim.cached) {
|
||||
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// C3 — per-user-per-chain mutex
|
||||
const releaseLock = await acquireSendLock(userId, chain);
|
||||
|
||||
let mnemonic: string | null = null;
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain as ChainCode);
|
||||
if (!wallet) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||
if (!blob) {
|
||||
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// CRITICAL — audit row BEFORE broadcast
|
||||
let auditId: string;
|
||||
try {
|
||||
auditId = await auditLogStrict({
|
||||
event: 'wallet.sign_raw_evm',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
meta: { chain, to, chainId: Number(chainId) },
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit DB INSERT MUST succeed for wallet.sign_raw_evm: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
|
||||
let result: { txid: string };
|
||||
try {
|
||||
result = await signAndBroadcastRawEvm({
|
||||
chain: chain as 'ETH' | 'BSC',
|
||||
mnemonic,
|
||||
expectedFromAddress: wallet.address,
|
||||
tx: {
|
||||
to,
|
||||
data,
|
||||
value: String(value),
|
||||
chainId: Number(chainId),
|
||||
gas: String(gas),
|
||||
maxFeePerGas: String(maxFeePerGas),
|
||||
maxPriorityFeePerGas: String(maxPriorityFeePerGas),
|
||||
},
|
||||
feeTier: normalizedFeeTier,
|
||||
});
|
||||
} catch (signErr: any) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'BROADCAST_FAILED');
|
||||
throw signErr;
|
||||
}
|
||||
|
||||
await completeAudit(auditId, 'success', { txid: result.txid });
|
||||
res.json({ success: true, data: { txid: result.txid, chain } });
|
||||
} catch (err: any) {
|
||||
logger.error(`signRawEvmTx failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||
await auditLog({
|
||||
event: 'wallet.sign_raw_evm',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
meta: { chain },
|
||||
errorCode: 'BROADCAST_FAILED',
|
||||
});
|
||||
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'Failed to sign/broadcast tx' });
|
||||
} finally {
|
||||
mnemonic = null;
|
||||
releaseLock();
|
||||
if (idempKey) {
|
||||
const status = res.statusCode;
|
||||
saveIdempotencyResponse(userId, idempKey, status, JSON.stringify({ cached: true, status }))
|
||||
.catch((e: any) => logger.warn(`saveIdempotencyResponse failed: ${e.message}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user