security: remove .env from tracking (contains secrets)

This commit is contained in:
ZOMBIIIIIII
2026-05-11 18:15:21 +03:00
parent 295c3a9d6d
commit 64696b334c
26 changed files with 1840 additions and 128 deletions

View File

@@ -11,10 +11,14 @@
},
"dependencies": {
"@solana/web3.js": "^1.98.4",
"bip32": "^4.0.0",
"bip39": "^3.1.0",
"bitcoinjs-lib": "^6.1.5",
"bs58": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"ed25519-hd-key": "^1.3.0",
"ethers": "5.7.2",
"express": "^4.21.0",
"express-rate-limit": "^8.4.1",
@@ -23,6 +27,7 @@
"knex": "^3.1.0",
"pg": "^8.13.0",
"swagger-ui-express": "^5.0.1",
"tiny-secp256k1": "^2.2.3",
"ulidx": "^2.4.1"
},
"devDependencies": {

View File

@@ -8,7 +8,7 @@ import { swaggerSpec } from './config/swagger';
import { traceMiddleware } from './middleware/trace';
import { authMiddleware } from './middleware/auth';
import { csrfMiddleware } from './middleware/csrf';
import { globalLimiter, mutateLimiter, sensitiveLimiter } from './middleware/rate-limit';
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
import { errorHandler } from './middleware/error-handler';
import walletRoutes from './routes/wallet.routes';
import vaultRoutes from './routes/vault.routes';
@@ -51,11 +51,13 @@ app.use('/api', globalLimiter);
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
const protect = [authMiddleware, csrfMiddleware];
// Sensitive (send / vault) — самый строгий лимит
// Sensitive — самый строгий лимит. Каждый POST/PUT защищён JWT + CSRF.
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
app.use('/api/vault', ...protect, sensitiveLimiter, vaultRoutes);
// Mutating (создание кошельков / broadcast / build) — повышенный лимит
// Mutating (proxy + read endpoints) — повышенный лимит
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
@@ -64,6 +66,11 @@ app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
app.use((_req, res) => {
res.status(404).json({ success: false, error: 'Not found' });
});
app.use(errorHandler);
export default app;

View File

@@ -17,8 +17,10 @@ export let env = {
},
jwt: {
algorithm: p.JWT_ALGORITHM || 'RS256',
issuer: p.JWT_ISSUER || 'auth-service',
audience: p.JWT_AUDIENCE || 'elcsa',
// Намеренно без default — каждый деплой ЯВНО указывает iss/aud, иначе сервис
// примет любой токен подписанный нашими ключами с любым iss/aud.
issuer: p.JWT_ISSUER || '',
audience: p.JWT_AUDIENCE || '',
},
vault: {
addr: p.VAULT_ADDR || '',
@@ -29,6 +31,7 @@ export let env = {
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
csrfPath: p.VAULT_CSRF_PATH || '',
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
},
cors: {
// No default — каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin доступа.
@@ -52,6 +55,11 @@ export function getVaultToken(): string | null {
}
export async function initEnv(): Promise<void> {
// Fail-fast на отсутствующие критические env vars
if (!env.jwt.issuer || !env.jwt.audience) {
throw new Error('JWT_ISSUER and JWT_AUDIENCE must be explicitly set (no defaults)');
}
const { addr, roleId, secretId, mount, secretPath } = env.vault;
if (!addr || !roleId || !secretId) {
@@ -106,4 +114,11 @@ export async function initEnv(): Promise<void> {
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
};
// Re-validate after Vault load. Vault мог переписать iss/aud — если они теперь пустые
// или невалидные, fail-fast.
if (!env.jwt.issuer || !env.jwt.audience) {
throw new Error('JWT_ISSUER and JWT_AUDIENCE became empty after Vault load');
}
logger.info(`JWT validation: iss="${env.jwt.issuer}", aud="${env.jwt.audience}"`);
}

View File

@@ -1,5 +1,6 @@
import { Request, Response } from 'express';
import { UserModel } from '../models/user.model';
import { logger } from '../lib/logger';
const MAX_VAULT_SIZE = 8192; // base64 encrypted blob upper limit
const MAX_SALT_LEN = 128;
@@ -29,7 +30,8 @@ export const VaultController = {
},
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
logger.error(`getVault failed for user ${userId}: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal error' });
}
},
@@ -61,7 +63,8 @@ export const VaultController = {
await UserModel.setVault(userId, encryptedVault, vaultSalt);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
logger.error(`putVault failed for user ${userId}: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal error' });
}
},
};

View File

@@ -1,21 +1,29 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
import { WalletModel } from '../models/wallet.model';
import { UserModel } from '../models/user.model';
import { getBalance, getTransactions, buildSend } from '../services/wallet-ops.service';
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 } from '../lib/audit-log';
import { logger } from '../lib/logger';
const ALLOWED_CHAINS = new Set<ChainCode>(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']);
const MAX_WALLETS_PER_REQUEST = 20;
const MAX_DERIVATION_PATH = 64;
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
const MAX_TX_LIMIT = 100;
class ConflictError extends Error {
constructor() { super('Wallet already exists'); }
}
function isChain(value: unknown): value is ChainCode {
return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode);
}
export const WalletController = {
/**
* GET /api/wallets — все кошельки юзера
* GET /api/wallets — все адреса юзера (chain + address + derivationPath).
*/
async getWallets(req: Request, res: Response) {
try {
@@ -28,76 +36,167 @@ export const WalletController = {
derivationPath: w.derivation_path,
})),
});
} catch {
} catch (err: any) {
logger.error(`getWallets failed for user ${req.auth!.userId}: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal error' });
}
},
/**
* POST /api/wallets — upsert массива кошельков для юзера из JWT.
* POST /api/wallets/create — custodial wallet bootstrap.
*
* 1) Проверка: у юзера ещё нет коша (иначе 409)
* 2) Генерим BIP39 mnemonic (128 bit, 12 слов)
* 3) Деривим адреса для всех 5 chains
* 4) Шифруем mnemonic AES-GCM (master-key из Vault)
* 5) Транзакционно пишем encrypted_mnemonic + wallets
* 6) Возвращаем ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
*/
async createWallets(req: Request, res: Response) {
async createWallet(req: Request, res: Response) {
const userId = req.auth!.userId;
const { wallets } = req.body ?? {};
if (!Array.isArray(wallets) || wallets.length === 0 || wallets.length > MAX_WALLETS_PER_REQUEST) {
if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' });
return;
}
let mnemonic: string | null = null;
try {
await UserModel.ensureExists(userId);
// Сначала проверка для быстрого 409 (не атомарна — реальная защита ниже)
if (await UserModel.hasMnemonic(userId)) {
res.status(409).json({ success: false, error: 'Wallet already exists' });
return;
}
mnemonic = generateMnemonic();
const derived = await deriveAllAddresses(mnemonic);
const blob = encryptMnemonic(mnemonic);
// ── АТОМАРНО: UPDATE users WHERE encrypted_mnemonic IS NULL + INSERT wallets ──
// Защита от funds-loss race: если параллельный запрос успел проставить mnemonic,
// наш UPDATE вернёт affected=0 → откатываем транзакцию, возвращаем 409.
const created = await db.transaction(async (trx) => {
const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx);
if (!claimed) {
// Кто-то другой выиграл race — наш mnemonic становится бессмысленным
throw new ConflictError();
}
await WalletModel.createMany(
derived.map((w) => ({
user_id: userId,
chain: w.chain,
address: w.address,
derivation_path: w.derivationPath,
})),
trx,
);
return derived;
});
await auditLog({
event: 'wallet.create',
userId,
ip: req.ip || null,
result: 'success',
meta: { chains: created.map((w) => w.chain) },
});
res.status(201).json({
success: true,
data: created.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivationPath,
})),
});
} catch (err: any) {
if (err instanceof ConflictError) {
res.status(409).json({ success: false, error: 'Wallet already exists' });
return;
}
logger.error(`createWallet failed for user ${userId}: ${err.message}`);
await auditLog({
event: 'wallet.create',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: err.code || 'INTERNAL',
});
res.status(500).json({ success: false, error: 'Failed to create wallet' });
} finally {
mnemonic = null; // best-effort GC hint
}
},
/**
* POST /api/wallets/mnemonic/reveal — reveal mnemonic для settings-страницы.
*
* Защита (defense-in-depth):
* 1. POST (CSRF token обязателен)
* 2. Тело должно содержать { confirm: "I_UNDERSTAND_SEED_IS_SECRET" } — защита
* от случайного XHR / image-tag / CSRF-attack which forge только URL
* 3. Rate-limit 5/час per-user
* 4. Audit-log на каждый вызов (success + failure)
*/
async revealMnemonic(req: Request, res: Response) {
const userId = req.auth!.userId;
if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' });
return;
}
// Confirmation token защищает от случайных XHR из чужих origin (даже если CSRF
// фронтенд сломан, атакующему нужно знать секретную фразу — она задокументирована в API)
if (req.body?.confirm !== 'I_UNDERSTAND_SEED_IS_SECRET') {
res.status(400).json({
success: false,
error: `wallets must be a non-empty array (max ${MAX_WALLETS_PER_REQUEST})`,
error: 'Missing or invalid confirm token. Send { "confirm": "I_UNDERSTAND_SEED_IS_SECRET" } in body.',
});
return;
}
for (const w of wallets) {
if (!w || typeof w !== 'object') {
res.status(400).json({ success: false, error: 'Invalid wallet entry' });
return;
}
if (!isChain(w.chain)) {
res.status(400).json({ success: false, error: 'Invalid chain' });
return;
}
if (!isValidAddress(w.chain, w.address)) {
res.status(400).json({ success: false, error: 'Invalid address format for chain' });
return;
}
if (
typeof w.derivationPath !== 'string' ||
w.derivationPath.length === 0 ||
w.derivationPath.length > MAX_DERIVATION_PATH ||
!/^m(\/[0-9]+'?)*$/.test(w.derivationPath)
) {
res.status(400).json({ success: false, error: 'Invalid derivationPath (BIP32 m/.. format expected)' });
return;
}
}
try {
await UserModel.ensureExists(userId);
const blob = await UserModel.getEncryptedMnemonic(userId);
if (!blob) {
await auditLog({
event: 'mnemonic.reveal',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: 'NOT_FOUND',
});
res.status(404).json({ success: false, error: 'Wallet not created yet' });
return;
}
const rows = await WalletModel.upsertMany(
wallets.map((w: { chain: ChainCode; address: string; derivationPath: string }) => ({
user_id: userId,
chain: w.chain,
address: w.address,
derivation_path: w.derivationPath,
}))
);
res.status(201).json({
success: true,
data: rows.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivation_path,
})),
const mnemonic = decryptMnemonic(blob);
await auditLog({
event: 'mnemonic.reveal',
userId,
ip: req.ip || null,
result: 'success',
});
} catch {
res.status(500).json({ success: false, error: 'Internal error' });
res.json({ success: true, data: { mnemonic } });
} catch (err: any) {
logger.error(`revealMnemonic failed for user ${userId}: ${err.message}`);
await auditLog({
event: 'mnemonic.reveal',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: 'INTERNAL',
});
res.status(500).json({ success: false, error: 'Failed to reveal mnemonic' });
}
},
/**
* GET /api/wallets/:chain/balance — баланс для адреса юзера в данной chain.
* GET /api/wallets/:chain/balance
*/
async getChainBalance(req: Request, res: Response) {
const userId = req.auth!.userId;
@@ -117,7 +216,8 @@ export const WalletController = {
const balance = await getBalance(chain, wallet.address);
res.json({ success: true, data: balance });
} catch {
} catch (err: any) {
logger.error(`getChainBalance ${chain} failed for user ${userId}: ${err.stack || err.message}`);
res.status(502).json({ success: false, error: 'Upstream RPC error' });
}
},
@@ -145,13 +245,16 @@ export const WalletController = {
const txs = await getTransactions(chain, wallet.address, limit);
res.json({ success: true, data: txs });
} catch {
} catch (err: any) {
logger.error(`getChainTransactions ${chain} failed for user ${userId}: ${err.stack || err.message}`);
res.status(502).json({ success: false, error: 'Upstream RPC error' });
}
},
/**
* POST /api/wallets/:chain/send — build unsigned транзакцию.
* POST /api/wallets/:chain/send — custodial sign + broadcast.
* Сервер расшифровывает мнемонику, деривит privkey, подписывает, broadcast'ит.
* Возвращает { txid }.
*/
async sendFromChain(req: Request, res: Response) {
const userId = req.auth!.userId;
@@ -162,6 +265,11 @@ export const WalletController = {
return;
}
if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' });
return;
}
const { to, amount, token } = req.body ?? {};
if (!isValidAddress(chain, String(to))) {
@@ -182,6 +290,7 @@ export const WalletController = {
normalizedToken = token.toUpperCase();
}
let mnemonic: string | null = null;
try {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
@@ -189,21 +298,51 @@ export const WalletController = {
return;
}
const tx = await buildSend({
const blob = await UserModel.getEncryptedMnemonic(userId);
if (!blob) {
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
return;
}
mnemonic = decryptMnemonic(blob);
const result = await signAndBroadcast({
chain,
from: wallet.address,
to,
amount,
mnemonic,
to: String(to),
amount: String(amount),
token: normalizedToken,
expectedFromAddress: wallet.address,
});
res.json({ success: true, data: tx });
await auditLog({
event: 'wallet.send',
userId,
ip: req.ip || null,
result: 'success',
meta: { chain, hasToken: !!normalizedToken, txid: result.txid },
});
res.json({ success: true, data: { txid: result.txid, chain } });
} catch (err: any) {
// Не возвращаем raw upstream message — может содержать sensitive info
const safeMsg = err?.message?.toLowerCase().includes('not implemented')
? 'Send not supported for this chain/token combination'
: 'Failed to build transaction';
res.status(502).json({ success: false, error: safeMsg });
logger.error(`send failed for user ${userId} chain ${chain}: ${err.message}`);
await auditLog({
event: 'wallet.send',
userId,
ip: req.ip || null,
result: 'failure',
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';
res.status(502).json({ success: false, error: msg });
} finally {
// Best-effort cleanup mnemonic в RAM
mnemonic = null;
}
},
};

View File

@@ -1,6 +1,7 @@
import app from './app';
import { env, initEnv } from './config/env';
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
import { isCryptoReady } from './services/crypto.service';
import { logger } from './lib/logger';
async function main() {
@@ -8,6 +9,13 @@ async function main() {
await initEnv();
await refreshAllKeys();
// Custodial: без master-key сервис не может расшифровать ни одну мнемонику — fail fast.
if (!isCryptoReady()) {
logger.error('Crypto master key not loaded — refusing to start (custodial wallets require it)');
process.exit(1);
}
startKeyRotation();
const server = app.listen(env.port, () => {

View File

@@ -1,33 +1,81 @@
/**
* Chain-specific address format validators.
* НЕ заменяет реальную чеканку checksum — это первый barrier.
* Chain-specific address validators с CHECKSUM проверкой.
* Принципиально: regex/length недостаточно — TRX/BTC используют base58check,
* один испорченный символ может пройти regex, но кошелёк по такому адресу
* не восстановим → funds permanently lost.
*/
import { ethers } from 'ethers';
import { createHash } from 'crypto';
import bs58 from 'bs58';
import * as bitcoin from 'bitcoinjs-lib';
import { PublicKey } from '@solana/web3.js';
const BTC_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
const TRX_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
const SOL_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
export function isValidAddress(chain: ChainCode, address: string): boolean {
if (typeof address !== 'string' || address.length === 0 || address.length > 256) return false;
// Любой блокчейн-адрес помещается в ~64 chars. 256 был оверкилл и open vector
// для DoS (тратим CPU на bs58.decode 200-char garbage).
if (typeof address !== 'string' || address.length === 0 || address.length > 64) return false;
switch (chain) {
case 'BTC':
return BTC_RE.test(address);
return isValidBtcAddress(address);
case 'TRX':
return TRX_RE.test(address);
return isValidTrxAddress(address);
case 'ETH':
case 'BSC':
return ethers.utils.isAddress(address);
case 'SOL':
return SOL_RE.test(address);
return isValidSolAddress(address);
default:
return false;
}
}
// ── BTC: bitcoinjs-lib проверяет version byte + checksum (P2PKH/P2SH/bech32) ──
function isValidBtcAddress(address: string): boolean {
try {
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
return true;
} catch {
return false;
}
}
// ── TRX: base58check + первый байт 0x41 ──
function isValidTrxAddress(address: string): boolean {
if (!TRX_RE.test(address)) return false;
let decoded: Uint8Array;
try {
decoded = bs58.decode(address);
} catch {
return false;
}
if (decoded.length !== 25) return false; // 1 prefix + 20 payload + 4 checksum
if (decoded[0] !== 0x41) return false; // TRX mainnet prefix
const payload = decoded.subarray(0, 21);
const checksum = decoded.subarray(21);
const h1 = createHash('sha256').update(payload).digest();
const h2 = createHash('sha256').update(h1).digest();
for (let i = 0; i < 4; i++) {
if (h2[i] !== checksum[i]) return false;
}
return true;
}
// ── SOL: реальное base58-декодирование через PublicKey ──
function isValidSolAddress(address: string): boolean {
try {
const pk = new PublicKey(address);
// PublicKey принимает 32-байтовое значение; isOnCurve дополнительный sanity
return pk.toBytes().length === 32;
} catch {
return false;
}
}
export function isValidAmount(amount: string): boolean {
if (typeof amount !== 'string' || amount.length === 0 || amount.length > 78) return false;
if (!/^\d+$/.test(amount)) return false;

View File

@@ -0,0 +1,61 @@
/**
* Audit log — append-only JSON lines в отдельный файл `logs/audit.log`.
* Используется для критических операций: mnemonic reveal, custodial sign.
*
* НИКОГДА не пишет sensitive данные (mnemonic / privkey / etc.) — только мета.
*/
import { promises as fs } from 'fs';
import path from 'path';
import { logger } from './logger';
import { getTraceId } from './trace-store';
const AUDIT_DIR = path.resolve(__dirname, '../../../../logs');
const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log');
let initialized = false;
async function ensureFile(): Promise<void> {
if (initialized) return;
try {
await fs.mkdir(AUDIT_DIR, { recursive: true });
// Создать с правами 0600 если файла нет
const handle = await fs.open(AUDIT_FILE, 'a', 0o600);
await handle.close();
try {
await fs.chmod(AUDIT_FILE, 0o600);
} catch {
// chmod может не работать на Windows — игнор
}
initialized = true;
} catch (err: any) {
logger.error(`Audit log init failed: ${err.message}`);
}
}
export interface AuditEntry {
event: string; // 'mnemonic.reveal', 'wallet.create', 'wallet.send', etc.
userId: string;
ip?: string | null;
meta?: Record<string, unknown>;
result?: 'success' | 'failure';
errorCode?: string;
}
export async function auditLog(entry: AuditEntry): Promise<void> {
await ensureFile();
const line = JSON.stringify({
timestamp: new Date().toISOString(),
trace_id: getTraceId(),
...entry,
});
try {
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
} catch (err: any) {
// Если audit-log не записался — логируем в обычный logger как ошибку,
// но НЕ блокируем основной флоу
logger.error(`Audit log write failed: ${err.message}`);
}
}

View File

@@ -35,10 +35,16 @@ function getCallerInfo(): { file: string; line: number } {
const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [
{ regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' },
{ regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' },
{ regex: /(mnemonic|seed[_-]?phrase|private[_-]?key|priv[_-]?key)\s*[:=]\s*\S+/gi, replace: '$1=***' },
{ regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' },
{ regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' },
{ regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' },
{ regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' },
// BIP39 mnemonic phrase (12+ lowercase английских слов через пробел) — рискованный паттерн,
// но лучше пере-санитайзить чем пропустить mnemonic в логи
{ regex: /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/g, replace: '[REDACTED_MNEMONIC]' },
// Hex privkey (64 hex chars подряд, optional 0x)
{ regex: /\b(0x)?[0-9a-fA-F]{64}\b/g, replace: '[REDACTED_HEX64]' },
];
function sanitize(msg: string): string {

View File

@@ -33,7 +33,7 @@ export function errorHandler(err: HttpError, _req: Request, res: Response, _next
return;
}
// Серверные ошибки (5xx) — generic message, детали только в логи
logger.error(`Server error: ${err.message}`);
// Серверные ошибки (5xx) — generic message, детали (со stack) только в логи
logger.error(`Server error: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal server error' });
}

View File

@@ -31,7 +31,7 @@ export const mutateLimiter = rateLimit({
message: { success: false, error: 'Too many mutating requests' },
});
// Самый строгий — для send / vault PUT (anti-abuse / spam tx prevention)
// Самый строгий — для send / vault PUT / wallet create (anti-abuse / spam tx prevention)
export const sensitiveLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 10,
@@ -40,3 +40,14 @@ export const sensitiveLimiter = rateLimit({
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many sensitive requests' },
});
// Экстремально строгий — для GET /api/wallets/mnemonic.
// Reveal seed phrase — критическая операция: 5 запросов в час per-user.
export const mnemonicRevealLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many mnemonic reveal requests' },
});

