diff --git a/.env.example b/.env.example index 33c3b83..63aace5 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,11 @@ VAULT_JWT_KIDS_PREFIX=jwt/kids # CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF) VAULT_CSRF_PATH= +# Crypto master-key для шифрования мнемоник юзеров (AES-256-GCM). +# В Vault лежит hex-строка длиной 64 (32 байта). +# Положить: vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) +VAULT_CRYPTO_KEY_PATH=crypto/master + # ── JWT ──────────────────────────────────────────────────────────── # Allowed: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512 JWT_ALGORITHM=RS256 diff --git a/Dockerfile b/Dockerfile index 06b1582..692edc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,8 @@ COPY --from=build --chown=app:app /app/apps/api/dist ./dist COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json +RUN mkdir -p /app/logs && chown -R app:app /app/logs + USER app EXPOSE 3001 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e20d23f --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# CryptoWallet API — Deployment Bundle (v5.0 custodial) + +Multi-chain **custodial** wallet API (ETH / BSC / BTC / TRX / SOL). +- Сервер генерит mnemonic, хранит зашифрованной (AES-256-GCM, master-key из HashiCorp Vault) +- Сервер сам подписывает tx по запросу юзера (юзер на клиенте жмёт "подтвердить") + +Auth — JWT (BITOK), секреты — HashiCorp Vault (AppRole). + +## Pre-deploy setup (один раз навсегда) + +```bash +# 1. Master-key в Vault +vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) + +# 2. DB schema +psql -h 72.56.9.76 -U postgres_user -d postgres -f cryptowallet-schema.sql +``` + +⚠️ **Master-key менять нельзя** — все existing encrypted_mnemonic станут нерасшифровываемыми. Сервис логирует WARN если в Vault ключ изменился. + +## Deploy + +```bash +scp -P 2222 -r deployserver/ server@176.124.213.102:~/cryptowallet/ +ssh server@176.124.213.102 -p 2222 +cd ~/cryptowallet && cp .env.example .env && nano .env && ./start.sh +``` + +В `.env` обязательны: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER`, `JWT_AUDIENCE`, `CORS_ORIGINS`. + +## Endpoints (24) + +| Method | Path | Описание | +|---|---|---| +| GET | /api/health | Liveness (public) | +| GET | /api/docs | Swagger UI | +| POST | **/api/wallets/create** | **Сервер создаёт коша** (no body, returns addresses) | +| GET | /api/wallets | Список адресов юзера | +| POST | **/api/wallets/mnemonic/reveal** | Reveal seed (body confirm + 5/час) | +| GET | /api/wallets/{chain}/balance | Баланс | +| GET | /api/wallets/{chain}/transactions | История tx | +| POST | **/api/wallets/{chain}/send** | **Сервер подписывает + broadcast** | +| ... | /api/btc/* /api/tron/* /api/sol/* /api/bsc/* /api/relay/* | Proxy endpoints | + +## Security highlights + +- **AES-256-GCM** для encrypted_mnemonic (12-byte random IV, 16-byte auth tag, fail-secure) +- **Master-key set-once** (rotation запрещена) +- **Race-safe createWallet**: `db.transaction` + `UPDATE WHERE encrypted_mnemonic IS NULL` +- **TRX MITM defense**: local recompute txID + verify raw_data перед подписью +- **EVM gas cap** 500 gwei (применён к tx, не только check) +- **Address checksum validation** (BTC bitcoinjs-lib, TRX bs58check, SOL PublicKey, EVM EIP-55) +- **assertAddressMatch** — derived(mnemonic, path) === DB.address перед подписью +- **SOL confirmTransaction** — ждём подтверждения +- **BTC** P2WPKH bech32, fee fallback 15 sat/vB + 1.1x safety, dust 294, broadcast 20s timeout +- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час + audit-log +- **Logger sanitization**: password/token/mnemonic/hex64/BIP39-phrase patterns +- **Audit log** `logs/audit.log` (wallet.create / wallet.send / mnemonic.reveal) +- **Hourly key rotation**: JWT keys + CSRF secret из Vault (master-key НЕ ротируется) +- **Fail-fast**: сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE + +## Update / Rebuild + +```bash +scp -P 2222 -r deployserver/apps server@176.124.213.102:~/cryptowallet/ +ssh server@176.124.213.102 -p 2222 'cd cryptowallet && docker compose up -d --build' +``` diff --git a/apps/api/package.json b/apps/api/package.json index 0205a6f..09fe938 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,11 +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", @@ -24,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": { diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c8b9fc9..9f80744 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 relayProxyRoutes from './routes/relay-proxy.routes'; @@ -52,6 +52,7 @@ const protect = [authMiddleware, csrfMiddleware]; // Sensitive — самый строгий лимит. Каждый POST защищён 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); // Mutating (proxy + read endpoints) — повышенный лимит diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 85b53e2..193fb51 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -31,11 +31,26 @@ 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 доступа. - origins: (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean), - allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false', + // Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin. + // Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety. + origins: (p.CORS_ORIGINS || '') + .split(',') + .map((o) => o.trim()) + .filter(Boolean) + .filter((o) => { + try { + const u = new URL(o); + return u.protocol === 'https:' || u.protocol === 'http:'; + } catch { + return false; + } + }), + // Default = false (fail-secure). Чтобы включить credentials cross-origin — + // ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true. + allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true', }, port: parseInt(p.API_PORT || '3001'), relayApiKey: p.RELAY_API_KEY || null, diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 60dc2db..c9ac11c 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -1,15 +1,21 @@ 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, auditLogStrict } from '../lib/audit-log'; import { logger } from '../lib/logger'; -const ALLOWED_CHAINS = new Set(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']); -const MAX_WALLETS_PER_REQUEST = 20; -const MAX_DERIVATION_PATH = 64; +const ALLOWED_CHAINS = new Set(ALL_CHAINS); const MAX_TX_LIMIT = 100; -const BIP32_PATH_RE = /^m(\/[0-9]+'?)*$/; + +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); @@ -37,69 +43,153 @@ export const WalletController = { }, /** - * POST /api/wallets/create — non-custodial upsert. - * Клиент сам деривит mnemonic и шлёт массив { chain, address, derivationPath }. - * Сервер валидирует и сохраняет (upsert по user_id+chain). + * POST /api/wallets/create — custodial bootstrap. + * Сервер: генерит mnemonic → деривит адреса 5 chains → шифрует AES-GCM (master из Vault) + * → атомарно (UPDATE WHERE NULL + INSERT в одной транзакции) сохраняет + * Возвращает: ТОЛЬКО адреса. 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); + + 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); + + const created = await db.transaction(async (trx) => { + const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx); + if (!claimed) { + 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) { + // Audit conflict events — surveillance attacker spamming the endpoint + await auditLog({ + event: 'wallet.create', + userId, + ip: req.ip || null, + result: 'failure', + errorCode: 'CONFLICT', + }); + res.status(409).json({ success: false, error: 'Wallet already exists' }); + return; + } + logger.error(`createWallet failed for user ${userId}: ${err.stack || err.message}`); + await auditLog({ + event: 'wallet.create', + userId, + ip: req.ip || null, + result: 'failure', + errorCode: 'INTERNAL', + }); + res.status(500).json({ success: false, error: 'Failed to create wallet' }); + } finally { + mnemonic = null; + } + }, + + /** + * POST /api/wallets/mnemonic/reveal — settings: показать seed. + * Защита: POST + CSRF + body confirm token + rate-limit 5/час + audit-log. + */ + 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; + } + + 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 || - !BIP32_PATH_RE.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, - })), - ); + const mnemonic = decryptMnemonic(blob); - res.status(201).json({ - success: true, - data: rows.map((w) => ({ - chain: w.chain, - address: w.address, - derivationPath: w.derivation_path, - })), - }); + // CRITICAL operation — fail-secure audit (если запись fail'ит — не отдаём mnemonic). + try { + 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}`); + res.status(503).json({ success: false, error: 'Audit service unavailable' }); + return; + } + + res.json({ success: true, data: { mnemonic } }); } catch (err: any) { - logger.error(`createWallets failed for user ${userId}: ${err.stack || err.message}`); - res.status(500).json({ success: false, error: 'Internal error' }); + logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || 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' }); } }, @@ -160,8 +250,9 @@ export const WalletController = { }, /** - * POST /api/wallets/:chain/send — build unsigned tx (non-custodial). - * Возвращает unsigned tx; клиент подписывает приватом и broadcast'ит сам. + * POST /api/wallets/:chain/send — custodial sign + broadcast. + * Юзер жмёт "подтвердить" → клиент шлёт {to, amount, token?} → сервер расшифровывает + * мнемонику, деривит privkey, подписывает, broadcast'ит → возвращает txid. */ async sendFromChain(req: Request, res: Response) { const userId = req.auth!.userId; @@ -172,6 +263,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))) { @@ -192,6 +288,7 @@ export const WalletController = { normalizedToken = token.toUpperCase(); } + let mnemonic: string | null = null; try { const wallet = await WalletModel.findByUserAndChain(userId, chain); if (!wallet) { @@ -199,21 +296,62 @@ 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, + mnemonic, to: String(to), amount: String(amount), token: normalizedToken, + expectedFromAddress: wallet.address, }); - res.json({ success: true, data: tx }); + // CRITICAL operation — fail-secure audit + try { + await auditLogStrict({ + event: 'wallet.send', + userId, + ip: req.ip || null, + result: 'success', + meta: { chain, hasToken: !!normalizedToken, txid: result.txid }, + }); + } 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', + }); + return; + } + + res.json({ success: true, data: { txid: result.txid, chain } }); } catch (err: any) { - logger.error(`buildSend ${chain} failed for user ${userId}: ${err.stack || err.message}`); - const msg = err?.message?.toLowerCase?.().includes('not supported') - ? 'Token/chain combination not supported' - : 'Failed to build transaction'; + logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || 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 { + mnemonic = null; } }, }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d86ed2d..95458a1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,13 +1,31 @@ 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'; +// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets) +process.on('unhandledRejection', (reason: any) => { + logger.error(`Unhandled rejection: ${reason?.stack || reason?.message || reason}`); +}); +process.on('uncaughtException', (err: Error) => { + logger.error(`Uncaught exception: ${err.stack || err.message}`); + // Process state could be corrupt — exit cleanly + process.exit(1); +}); + async function main() { logger.info(`Wallet service instance started with id ${logger.instanceId}`); 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, () => { diff --git a/apps/api/src/lib/audit-log.ts b/apps/api/src/lib/audit-log.ts new file mode 100644 index 0000000..9a73ecd --- /dev/null +++ b/apps/api/src/lib/audit-log.ts @@ -0,0 +1,75 @@ +/** + * Audit log — append-only JSON lines в `logs/audit.log`. + * Используется для критических custodial операций. + * НИКОГДА не логирует mnemonic / privkey / encrypted blob. + */ + +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 { + if (initialized) return; + try { + await fs.mkdir(AUDIT_DIR, { recursive: true }); + const handle = await fs.open(AUDIT_FILE, 'a', 0o600); + await handle.close(); + try { + await fs.chmod(AUDIT_FILE, 0o600); + } catch { + // Windows chmod — игнор + } + initialized = true; + } catch (err: any) { + logger.error(`Audit log init failed: ${err.message}`); + } +} + +export interface AuditEntry { + event: string; + userId: string; + ip?: string | null; + meta?: Record; + result?: 'success' | 'failure'; + errorCode?: string; +} + +/** + * Best-effort write. Если запись провалилась — только log, не throws. + * Используется для не-критических событий (wallet.create success, etc). + */ +export async function auditLog(entry: AuditEntry): Promise { + 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) { + logger.error(`Audit log write failed: ${err.message}`); + } +} + +/** + * Fail-secure write. Если запись провалилась — throws. + * Используется для critical security событий (mnemonic.reveal, wallet.send), + * где compliance требует чтобы операция НЕ происходила без audit-trail. + */ +export async function auditLogStrict(entry: AuditEntry): Promise { + await ensureFile(); + const line = JSON.stringify({ + timestamp: new Date().toISOString(), + trace_id: getTraceId(), + ...entry, + }); + // Без try/catch — caller обрабатывает failure + await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 }); +} diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 4e00edb..32e9081 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -35,14 +35,14 @@ 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: /(role[_-]?id|secret[_-]?id)\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]' }, + // BIP39 mnemonic phrase (12-24 lowercase английских слов через пробел) — case-insensitive + { regex: /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/gi, replace: '[REDACTED_MNEMONIC]' }, // Hex privkey (64 hex chars подряд, optional 0x) { regex: /\b(0x)?[0-9a-fA-F]{64}\b/g, replace: '[REDACTED_HEX64]' }, ]; diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index d3d4241..40f539f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -35,7 +35,9 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc req.auth = await verifyAccessToken(token); next(); } catch (err: any) { + // Лог детали server-side, клиенту — единое generic сообщение. + // Иначе err.message distinguishes "expired" vs "bad signature" vs "kid unknown" → info oracle. logger.warn(`Auth failed: ${err.message}`); - res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' }); + res.status(401).json({ success: false, error: 'Unauthorized' }); } } diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 8425be3..25838e0 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -1,39 +1,61 @@ import { Request, Response, NextFunction } from 'express'; +import { timingSafeEqual } from 'crypto'; import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service'; import { env } from '../config/env'; import { logger } from '../lib/logger'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +/** + * CSRF middleware с double-submit pattern. + * + * Требует ОБА source'а: cookie `csrf_token` AND header `X-CSRF-Token`, + * сравнивает их constant-time. Без обоих или при несовпадении — 403. + * + * Защита: если attacker украл только cookie (auto-sent при cross-site POST), + * он не может выставить header X-CSRF-Token из чужого origin без CORS, + * а CORS у нас явный whitelist. Single-source check был bypass'able. + */ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void { if (SAFE_METHODS.has(req.method)) { next(); return; } - // Если CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем. - // Это явная конфигурация, не fail-open. + // CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем явно. if (!env.vault.csrfPath) { next(); return; } - // CSRF включён, но секрет не загружен (Vault недоступен) → fail-secure: 503. - // НИКОГДА не пропускаем mutating запросы при не-валидном состоянии. + // CSRF включён, но секрет не загружен → fail-secure 503. if (!isCsrfConfigured()) { logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request'); res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' }); return; } - const token = req.cookies?.csrf_token || req.headers['x-csrf-token']; + const cookieToken = req.cookies?.csrf_token; + const headerToken = req.headers['x-csrf-token']; - if (!token || typeof token !== 'string') { - res.status(403).json({ success: false, error: 'CSRF token missing' }); + // Double-submit: ОБА обязательны. + if (!cookieToken || typeof cookieToken !== 'string' || + !headerToken || typeof headerToken !== 'string') { + res.status(403).json({ success: false, error: 'CSRF token missing (need cookie + header)' }); return; } - const result = verifyCsrfToken(token); + // Constant-time сравнение cookie === header (защита от timing oracle). + const a = Buffer.from(cookieToken); + const b = Buffer.from(headerToken); + if (a.length !== b.length || !timingSafeEqual(a, b)) { + logger.warn('CSRF: cookie/header mismatch'); + res.status(403).json({ success: false, error: 'CSRF token mismatch' }); + return; + } + + // HMAC verify только после совпадения двух source'ов. + const result = verifyCsrfToken(cookieToken); if (!result.valid) { logger.warn(`CSRF validation failed: ${result.reason}`); res.status(403).json({ success: false, error: 'Invalid CSRF token' }); diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts index a18fe11..447ad26 100644 --- a/apps/api/src/middleware/rate-limit.ts +++ b/apps/api/src/middleware/rate-limit.ts @@ -31,7 +31,7 @@ export const mutateLimiter = rateLimit({ message: { success: false, error: 'Too many mutating requests' }, }); -// Самый строгий — для send / wallet create (anti-abuse / spam tx prevention) +// Самый строгий — для send / wallet create export const sensitiveLimiter = rateLimit({ windowMs: 60 * 1000, limit: 10, @@ -40,3 +40,13 @@ export const sensitiveLimiter = rateLimit({ keyGenerator: keyByUserOrIp, message: { success: false, error: 'Too many sensitive requests' }, }); + +// Экстремально строгий — reveal seed phrase, 5/час +export const mnemonicRevealLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 5, + standardHeaders: 'draft-7', + legacyHeaders: false, + keyGenerator: keyByUserOrIp, + message: { success: false, error: 'Too many mnemonic reveal requests' }, +}); diff --git a/apps/api/src/middleware/trace.ts b/apps/api/src/middleware/trace.ts index 7b0075a..b04a34c 100644 --- a/apps/api/src/middleware/trace.ts +++ b/apps/api/src/middleware/trace.ts @@ -2,10 +2,15 @@ import { Request, Response, NextFunction } from 'express'; import { generateUlid } from '../utils/ulid'; import { traceStore } from '../lib/trace-store'; +const TRACE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + export function traceMiddleware(req: Request, res: Response, next: NextFunction): void { - const traceId = req.headers['x-trace-id'] as string - || req.headers['x-request-id'] as string - || generateUlid(); + const supplied = (req.headers['x-trace-id'] || req.headers['x-request-id']) as string | undefined; + + // Validate client-supplied trace-ID — иначе log injection / trace forgery + const traceId = (typeof supplied === 'string' && TRACE_ID_RE.test(supplied)) + ? supplied + : generateUlid(); res.setHeader('X-Trace-ID', traceId); diff --git a/apps/api/src/models/session.model.ts b/apps/api/src/models/session.model.ts deleted file mode 100644 index 033940a..0000000 --- a/apps/api/src/models/session.model.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { db } from '../config/database'; -import { generateUlid } from '../utils/ulid'; - -export interface SessionRow { - id: string; - sid: string; - user_id: string; - device_id: string | null; - user_agent: string | null; - first_ip: string | null; - last_ip: string | null; - last_seen_at: Date | null; - revoked_at: Date | null; - refresh_jti_hash: string | null; - refresh_expires_at: Date | null; - created_at: Date; - updated_at: Date; -} - -export const SessionModel = { - async findBySid(sid: string): Promise { - return db('sessions').where({ sid }).whereNull('revoked_at').first(); - }, - - async findByUserId(userId: string): Promise { - return db('sessions').where({ user_id: userId }).whereNull('revoked_at'); - }, - - async create(data: { - sid: string; - user_id: string; - device_id?: string; - user_agent?: string; - first_ip?: string; - refresh_jti_hash?: string; - refresh_expires_at?: Date; - }): Promise { - const [session] = await db('sessions') - .insert({ - id: generateUlid(), - ...data, - last_ip: data.first_ip || null, - }) - .returning('*'); - return session; - }, - - async revoke(sid: string): Promise { - await db('sessions') - .where({ sid }) - .update({ revoked_at: db.fn.now(), updated_at: db.fn.now() }); - }, - - async revokeAllForUser(userId: string): Promise { - await db('sessions') - .where({ user_id: userId }) - .whereNull('revoked_at') - .update({ revoked_at: db.fn.now(), updated_at: db.fn.now() }); - }, - - async updateLastSeen(sid: string, ip: string): Promise { - await db('sessions') - .where({ sid }) - .update({ last_seen_at: db.fn.now(), last_ip: ip, updated_at: db.fn.now() }); - }, -}; diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts index 2e45dd0..f19394d 100644 --- a/apps/api/src/models/user.model.ts +++ b/apps/api/src/models/user.model.ts @@ -1,3 +1,4 @@ +import type { Knex } from 'knex'; import { db } from '../config/database'; export interface UserRow { @@ -17,25 +18,44 @@ export interface UserRow { kyc_verified: boolean; kyc_verified_at: Date | null; is_deleted: boolean; - encrypted_vault: string | null; // legacy, unused - vault_salt: string | null; // legacy, unused - encrypted_mnemonic: string | null; // legacy from custodial-experiment, unused + encrypted_vault: string | null; // legacy, unused + vault_salt: string | null; // legacy, unused + encrypted_mnemonic: string | null; // AES-GCM blob (custodial) created_at: Date; updated_at: Date; } -export const UserModel = { - async findById(id: string): Promise { - return db('users').where({ id, is_deleted: false }).first(); - }, +// Public-safe subset, без encrypted_* / password_hash. +// Используется когда results могут попасть в response. +const PUBLIC_USER_COLUMNS = [ + 'id', + 'email', + 'last_name', + 'first_name', + 'middle_name', + 'birth_date', + 'phone', + 'kyc_verified', + 'kyc_verified_at', + 'is_deleted', + 'created_at', + 'updated_at', +] as const; - async findByEmail(email: string): Promise { - return db('users').where({ email, is_deleted: false }).first(); +export const UserModel = { + /** + * Public-safe lookup. НЕ возвращает encrypted_* / password_hash. + */ + async findById(id: string) { + return db('users') + .where({ id, is_deleted: false }) + .select(...PUBLIC_USER_COLUMNS) + .first(); }, /** * Создать запись пользователя если её нет. - * id берётся из JWT (sub). Email/password_hash — заглушки, реальная auth у BITOK. + * id — из JWT.sub (валидируется на JWT verify). */ async ensureExists(id: string): Promise { await db('users') @@ -48,14 +68,36 @@ export const UserModel = { .ignore(); }, - async update( - id: string, - data: Partial>, - ): Promise { - const [user] = await db('users') - .where({ id }) - .update({ ...data, updated_at: db.fn.now() }) - .returning('*'); - return user; + /** + * Set-once: возвращает true только если этот вызов реально занял slot. + * Защита от race: два параллельных createWallet не могут оба перезаписать. + * Также filter is_deleted=false — не давать zombie-account resurrection. + */ + async setEncryptedMnemonicIfAbsent(id: string, blob: string, trx?: Knex.Transaction): Promise { + const k = trx || db; + const affected = await k('users') + .where({ id, is_deleted: false }) + .whereNull('encrypted_mnemonic') + .update({ + encrypted_mnemonic: blob, + updated_at: k.fn.now(), + }); + return affected === 1; + }, + + async getEncryptedMnemonic(id: string): Promise { + const row = await db('users') + .where({ id, is_deleted: false }) + .select('encrypted_mnemonic') + .first(); + return row?.encrypted_mnemonic ?? null; + }, + + async hasMnemonic(id: string): Promise { + 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); }, }; diff --git a/apps/api/src/models/wallet.model.ts b/apps/api/src/models/wallet.model.ts index 543e080..ed6e7ea 100644 --- a/apps/api/src/models/wallet.model.ts +++ b/apps/api/src/models/wallet.model.ts @@ -20,6 +20,12 @@ export const WalletModel = { return db('wallets').where({ user_id: userId, chain }).first(); }, + /** + * Insert wallets. UNIQUE(user_id, chain) на уровне DB предотвращает дубликаты — + * на конфликт kicks transaction rollback. + * Используется только из createWallet (custodial bootstrap, one-shot per user). + * НЕ используем upsertMany — нет легитимного пути менять адрес после генерации. + */ async createMany( wallets: { user_id: string; chain: string; address: string; derivation_path: string }[], trx?: Knex.Transaction, @@ -27,19 +33,4 @@ export const WalletModel = { const withIds = wallets.map((w) => ({ id: generateUlid(), ...w })); return (trx || db)('wallets').insert(withIds).returning('*'); }, - - /** - * Insert wallets, on conflict (user_id, chain) update address + derivation_path. - * Used by POST /api/wallets — клиент шлёт массив адресов после регистрации в BITOK. - */ - async upsertMany( - wallets: { user_id: string; chain: string; address: string; derivation_path: string }[] - ): Promise { - const withIds = wallets.map((w) => ({ id: generateUlid(), ...w })); - return db('wallets') - .insert(withIds) - .onConflict(['user_id', 'chain']) - .merge(['address', 'derivation_path']) - .returning('*'); - }, }; diff --git a/apps/api/src/routes/bsc-swap-proxy.routes.ts b/apps/api/src/routes/bsc-swap-proxy.routes.ts index 09da839..a9f7d4f 100644 --- a/apps/api/src/routes/bsc-swap-proxy.routes.ts +++ b/apps/api/src/routes/bsc-swap-proxy.routes.ts @@ -85,8 +85,8 @@ async function getSwapQuote(req: Request, res: Response) { toDecimals: TOKEN_DECIMALS[to], }); } catch (error) { - const msg = error instanceof Error ? error.message : 'Failed to get BSC swap quote'; - res.status(502).json({ success: false, error: msg }); + console.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`); + res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' }); } } @@ -113,6 +113,18 @@ async function buildSwapTx(req: Request, res: Response) { return; } + // Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt). + // "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage + // → sandwich attack осушает swap. + if (!/^\d+$/.test(String(amount)) || BigInt(amount) <= 0n) { + res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' }); + return; + } + if (!/^\d+$/.test(String(amountOutMin)) || BigInt(amountOutMin) <= 0n) { + res.status(400).json({ success: false, error: 'Invalid amountOutMin (must be positive; 0 = unlimited slippage)' }); + return; + } + try { const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID); const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider); @@ -145,10 +157,12 @@ async function buildSwapTx(req: Request, res: Response) { ); if (currentAllowance.lt(ethers.BigNumber.from(amount))) { - // Build approve tx + // Exact-amount approve (НЕ MaxUint256!). Infinite approval = post-tx attack vector: + // если router compromised или attacker узнаёт private key позже, attacker дренит + // всё что approved. Approve только то что нужно сейчас. const approveData = tokenContract.interface.encodeFunctionData( 'approve', - [PANCAKE_ROUTER, ethers.constants.MaxUint256] + [PANCAKE_ROUTER, amount] ); transactions.push({ @@ -175,8 +189,8 @@ async function buildSwapTx(req: Request, res: Response) { res.json({ success: true, transactions }); } catch (error) { - const msg = error instanceof Error ? error.message : 'Failed to build BSC swap'; - res.status(502).json({ success: false, error: msg }); + console.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`); + res.status(502).json({ success: false, error: 'Failed to build BSC swap' }); } } diff --git a/apps/api/src/routes/btc-proxy.routes.ts b/apps/api/src/routes/btc-proxy.routes.ts index 4d2fcad..5692f9c 100644 --- a/apps/api/src/routes/btc-proxy.routes.ts +++ b/apps/api/src/routes/btc-proxy.routes.ts @@ -111,7 +111,8 @@ async function getFeeEstimates(_req: Request, res: Response) { async function broadcastTx(req: Request, res: Response) { const { hex } = req.body; - if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex)) { + // BTC max tx serialized ~100KB = 200_000 hex chars. Cap чтобы не abuse'или bandwidth. + if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length > 200_000) { res.status(400).json({ success: false, error: 'Invalid transaction hex' }); return; } @@ -130,7 +131,8 @@ async function broadcastTx(req: Request, res: Response) { const text = await response.text(); if (!response.ok) { - res.status(response.status).json({ success: false, error: text || 'Broadcast failed' }); + // Don't leak Blockstream error body (could contain UTXO state oracle). + res.status(502).json({ success: false, error: 'BTC broadcast failed' }); return; } diff --git a/apps/api/src/routes/relay-proxy.routes.ts b/apps/api/src/routes/relay-proxy.routes.ts index 04183b8..33f675b 100644 --- a/apps/api/src/routes/relay-proxy.routes.ts +++ b/apps/api/src/routes/relay-proxy.routes.ts @@ -1,9 +1,19 @@ import { NextFunction, Request, Response, Router } from 'express'; import { env } from '../config/env'; +import { logger } from '../lib/logger'; const router = Router(); const RELAY_API_URL = 'https://api.relay.link'; -const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']); +const RELAY_TIMEOUT_MS = 20_000; + +// Whitelist: GET-paths + allowed `/execute/` actions. +// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check. +const ALLOWED_GET_PATHS = new Set(['/quote/v2', '/intents/status/v3']); +const ALLOWED_EXECUTE_ACTIONS = new Set([ + 'swap', + 'bridge', + // добавлять по мере необходимости +]); router.use(proxyRelayRequest); @@ -12,7 +22,19 @@ export default router; async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) { try { const relayPath = req.path; - if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) { + + // Whitelist matching — никакого freeform после `/execute/`. + let allowed = false; + if (ALLOWED_GET_PATHS.has(relayPath)) { + allowed = true; + } else if (relayPath.startsWith('/execute/')) { + const action = relayPath.slice('/execute/'.length); + // action: только alphanumeric, никаких слешей/дотов + if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) { + allowed = true; + } + } + if (!allowed) { res.status(404).json({ success: false, error: 'Relay endpoint not allowed' }); return; } @@ -24,29 +46,55 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction value.forEach((item) => relayUrl.searchParams.append(key, String(item))); return; } - if (typeof value !== 'undefined') { relayUrl.searchParams.set(key, String(value)); } }); - const response = await fetch(relayUrl.toString(), { - method: req.method, - headers: { - Accept: 'application/json', - ...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}), - ...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}), - }, - body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}), - }); + // Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely. + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS); - const contentType = response.headers.get('content-type') ?? 'application/json'; - const payload = await response.text(); + let upstream: globalThis.Response; + try { + upstream = await fetch(relayUrl.toString(), { + method: req.method, + headers: { + Accept: 'application/json', + ...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}), + ...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}), + }, + body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}), + signal: controller.signal, + }); + } finally { + clearTimeout(t); + } - res.status(response.status); - res.type(contentType); - res.send(payload); - } catch (error) { - next(error); + // Force JSON content-type — иначе compromised upstream может вернуть text/html + // → reflected XSS если frontend рендерит ответ напрямую. + res.status(upstream.status); + res.type('application/json'); + + const text = await upstream.text(); + if (!upstream.ok) { + logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`); + res.json({ success: false, error: 'Relay upstream error' }); + return; + } + + // Send raw text если это валидный JSON, иначе обернём + try { + res.send(text); + } catch { + res.json({ success: false, error: 'Relay returned non-JSON' }); + } + } catch (error: any) { + if (error?.name === 'AbortError') { + res.status(504).json({ success: false, error: 'Relay request timeout' }); + return; + } + logger.error(`Relay proxy failed: ${error?.stack || error?.message}`); + res.status(502).json({ success: false, error: 'Relay proxy error' }); } } diff --git a/apps/api/src/routes/sol-swap-proxy.routes.ts b/apps/api/src/routes/sol-swap-proxy.routes.ts index d4e5305..027a636 100644 --- a/apps/api/src/routes/sol-swap-proxy.routes.ts +++ b/apps/api/src/routes/sol-swap-proxy.routes.ts @@ -85,8 +85,10 @@ async function getQuote(req: Request, res: Response) { const response = await fetch(url.toString(), { headers, signal: controller.signal }); if (!response.ok) { - const text = await response.text().catch(() => 'Unknown error'); - res.status(response.status).json({ success: false, error: `Jupiter API error: ${text}` }); + const text = await response.text().catch(() => ''); + // НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs) + console.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`); + res.status(502).json({ success: false, error: 'Jupiter upstream error' }); return; } @@ -153,8 +155,9 @@ async function buildSwap(req: Request, res: Response) { }); if (!response.ok) { - const text = await response.text().catch(() => 'Unknown error'); - res.status(response.status).json({ success: false, error: `Jupiter swap build error: ${text}` }); + const text = await response.text().catch(() => ''); + console.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`); + res.status(502).json({ success: false, error: 'Jupiter upstream error' }); return; } diff --git a/apps/api/src/routes/tron-proxy.routes.ts b/apps/api/src/routes/tron-proxy.routes.ts index 3360b59..46b9172 100644 --- a/apps/api/src/routes/tron-proxy.routes.ts +++ b/apps/api/src/routes/tron-proxy.routes.ts @@ -219,6 +219,10 @@ const ALLOWED_TRC_FUNCTIONS = new Set([ * Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction. * Whitelisted contracts + function selectors only. */ +// Максимальный fee_limit для TriggerSmartContract: 1000 TRX = 1_000_000_000 sun. +// Без этого attacker с whitelist-проходящим контрактом мог бы выкачать ресурсы аккаунта. +const MAX_FEE_LIMIT_SUN = 1_000_000_000; + async function triggerSmartContract(req: Request, res: Response) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS); @@ -228,6 +232,9 @@ async function triggerSmartContract(req: Request, res: Response) { const contractAddress = String(body.contract_address || ''); const functionSelector = String(body.function_selector || ''); const ownerAddress = String(body.owner_address || ''); + const parameter = String(body.parameter || ''); + const callValueRaw = body.call_value; + const feeLimitRaw = body.fee_limit; if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) { res.status(403).json({ success: false, error: 'Contract address not allowed' }); @@ -242,6 +249,29 @@ async function triggerSmartContract(req: Request, res: Response) { return; } + // Validate parameter — hex (0-9a-f), без 0x prefix, length определена selector'ом. + if (!/^[0-9a-fA-F]*$/.test(parameter)) { + res.status(400).json({ success: false, error: 'Invalid parameter (must be hex)' }); + return; + } + // Лимит длины — самый длинный whitelist'нутый ABI принимает ~3-4 параметра = 256-512 hex chars + if (parameter.length > 1024) { + res.status(400).json({ success: false, error: 'parameter too long' }); + return; + } + + // Bound fee_limit + call_value + const feeLimit = Number(feeLimitRaw ?? 0); + if (!Number.isFinite(feeLimit) || feeLimit < 0 || feeLimit > MAX_FEE_LIMIT_SUN) { + res.status(400).json({ success: false, error: `fee_limit out of bounds (max ${MAX_FEE_LIMIT_SUN})` }); + return; + } + const callValue = Number(callValueRaw ?? 0); + if (!Number.isFinite(callValue) || callValue < 0) { + res.status(400).json({ success: false, error: 'Invalid call_value' }); + return; + } + const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', @@ -250,11 +280,22 @@ async function triggerSmartContract(req: Request, res: Response) { headers['TRON-PRO-API-KEY'] = env.tronApiKey; } + // ВАЖНО: НЕ forward'им req.body целиком — только validated fields. + const forwardBody = { + owner_address: ownerAddress, + contract_address: contractAddress, + function_selector: functionSelector, + parameter, + fee_limit: feeLimit, + call_value: callValue, + visible: true, + }; + const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, { method: 'POST', headers, signal: controller.signal, - body: JSON.stringify(req.body), + body: JSON.stringify(forwardBody), }); const data = await response.json(); diff --git a/apps/api/src/routes/tron-swap-proxy.routes.ts b/apps/api/src/routes/tron-swap-proxy.routes.ts index 76091a3..62b17bb 100644 --- a/apps/api/src/routes/tron-swap-proxy.routes.ts +++ b/apps/api/src/routes/tron-swap-proxy.routes.ts @@ -308,8 +308,8 @@ async function buildSwapTx(req: Request, res: Response) { res.status(504).json({ success: false, error: 'Build request timed out' }); return; } - const msg = error instanceof Error ? error.message : 'Failed to build swap'; - res.status(502).json({ success: false, error: msg }); + console.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`); + res.status(502).json({ success: false, error: 'Failed to build swap' }); } finally { clearTimeout(timeout); } diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 61b905d..b87970b 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -3,8 +3,9 @@ import { WalletController } from '../controllers/wallet.controller'; const router = Router(); +router.post('/create', WalletController.createWallet); router.get('/', WalletController.getWallets); -router.post('/create', WalletController.createWallets); +router.post('/mnemonic/reveal', WalletController.revealMnemonic); router.get('/:chain/balance', WalletController.getChainBalance); router.get('/:chain/transactions', WalletController.getChainTransactions); diff --git a/apps/api/src/services/crypto.service.ts b/apps/api/src/services/crypto.service.ts new file mode 100644 index 0000000..b70440f --- /dev/null +++ b/apps/api/src/services/crypto.service.ts @@ -0,0 +1,115 @@ +import { randomBytes, createCipheriv, createDecipheriv } 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; +const IV_LEN = 12; +const TAG_LEN = 16; + +let masterKey: Buffer | null = null; + +/** + * Установить master-key (вызывается ОДНОКРАТНО при первом старте). + * Повторная установка после успешной загрузки запрещена (это бы убило все + * существующие 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) { + throw new Error('swapMasterKey: master key already loaded; rotation is not supported'); + } + masterKey = newKey; +} + +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 { + 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"'); + } + 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; +} + +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'); +} + +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 { + throw new Error('decryptMnemonic: authentication failed'); + } +} diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index c663a29..b0d79a0 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -21,7 +21,7 @@ const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time export interface CsrfConfig { secret: string; salt: string; - digest: 'sha1' | 'sha256' | 'sha512'; + digest: 'sha256' | 'sha512'; maxAgeSec: number; } @@ -61,8 +61,9 @@ export async function fetchCsrfConfig( throw new Error('CSRF salt invalid: must be string >= 8 chars'); } - let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512'; - if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') { + // sha1 deprecated — accept только sha256/sha512. + let digest: 'sha256' | 'sha512' = 'sha512'; + if (secrets.digest === 'sha256' || secrets.digest === 'sha512') { digest = secrets.digest; } @@ -87,8 +88,10 @@ function deriveKey(secret: string, salt: string, digest: string): Buffer { function decodeTimestamp(encoded: string): number { const raw = b64urlDecode(encoded); + // Использовать арифметику вместо bitwise — bitwise overflowит на 32-bit signed + // после 2038 если timestamp encoding станет 5-байтным. let ts = 0; - for (const b of raw) ts = (ts << 8) | b; + for (const b of raw) ts = ts * 256 + b; return ts + ITSDANGEROUS_EPOCH; } diff --git a/apps/api/src/services/jwt.service.ts b/apps/api/src/services/jwt.service.ts index c84baa9..7f842c7 100644 --- a/apps/api/src/services/jwt.service.ts +++ b/apps/api/src/services/jwt.service.ts @@ -127,8 +127,13 @@ export async function verifyAccessToken(token: string): Promise { throw Object.assign(new Error('Invalid token type'), { status: 401 }); } - if (!payload.sub || !payload.sid) { - throw Object.assign(new Error('Missing token claims'), { status: 401 }); + // Строгая валидация sub/sid — иначе number/__proto__/10MB строки попадают в PG / в req.auth. + const SUB_RE = /^[A-Za-z0-9_-]{1,64}$/; + if (typeof payload.sub !== 'string' || !SUB_RE.test(payload.sub)) { + throw Object.assign(new Error('Invalid sub claim'), { status: 401 }); + } + if (typeof payload.sid !== 'string' || !SUB_RE.test(payload.sid)) { + throw Object.assign(new Error('Invalid sid claim'), { status: 401 }); } return { diff --git a/apps/api/src/services/key-rotation.service.ts b/apps/api/src/services/key-rotation.service.ts index 305e652..2f1b393 100644 --- a/apps/api/src/services/key-rotation.service.ts +++ b/apps/api/src/services/key-rotation.service.ts @@ -2,27 +2,40 @@ 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 let timer: NodeJS.Timeout | null = null; -let currentVaultToken: string | null = null; + +// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3). +// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto. +let inflight: Promise | null = null; /** - * Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed. - * При любой ошибке оставляем старые значения в памяти, сервис продолжает работать. + * Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены. + * Reentrant-safe. */ export async function refreshAllKeys(): Promise { - const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault; + if (inflight) return inflight; + inflight = doRefresh().finally(() => { + inflight = null; + }); + return inflight; +} + +async function doRefresh(): Promise { + const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault; if (!addr || !roleId || !secretId) { logger.warn('Vault not configured, skipping key refresh'); return; } - // Vault token: используем закэшированный из initEnv, либо логинимся заново - let token = currentVaultToken || getVaultToken(); + // Каждый refresh — свежий Vault token. Старый optimisation с `currentVaultToken` + // был dead code (синхронный reset перед использованием). + let token = getVaultToken(); if (!token) { const fresh = await vaultAppRoleLogin(addr, roleId, secretId); if (!fresh) { @@ -30,16 +43,14 @@ export async function refreshAllKeys(): Promise { return; } token = fresh; - currentVaultToken = fresh; } - // ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ─────────── const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix); const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null); + 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') { logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`); return; @@ -48,16 +59,34 @@ export async function refreshAllKeys(): Promise { logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`); return; } + // Master-key: первый load обязателен, дальнейшие failures толерантны. + if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') { + logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`); + return; + } - // ── Atomic swap (синхронные операции, нельзя прервать) ────────────────── + // Atomic synchronous swap. JS single-threaded — между swap'ами нет await, + // т.е. observers видят либо все старые, либо все новые значения. swapKeyMap(jwtResult.value); if (csrfResult.status === 'fulfilled' && csrfResult.value) { swapCsrfConfig(csrfResult.value); } + 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'}` ); } @@ -68,8 +97,6 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void void refreshAllKeys().catch((err) => logger.error(`Key rotation tick failed: ${err?.message || err}`) ); - // На каждый тик — invalidate Vault token (он мог истечь), будет re-login - currentVaultToken = null; }, intervalMs); logger.info(`Key rotation scheduled (every ${intervalMs}ms)`); } diff --git a/apps/api/src/services/wallet-generator.service.ts b/apps/api/src/services/wallet-generator.service.ts new file mode 100644 index 0000000..2e35a29 --- /dev/null +++ b/apps/api/src/services/wallet-generator.service.ts @@ -0,0 +1,101 @@ +/** + * Wallet generation: BIP39 mnemonic + multi-chain address derivation. + * Server-side для custodial-флоу. + */ + +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 = { + 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; +} + +export function generateMnemonic(): string { + return bip39.generateMnemonic(128); +} + +export function validateMnemonic(m: string): boolean { + return bip39.validateMnemonic(m); +} + +export async function deriveAllAddresses(mnemonic: string): Promise { + if (!bip39.validateMnemonic(mnemonic)) { + throw new Error('Invalid mnemonic'); + } + + const seed = await bip39.mnemonicToSeed(mnemonic); + const seedHex = seed.toString('hex'); + + // ETH (BSC shares) + const ethWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.ETH); + const ethAddress = ethers.utils.getAddress(ethWallet.address); + + // BTC P2WPKH bech32 + const btcRoot = bip32.fromSeed(seed); + const btcChild = btcRoot.derivePath(DERIVATION_PATHS.BTC); + if (!btcChild.publicKey) throw new Error('BTC derivation failed'); + const btcPayment = bitcoin.payments.p2wpkh({ + pubkey: Buffer.from(btcChild.publicKey), + network: bitcoin.networks.bitcoin, + }); + if (!btcPayment.address) throw new Error('BTC payment derivation failed'); + + // TRX (same secp256k1 + keccak256 as ETH, different encoding) + const trxWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.TRX); + const trxAddress = ethAddressToTron(trxWallet.address); + + // SOL (ed25519) + 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 address (0x...) → TRX base58check (T...). + * Используют одну curve и keccak256-derivation; различается только prefix (0x41) + encoding. + */ +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]); + 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]))); +} diff --git a/apps/api/src/services/wallet-ops.service.ts b/apps/api/src/services/wallet-ops.service.ts index fcc2f07..f34117d 100644 --- a/apps/api/src/services/wallet-ops.service.ts +++ b/apps/api/src/services/wallet-ops.service.ts @@ -1,6 +1,6 @@ /** - * Wallet operations across chains: balance, transactions, build unsigned send tx. - * Non-custodial: server NEVER signs — клиент подписывает приватом. + * Wallet read-only operations across chains: balance + tx history. + * Server-side signing now lives in `wallet-signer.service.ts` (custodial). */ import { ethers } from 'ethers'; import { env } from '../config/env'; @@ -250,14 +250,16 @@ async function solTransactions(address: string, limit: number): Promise { + 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 { + 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) ─── + +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); + + 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)`); + } + + const nonce = await provider.getTransactionCount(wallet.address, 'pending'); + + // Принудительно EIP-1559 (type 2). ETH и BSC оба поддерживают с 2021. + // Если feeData не вернул maxFeePerGas — fallback но всё равно type 2 с computed cap. + const maxFeePerGas = feeData.maxFeePerGas ?? effectiveGasPrice; + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.BigNumber.from(0); + if (maxFeePerGas.gt(capWei)) { + throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`); + } + const feeFields: Partial = { + type: 2, + maxFeePerGas, + maxPriorityFeePerGas, + }; + + let tx: ethers.providers.TransactionRequest; + if (!p.token) { + const value = ethers.BigNumber.from(p.amount); + const balance = await provider.getBalance(wallet.address); + const estGas = ethers.BigNumber.from(21000); + 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') { + 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); + 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()); + + try { + await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed'); + } catch (err: any) { + throw new Error(`SOL tx submitted but confirmation timed out (sig=${sig}): ${err.message}`); + } + + return { txid: sig }; +} + +// ─── BITCOIN ─── + +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'); + + 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); + const feeMap = feesRes as Record; + // Floor 15 sat/vB — иначе tx может реджектиться по min-relay-fee при congestion. + const feeRate = Math.max(Math.ceil(feeMap['1'] ?? feeMap['3'] ?? feeMap['6'] ?? 15), 15); + + const amountSat = BigInt(p.amount); + if (amountSat > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('BTC amount exceeds safe integer range'); + } + + utxos.sort((a, b) => b.value - a.value); + + const psbt = new bitcoin.Psbt({ network }); + let totalIn = 0n; + + 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; + 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(); + + 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 = { '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)'); + } + + // ── MITM defense: 4-layer validation против compromised RPC ── + + // 1. Recompute txID = SHA256(raw_data_hex) локально. + // Если RPC лжёт о txID → attacker мог подсунуть 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. Expiration / timestamp bounds (защита от long-term replay). + // TRON default expiration ~60s. Если RPC выставил годовой expiration — + // подписанная tx replay'ится годами через скомпрометированную сеть. + const nowMs = Date.now(); + const expiration = Number(txBody.raw_data.expiration); + const timestamp = Number(txBody.raw_data.timestamp); + if (!Number.isFinite(expiration) || !Number.isFinite(timestamp)) { + throw new Error('TRX tx malformed (no expiration/timestamp)'); + } + if (expiration - nowMs > 90_000 || expiration <= nowMs) { + throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`); + } + if (Math.abs(timestamp - nowMs) > 30_000) { + throw new Error(`TRX timestamp drift too large: ${timestamp - nowMs}ms`); + } + + // 3. Verify contract[0].type (защита от swap'а TransferContract → TriggerSmartContract). + // Без этого RPC может вернуть TriggerSmartContract с косметическими полями to_address/amount. + const contract0 = txBody.raw_data.contract?.[0]; + if (!contract0) { + throw new Error('TRX tx malformed (no contract[0])'); + } + const expectedType = p.token ? 'TriggerSmartContract' : 'TransferContract'; + if (contract0.type !== expectedType) { + throw new Error(`TRX contract type mismatch: expected ${expectedType}, got ${contract0.type}`); + } + + // 4. Verify parameter.value (to / amount / contract_address / selector / data). + const contractValue = contract0.parameter?.value; + if (!contractValue) { + throw new Error('TRX tx malformed (no contract value)'); + } + if (contractValue.owner_address !== fromTronAddr) { + throw new Error(`TRX owner_address mismatch: expected ${fromTronAddr}, got ${contractValue.owner_address}`); + } + if (!p.token) { + 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 { + if (contractValue.contract_address !== USDT_TRC20) { + throw new Error(`TRX contract mismatch: expected ${USDT_TRC20}, got ${contractValue.contract_address}`); + } + const data = String(contractValue.data || ''); + if (data.length !== 128 + 8) { + throw new Error('TRX trc20 data length wrong'); + } + // ВАЖНО: verify method selector (a9059cbb = transfer). Без этого RPC может вернуть + // approve(095ea7b3) — тот же layout (address,uint256) — и юзер approve'ит unlimited. + if (data.slice(0, 8).toLowerCase() !== 'a9059cbb') { + throw new Error(`TRX trc20 selector mismatch: expected a9059cbb (transfer), got ${data.slice(0, 8)}`); + } + const expectedParam = + tronAddressToHex(p.to).padStart(64, '0') + + BigInt(p.amount).toString(16).padStart(64, '0'); + const actualParam = data.slice(8); + if (actualParam.toLowerCase() !== expectedParam.toLowerCase()) { + throw new Error('TRX trc20 parameter mismatch (to/amount tampering)'); + } + } + + // Sign verified 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); +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + 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); + } +} diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 6be3a42..c4bf61a 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -2,15 +2,15 @@ "openapi": "3.0.0", "info": { "title": "CryptoWallet API", - "version": "4.0.0", - "description": "Multi-chain crypto wallet API (non-custodial). Клиент сам деривит mnemonic и шлёт публичные адреса; сервер хранит только адреса и строит unsigned tx для отправки. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK)." + "version": "5.0.0", + "description": "Multi-chain custodial wallet API (ETH/BSC/BTC/TRX/SOL). Сервер генерит mnemonic, шифрует AES-256-GCM (master-key из HashiCorp Vault), хранит её и сам подписывает транзакции. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK)." }, "servers": [ { "url": "/api", "description": "API root" } ], "tags": [ { "name": "System", "description": "Health & service info" }, - { "name": "Wallets", "description": "User wallet records" }, + { "name": "Wallets", "description": "Custodial wallet lifecycle" }, { "name": "Wallet Ops", "description": "Per-chain balance / transactions / send" }, { "name": "BTC", "description": "Bitcoin RPC proxy (Blockstream)" }, { "name": "TRON", "description": "TRON RPC proxy (TronGrid)" }, @@ -32,10 +32,6 @@ "error": { "type": "string" } } }, - "SuccessEmpty": { - "type": "object", - "properties": { "success": { "type": "boolean", "example": true } } - }, "HealthResponse": { "type": "object", "properties": { @@ -52,26 +48,7 @@ "properties": { "chain": { "$ref": "#/components/schemas/Chain" }, "address": { "type": "string" }, - "derivationPath": { "type": "string", "description": "BIP32 path, например m/44'/60'/0'/0/0" } - } - }, - "WalletInput": { - "type": "object", - "required": ["chain", "address", "derivationPath"], - "properties": { - "chain": { "$ref": "#/components/schemas/Chain" }, - "address": { "type": "string", "maxLength": 64, "description": "Публичный адрес (chain-specific checksum-валидируется)" }, - "derivationPath": { "type": "string", "maxLength": 64, "description": "BIP32 m/.. (например m/44'/60'/0'/0/0)" } - } - }, - "CreateWalletsRequest": { - "type": "object", - "required": ["wallets"], - "properties": { - "wallets": { - "type": "array", "minItems": 1, "maxItems": 20, - "items": { "$ref": "#/components/schemas/WalletInput" } - } + "derivationPath": { "type": "string", "description": "BIP32 path" } } }, "WalletsResponse": { @@ -81,6 +58,31 @@ "data": { "type": "array", "items": { "$ref": "#/components/schemas/Wallet" } } } }, + "MnemonicResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { + "type": "object", + "properties": { + "mnemonic": { "type": "string", "description": "BIP39 mnemonic (12 words)" } + } + } + } + }, + "TxBroadcastResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { + "type": "object", + "properties": { + "txid": { "type": "string", "description": "Идентификатор отправленной транзакции" }, + "chain": { "$ref": "#/components/schemas/Chain" } + } + } + } + }, "BalanceResponse": { "type": "object", "properties": { @@ -125,17 +127,7 @@ "properties": { "to": { "type": "string", "description": "Recipient address" }, "amount": { "type": "string", "description": "Amount в smallest units" }, - "token": { "type": "string", "nullable": true, "description": "Например USDT для TRC20/ERC20/BEP20. Без token = native (TRX/ETH/BNB/BTC)" } - } - }, - "UnsignedTxResponse": { - "type": "object", - "properties": { - "success": { "type": "boolean" }, - "data": { - "type": "object", - "description": "Unsigned tx — формат зависит от chain (kind: btc | tron | evm | solana). Клиент подписывает приватом и broadcast'ит через соответствующий /api/{btc,tron}/broadcast endpoint или RPC своей цепи." - } + "token": { "type": "string", "nullable": true, "description": "USDT для TRC20/ERC20/BEP20. Без token = native." } } } } @@ -167,17 +159,44 @@ "/wallets/create": { "post": { - "summary": "Upsert wallets для авторизованного юзера", - "description": "Клиент сам генерит mnemonic и деривит публичные адреса (BIP44 для ETH/BSC/BTC/TRX/SOL). Тело — массив `{chain, address, derivationPath}`. На конфликт (user_id, chain) сервер обновляет address+derivationPath. Mnemonic клиенту не нужно слать — сервер её не хранит.", + "summary": "Создать custodial-кошелёк (server-side mnemonic)", + "description": "**Тело запроса не требуется.** Сервер генерит BIP39 mnemonic (12 слов), деривит адреса для 5 chains (BIP44: ETH 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', BSC = ETH path), шифрует mnemonic AES-256-GCM (master-key из HashiCorp Vault) и атомарно сохраняет. **Возвращает ТОЛЬКО адреса** — mnemonic клиенту не отдаётся. Чтобы потом увидеть seed — отдельный endpoint POST /wallets/mnemonic/reveal. Идемпотентность: 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-confirmation. Rate-limit 5/час per-user. Каждый запрос пишется в 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 (chain/address/derivationPath)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "401": { "description": "Not authenticated" } + "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" } } } }, @@ -212,8 +231,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": "Юзер на клиенте жмёт 'подтвердить' → клиент шлёт {to, amount, token?}. Сервер расшифровывает мнемонику, деривит chain privkey, подписывает, broadcast'ит. Возвращает txid. Защита: TRX MITM check, EVM gas cap 500 gwei, SOL confirmTransaction, BTC timeout + safety multiplier.", "tags": ["Wallet Ops"], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], "requestBody": { @@ -221,10 +240,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 (insufficient balance / RPC error / unsupported)" }, + "503": { "description": "Crypto service not ready" } } } }, diff --git a/cryptowallet-schema.sql b/cryptowallet-schema.sql index 6b7b61b..30cadbc 100644 --- a/cryptowallet-schema.sql +++ b/cryptowallet-schema.sql @@ -1,4 +1,4 @@ --- CryptoWallet API — DB schema (idempotent, non-custodial v4.0) +-- CryptoWallet API — DB schema (idempotent, custodial v5.0) CREATE TABLE IF NOT EXISTS users ( id VARCHAR(26) PRIMARY KEY, @@ -17,13 +17,36 @@ CREATE TABLE IF NOT EXISTS users ( kyc_verified BOOLEAN NOT NULL DEFAULT FALSE, kyc_verified_at TIMESTAMPTZ, is_deleted BOOLEAN NOT NULL DEFAULT FALSE, - encrypted_vault TEXT, -- legacy, unused - vault_salt VARCHAR(128), -- legacy, unused - encrypted_mnemonic TEXT, -- legacy, unused + encrypted_vault TEXT, -- legacy + vault_salt VARCHAR(128), -- legacy + encrypted_mnemonic TEXT, -- AES-256-GCM blob (custodial) created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic' + ) THEN + ALTER TABLE users ADD COLUMN encrypted_mnemonic TEXT; + END IF; +END $$; + +-- AES-GCM blob: 12 IV + plaintext + 16 tag. +-- 12-word mnemonic ~ 116 байт = ~156 base64 chars; 24-word ~ 212 байт = ~284 chars. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size' + ) THEN + ALTER TABLE users + ADD CONSTRAINT users_encrypted_mnemonic_size + CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 140 AND 512)); + END IF; +END $$; + CREATE TABLE IF NOT EXISTS wallets ( id VARCHAR(26) PRIMARY KEY, user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -36,12 +59,6 @@ CREATE TABLE IF NOT EXISTS wallets ( CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); -CREATE TABLE IF NOT EXISTS sessions ( - id VARCHAR(26) PRIMARY KEY, - user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +-- sessions table removed — JWT-stateless, не используется в коде. +-- Если существует от старой версии — оператор может drop вручную: +-- DROP TABLE IF EXISTS sessions CASCADE; diff --git a/docker-compose.yml b/docker-compose.yml index b1437f5..e2ce3e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,14 +5,29 @@ services: dockerfile: Dockerfile container_name: cryptowallet-api restart: unless-stopped + # Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy/Nginx). + # Если нужно direct exposure для dev — поменяй на "3001:3001" локально. ports: - - "3001:3001" + - "127.0.0.1:3001:3001" env_file: - .env environment: API_PORT: "3001" + volumes: + - ./logs:/app/logs + # Container hardening — post-RCE blast radius minimization + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + pids_limit: 256 + mem_limit: 512m + cpus: "1.0" healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"] + test: ["CMD", "wget", "-qO-", "--tries=1", "--timeout=3", "http://localhost:3001/api/health"] interval: 10s timeout: 5s retries: 5 diff --git a/package.json b/package.json index 3f07c53..abd1eec 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "turbo": "^2.4.0" }, "pnpm": { - "onlyBuiltDependencies": ["bcrypt"], + "onlyBuiltDependencies": ["tiny-secp256k1"], "overrides": { "ethers": "5.7.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a390ec4..5948401 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@solana/web3.js': specifier: ^1.98.4 version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6) + bip32: + specifier: ^4.0.0 + version: 4.0.0 + bip39: + specifier: ^3.1.0 + version: 3.1.0 bitcoinjs-lib: specifier: ^6.1.5 version: 6.1.7 @@ -35,6 +41,9 @@ importers: dotenv: specifier: ^16.4.0 version: 16.6.1 + ed25519-hd-key: + specifier: ^1.3.0 + version: 1.3.0 ethers: specifier: 5.7.2 version: 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -59,6 +68,9 @@ importers: swagger-ui-express: specifier: ^5.0.1 version: 5.0.1(express@4.22.1) + tiny-secp256k1: + specifier: ^2.2.3 + version: 2.2.4 ulidx: specifier: ^2.4.1 version: 2.4.1 @@ -339,6 +351,9 @@ packages: '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@scure/base@1.2.6': + resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@solana/buffer-layout@4.0.1': resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} engines: {node: '>=5.10'} @@ -553,6 +568,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -582,6 +601,13 @@ packages: resolution: {integrity: sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==} engines: {node: '>=8.0.0'} + bip32@4.0.0: + resolution: {integrity: sha512-aOGy88DDlVUhspIXJN+dVEtclhIsfAUppD43V0j40cPTld3pv/0X/MlrZSZ6jowIaQQzFwP8M6rFU2z2mVYjDQ==} + engines: {node: '>=6.0.0'} + + bip39@3.1.0: + resolution: {integrity: sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==} + bitcoinjs-lib@6.1.7: resolution: {integrity: sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==} engines: {node: '>=8.0.0'} @@ -621,6 +647,9 @@ packages: bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + bs58check@2.1.2: + resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} + bs58check@3.0.1: resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==} @@ -642,6 +671,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} @@ -662,6 +695,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + cipher-base@1.0.7: + resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} + engines: {node: '>= 0.10'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -708,10 +745,19 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + + create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -739,6 +785,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + delay@5.0.0: resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} @@ -774,6 +824,9 @@ packages: dynamic-dedupe@0.3.0: resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + ed25519-hd-key@1.3.0: + resolution: {integrity: sha512-IWwAyiiuJQhgu3L8NaHb68eJxTu2pgCwxIBdgpLJdKpYZM46+AXePSVTr7fkNKaUOfOL4IrjEUaQvyVRIDP7fg==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -920,6 +973,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -985,10 +1042,21 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-base@3.1.2: + resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} + engines: {node: '>= 0.8'} + hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -1052,6 +1120,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -1072,6 +1144,16 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1162,6 +1244,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -1346,6 +1431,10 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -1366,6 +1455,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1389,6 +1481,9 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -1424,12 +1519,19 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + ripemd160@2.0.3: + resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} + engines: {node: '>= 0.8'} + rpc-websockets@9.3.8: resolution: {integrity: sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==} run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1452,9 +1554,18 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1504,6 +1615,9 @@ packages: stream-json@1.9.1: resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1555,6 +1669,14 @@ packages: resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} engines: {node: '>=8'} + tiny-secp256k1@2.2.4: + resolution: {integrity: sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q==} + engines: {node: '>=14.0.0'} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1641,6 +1763,9 @@ packages: resolution: {integrity: sha512-ERZf7pKOR155NKs/PZt1+83NrSEJfUL7+p9/TGZg/8xzDVMntXEFQlX4CsNJQTyu4h3j+dZYiQWOOlv5pssuHQ==} hasBin: true + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1653,6 +1778,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + typeforce@1.18.0: resolution: {integrity: sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==} @@ -1661,6 +1790,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-tools@0.0.7: + resolution: {integrity: sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==} + engines: {node: '>=14.0.0'} + ulidx@2.4.1: resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} engines: {node: '>=16'} @@ -1679,6 +1812,9 @@ packages: resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} engines: {node: '>=6.14.2'} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -1708,11 +1844,18 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + wif@2.0.6: + resolution: {integrity: sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2278,6 +2421,8 @@ snapshots: '@scarf/scarf@1.4.0': {} + '@scure/base@1.2.6': {} + '@solana/buffer-layout@4.0.1': dependencies: buffer: 6.0.3 @@ -2534,6 +2679,10 @@ snapshots: array-union@2.1.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + balanced-match@1.0.2: {} base-x@3.0.11: @@ -2554,6 +2703,17 @@ snapshots: bip174@2.1.1: {} + bip32@4.0.0: + dependencies: + '@noble/hashes': 1.8.0 + '@scure/base': 1.2.6 + typeforce: 1.18.0 + wif: 2.0.6 + + bip39@3.1.0: + dependencies: + '@noble/hashes': 1.8.0 + bitcoinjs-lib@6.1.7: dependencies: '@noble/hashes': 1.8.0 @@ -2617,6 +2777,12 @@ snapshots: dependencies: base-x: 5.0.1 + bs58check@2.1.2: + dependencies: + bs58: 4.0.1 + create-hash: 1.2.0 + safe-buffer: 5.2.1 + bs58check@3.0.1: dependencies: '@noble/hashes': 1.8.0 @@ -2641,6 +2807,13 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2667,6 +2840,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + cipher-base@1.0.7: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2700,11 +2879,30 @@ snapshots: cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + create-hash@1.2.0: + dependencies: + cipher-base: 1.0.7 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.3 + sha.js: 2.4.12 + + create-hmac@1.1.7: + dependencies: + cipher-base: 1.0.7 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + create-require@1.1.1: {} cross-spawn@7.0.6: @@ -2723,6 +2921,12 @@ snapshots: deep-is@0.1.4: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + delay@5.0.0: {} depd@2.0.0: {} @@ -2751,6 +2955,11 @@ snapshots: dependencies: xtend: 4.0.2 + ed25519-hd-key@1.3.0: + dependencies: + create-hmac: 1.1.7 + tweetnacl: 1.0.3 + ee-first@1.1.1: {} elliptic@6.5.4: @@ -3001,6 +3210,10 @@ snapshots: flatted@3.4.2: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -3070,8 +3283,23 @@ snapshots: has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-base@3.1.2: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + hash.js@1.1.7: dependencies: inherits: 2.0.4 @@ -3133,6 +3361,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -3147,6 +3377,14 @@ snapshots: is-path-inside@3.0.3: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + isexe@2.0.0: {} isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)): @@ -3231,6 +3469,12 @@ snapshots: math-intrinsics@1.1.0: {} + md5.js@1.3.5: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + safe-buffer: 5.2.1 + media-typer@0.3.0: {} merge-descriptors@1.0.3: {} @@ -3373,6 +3617,8 @@ snapshots: picomatch@2.3.1: {} + possible-typed-array-names@1.1.0: {} + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -3385,6 +3631,8 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -3407,6 +3655,16 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -3435,6 +3693,11 @@ snapshots: dependencies: glob: 7.2.3 + ripemd160@2.0.3: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + rpc-websockets@9.3.8: dependencies: '@swc/helpers': 0.5.21 @@ -3452,6 +3715,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} @@ -3487,8 +3752,23 @@ snapshots: transitivePeerDependencies: - supports-color + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + setprototypeof@1.2.0: {} + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3542,6 +3822,10 @@ snapshots: dependencies: stream-chain: 2.2.5 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -3577,6 +3861,16 @@ snapshots: tildify@2.0.0: {} + tiny-secp256k1@2.2.4: + dependencies: + uint8array-tools: 0.0.7 + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3663,6 +3957,8 @@ snapshots: turbo-windows-64: 2.8.15 turbo-windows-arm64: 2.8.15 + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3674,10 +3970,18 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typeforce@1.18.0: {} typescript@5.9.3: {} + uint8array-tools@0.0.7: {} + ulidx@2.4.1: dependencies: layerr: 3.0.0 @@ -3695,6 +3999,8 @@ snapshots: node-gyp-build: 4.8.4 optional: true + util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} uuid@11.1.1: {} @@ -3716,10 +4022,24 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 + wif@2.0.6: + dependencies: + bs58check: 2.1.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} diff --git a/start.sh b/start.sh index b4b4b93..d7c84b7 100644 --- a/start.sh +++ b/start.sh @@ -6,10 +6,12 @@ cd "$(dirname "$0")" command -v docker >/dev/null 2>&1 || { echo "[ERROR] Docker not installed"; exit 1; } docker compose version >/dev/null 2>&1 || { echo "[ERROR] docker compose plugin missing"; exit 1; } +# .env handling if [ ! -f .env ]; then if [ -f .env.example ]; then cp .env.example .env - echo "[INFO] .env создан из примера — заполни Vault креды и запусти снова" + chmod 600 .env + echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова" exit 1 else echo "[ERROR] нет ни .env, ни .env.example" @@ -17,25 +19,41 @@ if [ ! -f .env ]; then fi fi +# Защита: .env должен быть 600 (только владелец) — содержит Vault role/secret IDs. +ENV_MODE=$(stat -c %a .env 2>/dev/null || stat -f %A .env 2>/dev/null) +if [ "$ENV_MODE" != "600" ]; then + echo "[WARN] .env mode is $ENV_MODE, enforcing 600" + chmod 600 .env +fi + +# Logs dir для audit-log mount — container's app user is uid 1001 +mkdir -p logs +chmod 750 logs +# Если есть права — попытаться выставить нужный owner (требует sudo на host) +if [ "$(stat -c %u logs 2>/dev/null)" != "1001" ]; then + chown 1001:1001 logs 2>/dev/null || echo "[INFO] chown logs 1001:1001 пропущен (нет прав; audit может не писаться)" +fi + echo "[INFO] Building and starting containers..." docker compose up -d --build echo "[INFO] Waiting for API to become healthy..." for i in $(seq 1 30); do - if curl -sf http://localhost:3001/api/health >/dev/null 2>&1; then + if curl -sf http://127.0.0.1:3001/api/health >/dev/null 2>&1; then echo "[OK] API is healthy" break fi if [ "$i" = "30" ]; then - echo "[ERROR] API not healthy after 60s. Logs:" - docker compose logs --tail=50 api + echo "[ERROR] API not healthy after 60s. Запусти 'docker compose logs --tail=50 api' для диагностики." exit 1 fi sleep 2 done echo "" -echo "API: http://localhost:3001" -echo "Health: http://localhost:3001/api/health" -echo "Docs: http://localhost:3001/api/docs" -echo "Logs: docker compose logs -f api" +echo "API (loopback only): http://127.0.0.1:3001" +echo " Перед публичным доступом → настрой reverse proxy (Caddy/Nginx) с TLS." +echo "Health: http://127.0.0.1:3001/api/health" +echo "Docs: http://127.0.0.1:3001/api/docs" +echo "Logs: docker compose logs -f api" +echo "Audit: tail -f logs/audit.log"