diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e85549b..8e6e1bc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,8 +6,7 @@ import swaggerUi from 'swagger-ui-express'; import { env } from './config/env'; import { swaggerSpec } from './config/swagger'; import { traceMiddleware } from './middleware/trace'; -import { authMiddleware } from './middleware/auth'; -import { csrfMiddleware } from './middleware/csrf'; +import { fixedUserMiddleware } from './middleware/fixed-user'; import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit'; import { errorHandler } from './middleware/error-handler'; import { WalletController } from './controllers/wallet.controller'; @@ -82,24 +81,21 @@ app.get('/api/docs/swagger.json', docsGate, (_req, res) => { }); app.use('/api/docs', docsGate, swaggerUi.serve, swaggerUi.setup(swaggerSpec)); -// ── PROTECTED endpoints (JWT + CSRF) ───────────────────────────────────────── -const protect = [authMiddleware, csrfMiddleware]; +const identify = [fixedUserMiddleware]; app.post('/api/wallets/create', sensitiveLimiter, WalletController.createWallet); -app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter); -app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter); +app.use('/api/wallets/mnemonic/reveal', ...identify, mnemonicRevealLimiter); +app.use('/api/wallets/:chain/send', ...identify, sensitiveLimiter); -// Mutating (proxy + read endpoints) — повышенный лимит -app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes); -app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes); -app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes); -app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes); -app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes); -app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes); -app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes); +app.use('/api/wallets', ...identify, mutateLimiter, walletRoutes); +app.use('/api/relay', ...identify, mutateLimiter, relayProxyRoutes); +app.use('/api/tron', ...identify, mutateLimiter, tronProxyRoutes); +app.use('/api/sol/swap', ...identify, mutateLimiter, solSwapProxyRoutes); +app.use('/api/tron/swap', ...identify, mutateLimiter, tronSwapProxyRoutes); +app.use('/api/btc', ...identify, mutateLimiter, btcProxyRoutes); +app.use('/api/bsc/swap', ...identify, mutateLimiter, bscSwapProxyRoutes); -// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols. -app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes); +app.use('/api/prices', ...identify, mutateLimiter, pricesRoutes); // 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text app.use((_req, res) => { diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index af6dcd5..9528d03 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -14,10 +14,10 @@ import { acquireSendLock } from '../lib/send-lock'; import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency'; import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log'; import { logger } from '../lib/logger'; +import { FIXED_API_USER_ID } from '../middleware/fixed-user'; const ALLOWED_CHAINS = new Set(ALL_CHAINS); const MAX_TX_LIMIT = 100; -const HARDCODED_CREATE_WALLET_USER_ID = '01KR4V0RPJYPBHPRNY31GSZHXG'; class ConflictError extends Error { constructor() { super('Wallet already exists'); } @@ -33,7 +33,7 @@ export const WalletController = { */ async getWallets(req: Request, res: Response) { try { - const wallets = await WalletModel.findByUserId(req.auth!.userId); + const wallets = await WalletModel.findByUserId(FIXED_API_USER_ID); res.json({ success: true, data: wallets.map((w) => ({ @@ -43,7 +43,7 @@ export const WalletController = { })), }); } catch (err: any) { - logger.error(`getWallets failed for user ${req.auth!.userId}: ${err.stack || err.message}`); + logger.error(`getWallets failed for user ${FIXED_API_USER_ID}: ${err.stack || err.message}`); res.status(500).json({ success: false, error: 'Internal error' }); } }, @@ -55,7 +55,7 @@ export const WalletController = { * Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём. */ async createWallet(req: Request, res: Response) { - const userId = HARDCODED_CREATE_WALLET_USER_ID; + const userId = FIXED_API_USER_ID; if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); @@ -161,7 +161,7 @@ export const WalletController = { * Защита: POST + CSRF + body confirm token + rate-limit 5/час + audit-log. */ async revealMnemonic(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); @@ -237,7 +237,7 @@ export const WalletController = { * GET /api/wallets/:chain/balance */ async getChainBalance(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; const chain = String(req.params.chain).toUpperCase(); if (!isChain(chain)) { @@ -264,7 +264,7 @@ export const WalletController = { * GET /api/wallets/:chain/transactions */ async getChainTransactions(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; const chain = String(req.params.chain).toUpperCase(); if (!isChain(chain)) { @@ -295,7 +295,7 @@ export const WalletController = { * мнемонику, деривит privkey, подписывает, broadcast'ит → возвращает txid. */ async sendFromChain(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; const chain = String(req.params.chain).toUpperCase(); if (!isChain(chain)) { @@ -471,7 +471,7 @@ export const WalletController = { * нужно whitelist'ить `to` или требовать Relay attestation. */ async signRawEvmTx(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; const chain = String(req.params.chain).toUpperCase(); if (chain !== 'ETH' && chain !== 'BSC') { @@ -633,7 +633,7 @@ export const WalletController = { * Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses. */ async swapOnChain(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; const chain = String(req.params.chain).toUpperCase(); if (chain !== 'BSC' && chain !== 'TRX' && chain !== 'SOL') { res.status(400).json({ success: false, error: 'Swap supported only on BSC, TRX, SOL. For ETH use Relay quote→execute→sign-raw-evm-tx.' }); @@ -763,7 +763,7 @@ export const WalletController = { * Body: { transaction: '' } */ async signSolanaTx(req: Request, res: Response) { - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; if (!isCryptoReady()) { res.status(503).json({ success: false, error: 'Crypto service not ready' }); return; diff --git a/apps/api/src/middleware/fixed-user.ts b/apps/api/src/middleware/fixed-user.ts new file mode 100644 index 0000000..36d039a --- /dev/null +++ b/apps/api/src/middleware/fixed-user.ts @@ -0,0 +1,25 @@ +import { Request, Response, NextFunction } from 'express'; +import type { AuthContext } from '../services/jwt.service'; + + +export const FIXED_API_USER_ID = '01KR4V0RPJYPBHPRNY31GSZHXG'; + + +const FIXED_AUTH: AuthContext = { + userId: FIXED_API_USER_ID, + sid: 'fixed', + token: { + sub: FIXED_API_USER_ID, + type: 'access', + sid: 'fixed', + iat: 0, + nbf: 0, + exp: 4102444800, + }, +}; + + +export function fixedUserMiddleware(req: Request, _res: Response, next: NextFunction): void { + req.auth = FIXED_AUTH; + next(); +} diff --git a/apps/api/src/routes/bsc-swap-proxy.routes.ts b/apps/api/src/routes/bsc-swap-proxy.routes.ts index e01f0d4..fcd1f0e 100644 --- a/apps/api/src/routes/bsc-swap-proxy.routes.ts +++ b/apps/api/src/routes/bsc-swap-proxy.routes.ts @@ -1,6 +1,7 @@ import { Request, Response, Router } from 'express'; import { ethers } from 'ethers'; import { logger } from '../lib/logger'; +import { FIXED_API_USER_ID } from '../middleware/fixed-user'; import { assertUserOwnsAddress } from '../lib/wallet-binding'; const router = Router(); @@ -117,7 +118,7 @@ async function buildSwapTx(req: Request, res: Response) { // C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr // и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector). - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; try { await assertUserOwnsAddress(userId, 'BSC', userAddress); } catch (err: any) { diff --git a/apps/api/src/routes/relay-proxy.routes.ts b/apps/api/src/routes/relay-proxy.routes.ts index 5bd9a88..4e3db35 100644 --- a/apps/api/src/routes/relay-proxy.routes.ts +++ b/apps/api/src/routes/relay-proxy.routes.ts @@ -3,6 +3,7 @@ import { env } from '../config/env'; import { logger } from '../lib/logger'; import { WalletModel } from '../models/wallet.model'; import type { ChainCode } from '../lib/address-validators'; +import { FIXED_API_USER_ID } from '../middleware/fixed-user'; const router = Router(); const RELAY_API_URL = 'https://api.relay.link'; @@ -57,11 +58,7 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction // Без этого authenticated user может set recipient=attacker → Relay строит quote → // victim signs → bridge funds к attacker'у. if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) { - const userId = (req as any).auth?.userId; - if (!userId) { - res.status(401).json({ success: false, error: 'auth required' }); - return; - } + const userId = FIXED_API_USER_ID; const bodyUser = req.body?.user; const bodyRecipient = req.body?.recipient; const originChainId = Number(req.body?.originChainId); diff --git a/apps/api/src/routes/sol-swap-proxy.routes.ts b/apps/api/src/routes/sol-swap-proxy.routes.ts index 609607e..b949fe1 100644 --- a/apps/api/src/routes/sol-swap-proxy.routes.ts +++ b/apps/api/src/routes/sol-swap-proxy.routes.ts @@ -1,6 +1,7 @@ import { Request, Response, Router } from 'express'; import { env } from '../config/env'; import { logger } from '../lib/logger'; +import { FIXED_API_USER_ID } from '../middleware/fixed-user'; import { assertUserOwnsAddress } from '../lib/wallet-binding'; import { PublicKey } from '@solana/web3.js'; @@ -135,7 +136,7 @@ async function buildSwap(req: Request, res: Response) { } // C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging) - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; try { await assertUserOwnsAddress(userId, 'SOL', userPublicKey); } catch (err: any) { diff --git a/apps/api/src/routes/tron-swap-proxy.routes.ts b/apps/api/src/routes/tron-swap-proxy.routes.ts index e383d1b..ea5bb52 100644 --- a/apps/api/src/routes/tron-swap-proxy.routes.ts +++ b/apps/api/src/routes/tron-swap-proxy.routes.ts @@ -1,6 +1,7 @@ import { Request, Response, Router } from 'express'; import { env } from '../config/env'; import { logger } from '../lib/logger'; +import { FIXED_API_USER_ID } from '../middleware/fixed-user'; import { assertUserOwnsAddress } from '../lib/wallet-binding'; const router = Router(); @@ -202,7 +203,7 @@ async function buildSwapTx(req: Request, res: Response) { } // H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у) - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; try { await assertUserOwnsAddress(userId, 'TRX', userAddress); } catch (err: any) { @@ -338,7 +339,7 @@ async function broadcastTx(req: Request, res: Response) { // C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера. // Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans). - const userId = req.auth!.userId; + const userId = FIXED_API_USER_ID; const contract0 = signedTransaction?.raw_data?.contract?.[0]; const ownerAddr = contract0?.parameter?.value?.owner_address; if (typeof ownerAddr !== 'string' || !ownerAddr) { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 4f3b8f4..296b280 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "CryptoWallet API", "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)." + "description": "Multi-chain custodial wallet API (ETH/BSC/BTC/TRX/SOL). Сервер генерит mnemonic, шифрует AES-256-GCM (master-key из HashiCorp Vault), хранит её и сам подписывает транзакции. Все операции выполняются для фиксированного user_id на сервере; JWT/CSRF не используются." }, "servers": [ { "url": "/api", "description": "API root" } @@ -220,10 +220,7 @@ } } }, - "security": [ - { "cookieAuth": [] }, - { "bearerAuth": [] } - ], + "security": [], "paths": { "/health": { "get": { @@ -248,7 +245,7 @@ "/wallets/create": { "post": { "summary": "Создать custodial-кошелёк (server-side mnemonic)", - "description": "**Публичный вызов (без JWT/CSRF).** Кошелёк всегда создаётся для фиксированного user_id на сервере. **Тело запроса не требуется.** Сервер генерит 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 если у юзера уже есть кошелёк.", + "description": "**Без JWT/CSRF.** Кошелёк всегда создаётся для фиксированного user_id на сервере. **Тело запроса не требуется.** Сервер генерит 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"], "security": [], "responses": { @@ -263,7 +260,7 @@ "/wallets/mnemonic/reveal": { "post": { "summary": "Раскрыть mnemonic (settings-screen)", - "description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику юзера. POST + CSRF + body-confirmation. Rate-limit 5/час per-user. Каждый запрос пишется в audit-log.", + "description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику для фиксированного user_id на сервере. POST + body-confirmation. Rate-limit 5/час. Каждый запрос пишется в audit-log.", "tags": ["Wallets"], "requestBody": { "required": true, @@ -293,7 +290,7 @@ "/wallets/{chain}/balance": { "get": { "summary": "Balance for user wallet in chain (с USD-ценами)", - "description": "Возвращает количество и USD-стоимость для native монеты + всех известных токенов сети. Каждый `FormattedAmount` содержит `raw` (smallest units), `formatted` (human-readable), `decimals`, `usdPrice` (цена 1 единицы), `usdValue` (стоимость holding'а). Цены — CoinGecko с 5-минутным KeyDB-кэшем. Если упал price oracle — `usdPrice`/`usdValue` = `null`, но количества всё равно возвращаются.\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" https://api.example.com/api/wallets/ETH/balance\n```", + "description": "Возвращает количество и USD-стоимость для native монеты + всех известных токенов сети. Каждый `FormattedAmount` содержит `raw` (smallest units), `formatted` (human-readable), `decimals`, `usdPrice` (цена 1 единицы), `usdValue` (стоимость holding'а). Цены — CoinGecko с 5-минутным KeyDB-кэшем. Если упал price oracle — `usdPrice`/`usdValue` = `null`, но количества всё равно возвращаются.\n\n**Пример curl:**\n```\ncurl https://api.example.com/api/wallets/ETH/balance\n```", "tags": ["Wallet Ops"], "parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }], "responses": { @@ -692,7 +689,7 @@ "/prices": { "get": { "summary": "USD-цены для списка символов", - "description": "Возвращает котировки USD для указанных символов (max 50). Символы должны быть из реестра поддерживаемых токенов (см. tag описание сетей в /wallets/{chain}/balance). Источник — CoinGecko free API, кэшируется в KeyDB 5 минут.\n\n**Resolution:**\n- Native символ совпадающий с chain code (BTC/ETH/BSC/TRX/SOL) → используется native CoinGecko id.\n- Иначе: ищется в реестре сети из `chain` query param.\n- Если `chain` не задан → fallback порядок ETH → BSC → SOL → TRX → BTC. Первый matched chain wins.\n\n**Безопасность:** symbols whitelisted, никакого user-input в URL CoinGecko (защита от SSRF). Max 50 символов на запрос. Auth обязательна (JWT Bearer или cookie).\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" \"https://api.example.com/api/prices?symbols=BTC,ETH,USDT,SOL,BONK\"\n```", + "description": "Возвращает котировки USD для указанных символов (max 50). Символы должны быть из реестра поддерживаемых токенов (см. tag описание сетей в /wallets/{chain}/balance). Источник — CoinGecko free API, кэшируется в KeyDB 5 минут.\n\n**Resolution:**\n- Native символ совпадающий с chain code (BTC/ETH/BSC/TRX/SOL) → используется native CoinGecko id.\n- Иначе: ищется в реестре сети из `chain` query param.\n- Если `chain` не задан → fallback порядок ETH → BSC → SOL → TRX → BTC. Первый matched chain wins.\n\n**Безопасность:** symbols whitelisted, никакого user-input в URL CoinGecko (защита от SSRF). Max 50 символов на запрос.\n\n**Пример curl:**\n```\ncurl \"https://api.example.com/api/prices?symbols=BTC,ETH,USDT,SOL,BONK\"\n```", "tags": ["Prices"], "parameters": [ {