View File

@@ -1,3 +1,4 @@
import type { Knex } from 'knex';
import { db } from '../config/database';
export interface UserRow {
@@ -19,6 +20,7 @@ export interface UserRow {
is_deleted: boolean;
encrypted_vault: string | null;
vault_salt: string | null;
encrypted_mnemonic: string | null;
created_at: Date;
updated_at: Date;
}
@@ -75,4 +77,41 @@ export const UserModel = {
.select('encrypted_vault', 'vault_salt')
.first();
},
/**
* Custodial: атомарно записать зашифрованную мнемонику.
* Используется set-once семантика: UPDATE WHERE encrypted_mnemonic IS NULL,
* возвращает true только если этот вызов реально сел в slot.
*
* Защищает от TOCTOU race: два параллельных createWallet не могут оба
* перезаписать друг друга. Без этого ВТОРОЙ запрос сохранил бы свою mnemonic,
* но wallet-rows остались бы от первого → funds permanently lost.
*/
async setEncryptedMnemonicIfAbsent(id: string, blob: string, trx?: Knex.Transaction): Promise<boolean> {
const q = (trx || db)('users')
.where({ id })
.whereNull('encrypted_mnemonic')
.update({
encrypted_mnemonic: blob,
updated_at: db.fn.now(),
});
const affected = await q;
return affected === 1;
},
async getEncryptedMnemonic(id: string): Promise<string | null> {
const row = await db('users')
.where({ id, is_deleted: false })
.select('encrypted_mnemonic')
.first();
return row?.encrypted_mnemonic ?? null;
},
async hasMnemonic(id: string): Promise<boolean> {
const row = await db('users')
.where({ id, is_deleted: false })
.select(db.raw('encrypted_mnemonic IS NOT NULL AS has'))
.first();
return Boolean(row?.has);
},
};

