feat: security audit fixes

This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:17:32 +03:00
parent e87d178d71
commit 1498ed3431
31 changed files with 2198 additions and 339 deletions

View File

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