View File

@@ -1,3 +1,4 @@
import type { Knex } from 'knex';
import { db } from '../config/database';
import { generateUlid } from '../utils/ulid';
@@ -20,10 +21,11 @@ export const WalletModel = {
},
async createMany(
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[],
trx?: Knex.Transaction,
): Promise<WalletRow[]> {
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
return db('wallets').insert(withIds).returning('*');
return (trx || db)('wallets').insert(withIds).returning('*');
},
/**

View File

@@ -3,9 +3,12 @@ import { WalletController } from '../controllers/wallet.controller';
const router = Router();
// Lifecycle
router.post('/create', WalletController.createWallet);
router.get('/', WalletController.getWallets);
router.post('/', WalletController.createWallets);
router.post('/mnemonic/reveal', WalletController.revealMnemonic);
// Per-chain operations
router.get('/:chain/balance', WalletController.getChainBalance);
router.get('/:chain/transactions', WalletController.getChainTransactions);
router.post('/:chain/send', WalletController.sendFromChain);

View File

@@ -0,0 +1,144 @@
import { randomBytes, createCipheriv, createDecipheriv, timingSafeEqual } from 'crypto';
import { fetchVaultKV2 } from '../config/vault';
/**
* Symmetric encryption (AES-256-GCM) для хранения мнемоник юзеров в БД.
* Master-key читается из Vault при старте + при каждой ротации ключей.
*
* Storage layout (base64):
* IV(12) || ciphertext(N) || authTag(16)
*
* Ключ — 32 байта (256 бит), храним в Buffer, нигде на диск не пишем.
* Если ключ не загружен — encrypt/decrypt бросают ошибку (fail-secure).
*/
const KEY_LEN = 32; // 256-bit AES key
const IV_LEN = 12; // GCM standard nonce
const TAG_LEN = 16; // GCM auth tag
let masterKey: Buffer | null = null;
/**
* Установить master-key. Вызывается ОДНОКРАТНО при первом старте.
* Передача null или повторная установка после успешной загрузки — запрещено,
* это бы убило все существующие encrypted_mnemonic.
*/
export function swapMasterKey(newKey: Buffer): void {
if (!newKey || newKey.length !== KEY_LEN) {
throw new Error(`swapMasterKey: invalid key (expected ${KEY_LEN} bytes)`);
}
if (masterKey) {
// Уже загружен — повторная установка опасна. Если ключ совпадает — silent no-op.
// Если отличается — это либо ротация (запрещена), либо bug, либо атака.
throw new Error('swapMasterKey: master key already loaded; rotation is not supported');
}
masterKey = newKey;
}
/**
* Проверить, отличается ли свежий fetched-ключ от установленного in-memory.
* Используется для WARN-логирования при ротации в Vault (операторская ошибка).
*/
export function masterKeyMatches(candidate: Buffer): boolean {
if (!masterKey || !candidate || candidate.length !== KEY_LEN) return false;
return masterKey.equals(candidate);
}
export function isCryptoReady(): boolean {
return masterKey !== null && masterKey.length === KEY_LEN;
}
/**
* Pre-fetch master-key из Vault. НЕ мутирует глобал — возвращает Buffer.
* Throws при отсутствии или невалидном формате.
*/
export async function fetchMasterKey(
addr: string,
token: string,
mount: string,
path: string,
): Promise<Buffer> {
const secrets = await fetchVaultKV2(addr, token, mount, path);
if (!secrets) {
throw new Error('Failed to load crypto master key from Vault');
}
const raw = secrets.key || secrets.master_key || secrets.MASTER_KEY;
if (!raw || typeof raw !== 'string') {
throw new Error('Crypto master key invalid: expected hex string in Vault field "key"');
}
// Принимаем только hex 64 chars = 32 bytes
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
throw new Error('Crypto master key invalid: must be 64-char hex (32 bytes)');
}
const buf = Buffer.from(raw, 'hex');
if (buf.length !== KEY_LEN) {
throw new Error(`Crypto master key invalid: got ${buf.length} bytes, expected ${KEY_LEN}`);
}
return buf;
}
/**
* Зашифровать строку (мнемонику) → base64 blob.
* Используется при создании коша.
*/
export function encryptMnemonic(plaintext: string): string {
if (!masterKey) {
throw new Error('Crypto service not ready');
}
if (typeof plaintext !== 'string' || plaintext.length === 0) {
throw new Error('encryptMnemonic: plaintext must be non-empty string');
}
const iv = randomBytes(IV_LEN);
const cipher = createCipheriv('aes-256-gcm', masterKey, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, ct, tag]).toString('base64');
}
/**
* Расшифровать base64 blob → исходная строка.
* Используется при send + reveal.
*/
export function decryptMnemonic(blob: string): string {
if (!masterKey) {
throw new Error('Crypto service not ready');
}
if (typeof blob !== 'string' || blob.length === 0) {
throw new Error('decryptMnemonic: blob must be non-empty string');
}
const buf = Buffer.from(blob, 'base64');
if (buf.length < IV_LEN + TAG_LEN + 1) {
throw new Error('decryptMnemonic: blob too short');
}
const iv = buf.subarray(0, IV_LEN);
const tag = buf.subarray(buf.length - TAG_LEN);
const ct = buf.subarray(IV_LEN, buf.length - TAG_LEN);
const decipher = createDecipheriv('aes-256-gcm', masterKey, iv);
decipher.setAuthTag(tag);
try {
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
} catch {
// Не пробрасываем оригинальную ошибку — она может содержать sensitive info
throw new Error('decryptMnemonic: authentication failed');
}
}
/**
* Сравнить два base64-blob'а constant-time (нужно для тестов / sanity).
*/
export function constantTimeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}

View File

@@ -2,6 +2,7 @@ import { env, getVaultToken } from '../config/env';
import { vaultAppRoleLogin } from '../config/vault';
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service';
import { logger } from '../lib/logger';
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
@@ -14,7 +15,7 @@ let currentVaultToken: string | null = null;
* При любой ошибке оставляем старые значения в памяти, сервис продолжает работать.
*/
export async function refreshAllKeys(): Promise<void> {
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault;
if (!addr || !roleId || !secretId) {
logger.warn('Vault not configured, skipping key refresh');
@@ -33,11 +34,14 @@ export async function refreshAllKeys(): Promise<void> {
currentVaultToken = fresh;
}
// ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ───────────
// ── Pre-fetch всех секретов параллельно (НЕ мутируя глобал) ───────────
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
// Master-key: первая загрузка обязательна (custodial без него работать не может),
// последующие тики толерантны (если упало — оставляем старый ключ).
const cryptoPromise = cryptoKeyPath ? fetchMasterKey(addr, token, mount, cryptoKeyPath) : Promise.resolve(null);
const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]);
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
// ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ──
if (jwtResult.status === 'rejected') {
@@ -48,16 +52,36 @@ export async function refreshAllKeys(): Promise<void> {
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
return;
}
// Master-key: если он ещё не загружен — это критическая ошибка (отказ при первом запуске).
// Если уже был — оставляем старый (ротация ключа = ломает всю расшифровку, не делаем on rotation).
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
return;
}
// ── Atomic swap (синхронные операции, нельзя прервать) ──────────────────
swapKeyMap(jwtResult.value);
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
swapCsrfConfig(csrfResult.value);
}
// Master-key загружаем ТОЛЬКО при первой инициализации (потом не ротируем — иначе сломаем расшифровку).
// Если в Vault положили НОВЫЙ ключ — WARN-log, операторская ошибка.
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
if (!isCryptoReady()) {
swapMasterKey(cryptoResult.value);
logger.info('Crypto master key loaded');
} else if (!masterKeyMatches(cryptoResult.value)) {
logger.warn(
'Vault crypto/master key DIFFERS from in-memory key. Service continues with old key. ' +
'Rotating master-key bricks all existing encrypted_mnemonic — revert Vault or plan re-encryption migration.'
);
}
}
logger.info(
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '')
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
);
}

View File

@@ -0,0 +1,121 @@
/**
* Wallet generation: BIP39 mnemonic + multi-chain address derivation.
* Server-side для custodial-флоу.
*
* Поддерживаемые chains (BIP44):
* ETH/BSC — m/44'/60'/0'/0/0 (secp256k1, EIP-55 checksum)
* BTC — m/84'/0'/0'/0/0 (P2WPKH bech32)
* TRX — m/44'/195'/0'/0/0 (secp256k1, base58check + prefix 0x41)
* SOL — m/44'/501'/0'/0' (ed25519)
*/
import { ethers } from 'ethers';
import { createHash } from 'crypto';
import bs58 from 'bs58';
import * as bip39 from 'bip39';
import { BIP32Factory } from 'bip32';
import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib';
import { Keypair } from '@solana/web3.js';
import { derivePath } from 'ed25519-hd-key';
import type { ChainCode } from '../lib/address-validators';
const bip32 = BIP32Factory(ecc);
export const DERIVATION_PATHS: Record<ChainCode, string> = {
ETH: "m/44'/60'/0'/0/0",
BSC: "m/44'/60'/0'/0/0",
BTC: "m/84'/0'/0'/0/0",
TRX: "m/44'/195'/0'/0/0",
SOL: "m/44'/501'/0'/0'",
};
export const ALL_CHAINS: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
export interface DerivedWallet {
chain: ChainCode;
address: string;
derivationPath: string;
}
/**
* Сгенерить 12-словную BIP39 мнемонику.
*/
export function generateMnemonic(): string {
return bip39.generateMnemonic(128);
}
/**
* Валидация существующей mnemonic (не используется в текущем флоу — оставлено на будущее).
*/
export function validateMnemonic(m: string): boolean {
return bip39.validateMnemonic(m);
}
/**
* Деривить адреса для всех chains из одной mnemonic.
*/
export async function deriveAllAddresses(mnemonic: string): Promise<DerivedWallet[]> {
if (!bip39.validateMnemonic(mnemonic)) {
throw new Error('Invalid mnemonic');
}
const seed = await bip39.mnemonicToSeed(mnemonic);
const seedHex = seed.toString('hex');
// ETH (BSC использует тот же адрес)
const ethWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.ETH);
const ethAddress = ethers.utils.getAddress(ethWallet.address); // EIP-55 checksum
// BTC (P2WPKH bech32)
const btcRoot = bip32.fromSeed(seed);
const btcChild = btcRoot.derivePath(DERIVATION_PATHS.BTC);
if (!btcChild.publicKey) {
throw new Error('BTC derivation failed: no public key');
}
const btcPayment = bitcoin.payments.p2wpkh({
pubkey: Buffer.from(btcChild.publicKey),
network: bitcoin.networks.bitcoin,
});
if (!btcPayment.address) {
throw new Error('BTC derivation failed: no address');
}
// TRX (derive privkey same curve as ETH, convert pubkey → TRX base58check address)
const trxWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.TRX);
const trxAddress = ethAddressToTron(trxWallet.address);
// SOL (ed25519 derivation)
const { key: solKey } = derivePath(DERIVATION_PATHS.SOL, seedHex);
if (!solKey || solKey.length !== 32) {
throw new Error('SOL derivation produced invalid seed length');
}
const solKeypair = Keypair.fromSeed(solKey);
const solAddress = solKeypair.publicKey.toBase58();
return [
{ chain: 'ETH', address: ethAddress, derivationPath: DERIVATION_PATHS.ETH },
{ chain: 'BSC', address: ethAddress, derivationPath: DERIVATION_PATHS.BSC },
{ chain: 'BTC', address: btcPayment.address, derivationPath: DERIVATION_PATHS.BTC },
{ chain: 'TRX', address: trxAddress, derivationPath: DERIVATION_PATHS.TRX },
{ chain: 'SOL', address: solAddress, derivationPath: DERIVATION_PATHS.SOL },
];
}
/**
* ETH-style address (0x...) → TRX base58check (T...).
* TRX и ETH используют одну curve и одну keccak256-логику для получения 20-байтного хеша.
* Различие только в префиксе (0x41 vs ничего) и в кодировке (base58check vs hex).
*/
export function ethAddressToTron(ethAddr: string): string {
const hex = ethAddr.toLowerCase().replace(/^0x/, '');
if (hex.length !== 40) {
throw new Error('ethAddressToTron: invalid input length');
}
const bytes = Buffer.from(hex, 'hex');
const prefixed = Buffer.concat([Buffer.from([0x41]), bytes]); // 21 байт
const h1 = createHash('sha256').update(prefixed).digest();
const h2 = createHash('sha256').update(h1).digest();
const checksum = h2.subarray(0, 4);
return bs58.encode(new Uint8Array(Buffer.concat([prefixed, checksum])));
}

View File

@@ -0,0 +1,442 @@
/**
* Server-side signing + broadcasting для custodial flow.
* Caller передаёт расшифрованную mnemonic, мы деривим privkey, подписываем, broadcast'им.
*
* Никогда не логируем mnemonic / privkey / signed tx hex.
*/
import { ethers } from 'ethers';
import { createHash } from 'crypto';
import * as bip39 from 'bip39';
import { BIP32Factory } from 'bip32';
import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib';
import { Keypair, Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { derivePath } from 'ed25519-hd-key';
import { env } from '../config/env';
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
import type { ChainCode } from '../lib/address-validators';
const bip32 = BIP32Factory(ecc);
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
const BSC_RPC = 'https://bsc-dataseed.binance.org';
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
const TRONGRID = 'https://api.trongrid.io';
const BLOCKSTREAM = 'https://blockstream.info/api';
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
const ERC20_ABI = [
'function transfer(address to, uint256 amount) returns (bool)',
];
const HTTP_TIMEOUT_MS = 20_000;
export interface SendParams {
chain: ChainCode;
mnemonic: string;
to: string;
amount: string; // smallest units (wei / sat / sun / lamport)
token?: string;
/**
* Адрес из БД (wallets.address) для текущего юзера+chain.
* Signer верифицирует: derived(mnemonic, path) === expectedFromAddress.
* Если нет — отказ от подписи. Защита от случайной смены DERIVATION_PATHS
* или подмены mnemonic в БД (например в результате backup-восстановления).
*/
expectedFromAddress: string;
}
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
switch (p.chain) {
case 'ETH': return sendEvm(p, ETH_RPC, 1, USDT_ERC20);
case 'BSC': return sendEvm(p, BSC_RPC, 56, USDT_BEP20);
case 'BTC': return sendBtc(p);
case 'TRX': return sendTrx(p);
case 'SOL': return sendSol(p);
}
}
function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void {
// EVM адреса case-insensitive (EIP-55 — только display)
const norm = (s: string) =>
chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s;
if (norm(derived) !== norm(expected)) {
throw new Error(`Derived ${chain} address ${derived} does not match stored ${expected}`);
}
}
// ─── EVM (ETH / BSC) ───
// Жёсткий cap на gas price — защита от fee-storm. ETH historically peaks at ~500 gwei,
// нормальный диапазон 5-50 gwei. BSC ~3-10 gwei.
const MAX_GAS_PRICE_GWEI = 500;
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
const provider = new ethers.providers.StaticJsonRpcProvider(rpc, chainId);
const signer = wallet.connect(provider);
// 1) Fee cap — fetch feeData и режем по верхней границе
const feeData = await provider.getFeeData();
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
const effectiveGasPrice = feeData.maxFeePerGas ?? feeData.gasPrice;
if (!effectiveGasPrice || effectiveGasPrice.gt(capWei)) {
throw new Error(`Gas price exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
}
// 2) Явный nonce — fail loud если provider лажает
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
// 3) Fee fields для tx — закрепляем cap, чтобы ethers не сходил за свежими ценами
// во время broadcast (TOCTOU).
const isEip1559 = !!feeData.maxFeePerGas;
const feeFields: Partial<ethers.providers.TransactionRequest> = isEip1559
? {
type: 2,
maxFeePerGas: feeData.maxFeePerGas!,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? ethers.BigNumber.from(0),
}
: { gasPrice: effectiveGasPrice };
let tx: ethers.providers.TransactionRequest;
if (!p.token) {
// Native: pre-check balance >= value + gas estimate
const value = ethers.BigNumber.from(p.amount);
const balance = await provider.getBalance(wallet.address);
const estGas = ethers.BigNumber.from(21000); // simple native transfer
const totalNeeded = value.add(effectiveGasPrice.mul(estGas));
if (balance.lt(totalNeeded)) {
throw new Error('Insufficient balance (value + gas)');
}
tx = { to: p.to, value, chainId, nonce, gasLimit: estGas, ...feeFields };
} else if (p.token.toUpperCase() === 'USDT') {
// ERC20: pre-check token balance + native gas balance
const iface = new ethers.utils.Interface([
...ERC20_ABI,
'function balanceOf(address) view returns (uint256)',
]);
const erc20 = new ethers.Contract(usdtAddr, iface, provider);
const tokenBal: ethers.BigNumber = await erc20.balanceOf(wallet.address);
if (tokenBal.lt(ethers.BigNumber.from(p.amount))) {
throw new Error('Insufficient token balance');
}
const nativeBal = await provider.getBalance(wallet.address);
const estGas = ethers.BigNumber.from(80000); // ERC20 transfer ~50-65k, запас
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
throw new Error('Insufficient native balance for gas');
}
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
} else {
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
}
const sent = await signer.sendTransaction(tx);
return { txid: sent.hash };
}
// ─── SOLANA ───
async function sendSol(p: SendParams): Promise<{ txid: string }> {
if (p.token) {
throw new Error('SOL SPL-token signing не реализовано (только native SOL)');
}
const seed = await bip39.mnemonicToSeed(p.mnemonic);
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
if (!key || key.length !== 32) {
throw new Error('SOL derivation produced invalid seed length');
}
const keypair = Keypair.fromSeed(key);
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
const conn = new Connection(SOL_RPC, 'confirmed');
const toPk = new PublicKey(p.to);
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
const tx = new Transaction({
feePayer: keypair.publicKey,
blockhash,
lastValidBlockHeight,
});
tx.add(
SystemProgram.transfer({
fromPubkey: keypair.publicKey,
toPubkey: toPk,
lamports: BigInt(p.amount),
}),
);
tx.sign(keypair);
const sig = await conn.sendRawTransaction(tx.serialize());
// Wait for confirmation — иначе sendRawTransaction только подтверждает что leader увидел.
// Solana дропает 5-15% unconfirmed во время congestion.
try {
await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
} catch (err: any) {
// Tx уже broadcastнут — может ещё пройти. Audit-log в caller'е покажет txid для reconciliation.
throw new Error(`SOL tx submitted but confirmation timed out (sig=${sig}): ${err.message}`);
}
return { txid: sig };
}
// ─── BITCOIN (P2WPKH bech32) ───
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
if (p.token) throw new Error('BTC tokens не поддерживаются');
const seed = await bip39.mnemonicToSeed(p.mnemonic);
const root = bip32.fromSeed(seed);
const child = root.derivePath(DERIVATION_PATHS.BTC);
if (!child.publicKey) throw new Error('BTC derivation failed');
const network = bitcoin.networks.bitcoin;
const pubkeyBuf = Buffer.from(child.publicKey);
const payment = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network });
if (!payment.address || !payment.output) throw new Error('BTC payment build failed');
const fromAddr = payment.address;
assertAddressMatch(fromAddr, p.expectedFromAddress, 'BTC');
// Fetch UTXOs + fee estimate
const [utxosRes, feesRes] = await Promise.all([
fetchJson(`${BLOCKSTREAM}/address/${fromAddr}/utxo`),
fetchJson(`${BLOCKSTREAM}/fee-estimates`),
]);
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
// Fee fallback приоритеты: 1 блок > 3 блока > 6 блоков > 15 sat/vB (защита от
// отказа broadcast по min-relay-fee на загруженном mempool).
const feeMap = feesRes as Record<string, number>;
const feeRate = Math.ceil(feeMap['1'] ?? feeMap['3'] ?? feeMap['6'] ?? 15);
const amountSat = BigInt(p.amount);
if (amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error('BTC amount exceeds safe integer range');
}
// Сортируем UTXO по убыванию value — greedy выбор
utxos.sort((a, b) => b.value - a.value);
const psbt = new bitcoin.Psbt({ network });
let totalIn = 0n;
// Оценка fee для P2WPKH: input ≈ 68 vB, output ≈ 31 vB, overhead ≈ 11 vB.
// * 1.1 safety multiplier — защита от незначительных изменений mempool fee
// между fetch и broadcast.
const feeFor = (ins: number, outs: number) =>
BigInt(Math.ceil((ins * 68 + outs * 31 + 11) * feeRate * 1.1));
const selectedUtxos: typeof utxos = [];
for (const u of utxos) {
selectedUtxos.push(u);
totalIn += BigInt(u.value);
if (totalIn >= amountSat + feeFor(selectedUtxos.length, 2)) break;
}
if (totalIn < amountSat + feeFor(selectedUtxos.length, 2)) {
throw new Error('Insufficient BTC balance (incl. fee)');
}
for (const u of selectedUtxos) {
psbt.addInput({
hash: u.txid,
index: u.vout,
witnessUtxo: { script: payment.output, value: u.value },
});
}
psbt.addOutput({ address: p.to, value: Number(amountSat) });
const fee = feeFor(selectedUtxos.length, 2);
const change = totalIn - amountSat - fee;
// P2WPKH dust threshold = 294 sat (vs 546 для legacy P2PKH).
// Если change < dust — донатим miner'у как extra fee.
if (change > 294n) {
psbt.addOutput({ address: fromAddr, value: Number(change) });
}
for (let i = 0; i < selectedUtxos.length; i++) {
psbt.signInput(i, {
publicKey: pubkeyBuf,
sign: (hash: Buffer) => Buffer.from(child.sign(hash)),
});
}
psbt.finalizeAllInputs();
const txHex = psbt.extractTransaction().toHex();
// Broadcast с явным timeout + content-type (иначе fetch может зависнуть навечно)
const broadcastController = new AbortController();
const tBroadcast = setTimeout(() => broadcastController.abort(), HTTP_TIMEOUT_MS);
let broadcast: Response;
try {
broadcast = await fetch(`${BLOCKSTREAM}/tx`, {
method: 'POST',
body: txHex,
headers: { 'Content-Type': 'text/plain' },
signal: broadcastController.signal,
});
} finally {
clearTimeout(tBroadcast);
}
if (!broadcast.ok) {
const body = await broadcast.text().catch(() => '');
throw new Error(`BTC broadcast failed (${broadcast.status}): ${body.slice(0, 200)}`);
}
const txid = (await broadcast.text()).trim();
return { txid };
}
// ─── TRON ───
async function sendTrx(p: SendParams): Promise<{ txid: string }> {
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
const fromTronAddr = ethAddressToTron(wallet.address);
assertAddressMatch(fromTronAddr, p.expectedFromAddress, 'TRX');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
let txBody: any;
if (!p.token) {
const built = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: fromTronAddr,
to_address: p.to,
amount: Number(p.amount),
visible: true,
}),
});
txBody = built;
} else if (p.token.toUpperCase() === 'USDT') {
const param =
tronAddressToHex(p.to).padStart(64, '0') +
BigInt(p.amount).toString(16).padStart(64, '0');
const built = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: fromTronAddr,
contract_address: USDT_TRC20,
function_selector: 'transfer(address,uint256)',
parameter: param,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
txBody = built.transaction;
} else {
throw new Error(`Token ${p.token} not supported on TRX`);
}
if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) {
throw new Error('TRX tx build failed (incomplete response)');
}
// ── ВЕРИФИКАЦИЯ против скомпрометированного RPC / MITM ────────────────────
// 1. Recompute txID локально: SHA256(raw_data_hex) должен совпасть с тем что прислал RPC.
// Если не совпало — RPC лжёт о txID и мог подсунуть raw_data, дренирующее на attacker.
const expectedTxId = createHash('sha256')
.update(Buffer.from(txBody.raw_data_hex, 'hex'))
.digest('hex');
if (expectedTxId !== txBody.txID) {
throw new Error('TRX txID mismatch — possible MITM/compromised RPC');
}
// 2. Verify что raw_data действительно содержит наш intent (to_address + amount)
const contractValue = txBody.raw_data?.contract?.[0]?.parameter?.value;
if (!contractValue) {
throw new Error('TRX tx malformed (no contract value)');
}
if (!p.token) {
// Native TRX: visible=true → to_address это base58 строка
if (contractValue.to_address !== p.to) {
throw new Error(`TRX to_address mismatch: expected ${p.to}, got ${contractValue.to_address}`);
}
if (String(contractValue.amount) !== String(p.amount)) {
throw new Error(`TRX amount mismatch: expected ${p.amount}, got ${contractValue.amount}`);
}
} else {
// TRC20: contract_address и parameter (encoded to+amount). Проверяем что contract правильный.
if (contractValue.contract_address !== USDT_TRC20) {
throw new Error(`TRX contract mismatch: expected ${USDT_TRC20}, got ${contractValue.contract_address}`);
}
// Decode parameter: первые 32 байта = to (TRX-hex prefixed by 0x41 padded), вторые = amount
const data = String(contractValue.data || '');
if (data.length !== 128 + 8) {
// method id (8 hex chars) + 2 * 32 bytes (64 hex chars each)
throw new Error('TRX trc20 data length wrong');
}
const expectedParam =
tronAddressToHex(p.to).padStart(64, '0') +
BigInt(p.amount).toString(16).padStart(64, '0');
const actualParam = data.slice(8); // strip method id
if (actualParam.toLowerCase() !== expectedParam.toLowerCase()) {
throw new Error('TRX trc20 parameter mismatch (to/amount tampering)');
}
}
// Подпись txID (теперь верифицированного локально)
const sk = new ethers.utils.SigningKey(wallet.privateKey);
const sig = sk.signDigest('0x' + txBody.txID);
const sigHex =
sig.r.slice(2) +
sig.s.slice(2) +
(sig.recoveryParam ?? 0).toString(16).padStart(2, '0');
txBody.signature = [sigHex];
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
method: 'POST',
headers,
body: JSON.stringify(txBody),
});
if (!broadcast?.result) {
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
throw new Error(`TRX broadcast failed: ${msg.slice(0, 200)}`);
}
return { txid: txBody.txID };
}
// ─── HELPERS ───
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function tronAddressToHex(address: string): string {
let num = 0n;
for (const ch of address) {
const i = BASE58_ALPHABET.indexOf(ch);
if (i === -1) throw new Error('Invalid base58 character in TRON address');
num = num * 58n + BigInt(i);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42); // strip 0x41 prefix + checksum bytes
}
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
}
return await res.json();
} finally {
clearTimeout(t);
}
}

View File

@@ -2,8 +2,8 @@
"openapi": "3.0.0",
"info": {
"title": "CryptoWallet API",
"version": "2.1.0",
"description": "Multi-chain crypto wallet API. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK). Non-custodial: server NEVER signs transactions, только строит unsigned tx + хранит зашифрованный vault."
"version": "3.0.0",
"description": "Multi-chain crypto wallet API. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK). CUSTODIAL: server генерит мнемонику, хранит её AES-GCM-зашифрованной (master-key из Vault) и сам подписывает транзакции."
},
"servers": [
{ "url": "/api", "description": "API root" }
@@ -56,22 +56,28 @@
"derivationPath": { "type": "string" }
}
},
"WalletInput": {
"MnemonicResponse": {
"type": "object",
"required": ["chain", "address", "derivationPath"],
"properties": {
"chain": { "$ref": "#/components/schemas/Chain" },
"address": { "type": "string", "maxLength": 256 },
"derivationPath": { "type": "string", "maxLength": 64 }
"success": { "type": "boolean", "example": true },
"data": {
"type": "object",
"properties": {
"mnemonic": { "type": "string", "description": "BIP39 mnemonic (12 words)" }
}
}
}
},
"CreateWalletsRequest": {
"TxBroadcastResponse": {
"type": "object",
"required": ["wallets"],
"properties": {
"wallets": {
"type": "array", "minItems": 1, "maxItems": 20,
"items": { "$ref": "#/components/schemas/WalletInput" }
"success": { "type": "boolean", "example": true },
"data": {
"type": "object",
"properties": {
"txid": { "type": "string", "description": "Идентификатор отправленной транзакции" },
"chain": { "$ref": "#/components/schemas/Chain" }
}
}
}
},
@@ -184,19 +190,49 @@
"200": { "description": "List of wallets", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
}
},
"/wallets/create": {
"post": {
"summary": "Upsert wallets for authenticated user",
"description": "user_id берётся из JWT (sub). При первом обращении создаёт user-row автоматически. На конфликт (user_id, chain) — обновляет address + derivationPath.",
"summary": "Создать custodial-кошелёк (mnemonic генерится на сервере)",
"description": "Сервер генерит BIP39 mnemonic (12 слов), деривит адреса для 5 chains, шифрует mnemonic AES-GCM (master-key из Vault) и сохраняет. **Возвращает ТОЛЬКО адреса** — mnemonic клиенту НЕ отдаётся. Чтобы потом увидеть seed — отдельный endpoint GET /wallets/mnemonic. Идемпотентность: 409 если у юзера уже есть коша.",
"tags": ["Wallets"],
"responses": {
"201": { "description": "Wallet created (returns addresses only)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
"401": { "description": "Not authenticated" },
"409": { "description": "Wallet already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"503": { "description": "Crypto service not ready" }
}
}
},
"/wallets/mnemonic/reveal": {
"post": {
"summary": "Раскрыть mnemonic (settings-screen)",
"description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику юзера. POST + CSRF + рукопожатие в body — защита от случайного XHR / image-tag CSRF / стороннего origin. Rate-limit 5/час. Каждый запрос пишется в audit-log.",
"tags": ["Wallets"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateWalletsRequest" } } }
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["confirm"],
"properties": {
"confirm": { "type": "string", "enum": ["I_UNDERSTAND_SEED_IS_SECRET"] }
}
}
}
}
},
"responses": {
"201": { "description": "Created/updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
"400": { "description": "Invalid input", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
"200": { "description": "Mnemonic revealed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MnemonicResponse" } } } },
"400": { "description": "Missing/invalid confirm token" },
"401": { "description": "Not authenticated" },
"404": { "description": "Wallet not created yet" },
"429": { "description": "Rate limit (5/hour) exceeded" },
"503": { "description": "Crypto service not ready" }
}
}
},
@@ -231,8 +267,8 @@
"/wallets/{chain}/send": {
"post": {
"summary": "Build unsigned send transaction (non-custodial)",
"description": "Возвращает unsigned tx. Клиент подписывает приватным ключом и broadcast'ит через /api/{btc,tron}/broadcast или RPC своей цепи.",
"summary": "Custodial send: server signs + broadcasts",
"description": "Сервер расшифровывает мнемонику → деривит chain-privkey → подписывает → broadcast'ит через RPC. Возвращает txid.",
"tags": ["Wallet Ops"],
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
"requestBody": {
@@ -240,10 +276,11 @@
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } }
},
"responses": {
"200": { "description": "Unsigned tx", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnsignedTxResponse" } } } },
"200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } },
"400": { "description": "Invalid input" },
"404": { "description": "Wallet not found" },
"502": { "description": "Upstream RPC error" }
"404": { "description": "Wallet/mnemonic not found" },
"502": { "description": "Broadcast failed (upstream RPC error / insufficient balance / unsupported token)" },
"503": { "description": "Crypto service not ready" }
}
}
},