diff --git a/.env.example b/.env.example index faf858a..ce94d61 100644 --- a/.env.example +++ b/.env.example @@ -1,39 +1,66 @@ -# CryptoWallet API deploy env. -# Copy to .env on server and fill real values before docker compose up. - -# REQUIRED. API will not start without Vault AppRole. -VAULT_ADDR=https://corp.vault.elcsa.ru +# ── Vault (AppRole) ──────────────────────────────────────────────── +VAULT_ADDR= VAULT_ROLE_ID= VAULT_SECRET_ID= VAULT_MOUNT_POINT=dev-secrets VAULT_SECRET_PATH=database VAULT_JWT_KID_PATH=jwt/kid VAULT_JWT_KIDS_PREFIX=jwt/kids + +# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF) VAULT_CSRF_PATH=csrf + +# 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 (внешний bitok issuer) ───────────────────────────────────── +# bitok-сервис подписывает JWT своим приватником, public key регистрируется +# в Vault под kid'ом (см. VAULT_JWT_KIDS_PREFIX). +# Allowed alg: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512 JWT_ALGORITHM=RS256 JWT_ISSUER=bitok JWT_AUDIENCE=elcsa +# ── Server ───────────────────────────────────────────────────────── API_PORT=3001 LOG_LEVEL=INFO -# Production: replace * with exact frontend origins. -CORS_ORIGINS=* -CORS_ALLOW_CREDENTIALS=false - +# ── KeyDB / Redis (idempotency cache) ────────────────────────────── +# REDIS_PASSWORD also used by docker-compose to seed KeyDB --requirepass. REDIS_HOST=keydb REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 -COINGECKO_API_KEY= +# ── CORS ──────────────────────────────────────────────────────────── +# Comma-separated list of allowed origins. ПУСТО = no cross-origin. +# Никогда не используй wildcard * +CORS_ORIGINS= +CORS_ALLOW_CREDENTIALS=true + +# ── External API keys (optional, fallback если Vault их не выдаёт) ─ RELAY_API_KEY= TRON_API_KEY= JUPITER_API_KEY= JUPITER_REFERRAL_ACCOUNT= JUPITER_FEE_BPS=70 -# Optional outbound proxy for RPC / swap / bridge calls. -OUTBOUND_PROXY_URL= +# ── Block explorers (optional, для tx history) ───────────────────── +ETHERSCAN_API_KEY= +BSCSCAN_API_KEY= + +# ── Price oracle (optional) ───────────────────────────────────────── +# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min). +# Если задан → передаётся через header `x-cg-demo-api-key`. +# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue) +# и /api/prices?symbols=... KeyDB cache: 5 минут. +COINGECKO_API_KEY= + +# ── DB fallback (если Vault недоступен при старте) ───────────────── +DB_HOST= +DB_PORT=5432 +DB_USER= +DB_PASSWORD= +DB_NAME= diff --git a/.gitignore b/.gitignore index 2d3e999..647c454 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,12 @@ .env .env.local +# Никогда не коммитить артефакты установки/сборки — только Docker build на сервере node_modules/ **/node_modules/ dist/ **/dist/ +.turbo/ +**/.turbo/ *.log logs/ .DS_Store diff --git a/Dockerfile b/Dockerfile index d6b12cd..e714ec4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,9 @@ RUN cd apps/api && pnpm build FROM base AS prod-deps COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY apps/api/package.json apps/api/ -RUN pnpm install --frozen-lockfile --prod +RUN pnpm install --frozen-lockfile --prod \ + && BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \ + && if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi # ── Stage 4: runtime image — minimal surface ── FROM node:20-alpine AS runtime diff --git a/README.md b/README.md index c78d481..9521a16 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ Multi-chain **custodial** wallet API (ETH / BSC / BTC / TRX / SOL). Auth — JWT, выданный сервисом **bitok** (внешний). Секреты — HashiCorp Vault (AppRole). -## Pre-deploy setup (один раз) +## Pre-deploy setup (только greenfield / первый раз) + +**На уже работающем production НЕ выполнять** `vault kv put` для crypto/csrf/jwt и **НЕ** пересоздавать KeyDB/Postgres volumes. ```bash -# 1. Master-key в Vault +# 1. Master-key в Vault (ТОЛЬКО если ключа ещё нет — иначе все mnemonic станут мёртвыми) vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) # 2. CSRF secret в Vault @@ -28,38 +30,69 @@ vault kv put dev-secrets/jwt/kids/ \ ⚠️ **Master-key менять нельзя** — все existing `encrypted_mnemonic` станут нерасшифровываемыми. API на старте делает self-test: пытается декриптить любой существующий mnemonic и фейлится если ключ не подошёл. -## Deploy +## Bundle contents + +Папка `deployserver/` — **только исходники** (`apps/api/src`, `package.json`, `pnpm-lock.yaml`) и Docker-конфиги. +**Не должно быть** `node_modules/`, `dist/`, `.turbo/` — ни в git, ни при `scp` на сервер. + +Локальная сборка bundle из монорепы: ```bash -# Залить bundle на сервер -scp -P 2222 -r deployserver/ server@:~/cryptowallet/ - -# На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...) -ssh server@ -p 2222 -cd ~/cryptowallet/deployserver -cp .env.example .env -nano .env # VAULT_ADDR / VAULT_ROLE_ID / VAULT_SECRET_ID НЕ должны быть пустыми -docker compose up -d --build -docker compose logs -f api -curl http://localhost:3001/api/health +node scripts/sync-deployserver.mjs ``` -API **не делает migrations / DROP / ALTER** при старте — только INSERT/UPDATE/SELECT. Schema (если нужны новые колонки/таблицы для нового функционала) обновляется только руками: `psql -f cryptowallet-schema.sql` (script append-only — `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT EXISTS`, никаких DROP). - -Если в логах есть `Vault not configured, using .env` и затем `Initial Vault refresh failed: vault_not_configured`, значит контейнер получил пустые `VAULT_ADDR`, `VAULT_ROLE_ID` или `VAULT_SECRET_ID`. Это не nginx-проблема: API падает на старте, пока AppRole не заполнен. - -## Update / Rebuild +Проверка/сборка образа — **только через Docker** (на сервере или локально): ```bash -# Залить новый src + rebuild api (keydb данные не теряются) +cd deployserver && docker compose build api +``` + +Не запускайте `pnpm install` внутри `deployserver/` — зависимости ставятся в multi-stage Dockerfile. + +## Deploy (первый раз на пустом сервере) + +```bash +# Только код + Docker-файлы. НЕ заливать .env с локальной машины поверх серверного. scp -P 2222 -r deployserver/apps deployserver/Dockerfile deployserver/docker-compose.yml \ - server@:~/cryptowallet/deployserver/ -ssh server@ -p 2222 'cd cryptowallet/deployserver && docker compose up -d --build' + deployserver/package.json deployserver/pnpm-lock.yaml deployserver/pnpm-workspace.yaml \ + deployserver/start.sh deployserver/.env.example \ + server@:~/cryptowallet/ + +ssh server@ -p 2222 +cd ~/cryptowallet +# .env только если файла ещё нет: +test -f .env || cp .env.example .env +chmod 600 .env +nano .env # заполни VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD +./start.sh +``` + +В `.env` **обязательны**: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER=bitok`, `JWT_AUDIENCE`, `CORS_ORIGINS`, `REDIS_PASSWORD`. + +## Update / Rebuild (production — без потери данных) + +**Разрешено:** пересобрать только контейнер `api` (образ из нового кода). + +**Запрещено при обычном апдейте:** +- `docker compose down -v` (снесёт volume KeyDB `keydb_data`) +- `vault kv put` / patch crypto master, csrf secret, jwt keys +- `scp`/`rsync` всего `deployserver/` поверх `~/cryptowallet/` (может затереть `.env`) +- `--force-recreate` для `keydb` +- повторный `psql -f cryptowallet-schema.sql` без необходимости (только если осознанно нужны новые колонки) + +```bash +# С локальной машины — только apps + lockfile + Dockerfile (не .env) +node scripts/sync-deployserver.mjs +scp -P 2222 -r deployserver/apps deployserver/Dockerfile deployserver/docker-compose.yml \ + deployserver/package.json deployserver/pnpm-lock.yaml deployserver/pnpm-workspace.yaml \ + server@:~/cryptowallet/ + +# На сервере — только API, KeyDB volume не трогаем +ssh server@ -p 2222 'cd ~/cryptowallet && docker compose build api && docker compose up -d api' ``` ## Endpoints -### Core wallet management | Method | Path | Описание | |---|---|---| | GET | `/api/health` | Liveness (public) | @@ -67,61 +100,13 @@ ssh server@ -p 2222 'cd cryptowallet/deployserver && docker compose up -d | GET | `/api/docs/swagger.json` | OpenAPI JSON | | POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) | | GET | `/api/wallets` | Список адресов юзера | -| GET | `/api/wallets/portfolio` | Аггрегированный USD portfolio по всем 5 chains (KeyDB cached 1h, stale fallback) | | POST | **`/api/wallets/mnemonic/reveal`** | Reveal seed (body confirm + 5/час rate-limit) | | GET | `/api/wallets/{chain}/balance` | Баланс (native + все известные токены чейна) | | GET | `/api/wallets/{chain}/transactions` | История tx | | POST | **`/api/wallets/{chain}/send`** | Сервер подписывает + broadcast. Body: `{to, amount, token?, feeTier?}` | -| POST | `/api/wallets/{chain}/send/cost-estimate` | Pre-flight gas/fee estimate (no broadcast) | | GET | **`/api/wallets/{chain}/gas-suggestions`** | Slow/normal/fast tiers (ETH/BSC, parsed из eth_feeHistory) | -| POST | **`/api/wallets/{chain}/sign-raw-evm-tx`** | Подписать произвольную EVM tx (для Relay execute steps). Поддерживает opt-in `bridgeAmount`+`bridgeToken` → автоматически взимает 0.7% app fee на EVM fee wallet ПЕРЕД main tx (atomic). | -| POST | `/api/wallets/SOL/sign-and-broadcast-tx` | Sign+broadcast serialized VersionedTransaction (Jupiter/Relay SOL steps) | - -### Bridge & Swap (NEW) -| Method | Path | Описание | -|---|---|---| -| POST | **`/api/bridge/execute`** | One-click bridge orchestrator. Auto-routes: TRX source → NearIntents 1Click; EVM/SOL source → Jumper (LiFi) или Relay. Atomic fee tx ДО main bridge. Idempotency-Key support. Returns `{feeTxid?, approveTxid?, bridgeTxid, trackerUrl, provider}`. | -| GET | **`/api/jumper/quote-best`** | Bridge quote с NearIntents priority + smart LiFi fallback. JWT-binds fromAddress. | -| GET | `/api/jumper/{chains,tools,tokens,connections,status}` | LiFi metadata proxies (JWT-protected) | -| POST | `/api/jumper/advanced/{routes,stepTransaction}` | LiFi multi-route preview / step tx fetcher | -| POST | `/api/wallets/{chain}/swap/quote` | Custodial swap quote (BSC PancakeSwap / SOL Jupiter / TRX SunSwap) — 30s locked minOut | -| POST | **`/api/wallets/{chain}/swap`** | Confirm custodial swap (BSC/SOL/TRX). BSC + SOL: 0.7% atomic fee ДО main swap. TRX: existing on-chain FeeSwapRouter. | -| POST | `/api/wallets/{chain}/swap/cost-estimate` | Swap pre-flight cost estimate | -| — | `/api/relay/*` | Relay.link bridge proxy (quote / execute / status / cost-estimate). Unchanged from previous deploys. | - -### App fee 0.7% (NEW — hardcoded recipients) -| Method | Path | Описание | -|---|---|---| -| POST | **`/api/wallets/{chain}/app-fee`** | Standalone fee transfer endpoint. Для **Relay frontend hook** — клиент явно вызывает после Relay execute. Body: `{amount, token?}`. Server validates JWT-bind + computes 0.7% + signs + broadcasts. Idempotency-Key support. | - -Fee wallets (захардкожены в `lib/app-fee.ts`, нельзя override через env): - -| Chain | Recipient address | -|---|---| -| EVM (ETH+BSC) | `0xeb9fbf0d137ef5ea7b9959044c2ed44ec1206c68` | -| SOL | `DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD` | -| TRX | `TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP` | -| BTC | (not collectable — no fee wallet) | - -Fee рассчёт: `feeAmount = amount × 70 / 10000` (BigInt-precision, 0 если < 144 smallest units). - -### Tokens & Prices (NEW) -| Method | Path | Описание | -|---|---|---| -| GET | **`/api/tokens`** `?chain={ETH,BSC,BTC,TRX,SOL}` `?bridgeable=true` | Token registry. `bridgeable=true` фильтрует только tokens с реальным bridge route (используется Jumper UI dropdowns) | -| GET | `/api/prices` `?symbols=BTC,ETH,USDT` `?chain={...}` | CoinGecko cached USD prices | -| GET | **`/api/prices/dynamics`** `?symbols=...` | 24h price change % для нескольких символов одним запросом | - -### Provider integrations (NEW) -- **NearIntents 1Click** (`https://1click.chaindefuser.com`) — direct TRX bridges. Asset map fetched dynamically через `/v0/tokens` (cached 1h in-memory). Quote → user transfers TRX/USDT to depositAddress → solver delivers cross-chain. No JWT для test env (0.2% NearIntents fee included в quote). Implementation: `src/lib/nearintents-client.ts`. -- **Jumper / LiFi** (`https://li.quest/v1`) — multi-chain bridge proxy + quote-best smart routing с NearIntents priority. Implementation: `src/routes/jumper-proxy.routes.ts`. -- **Relay.link** (`https://api.relay.link`) — EVM/SOL/BTC bridge proxy. **Unchanged** (preservation invariant). Implementation: `src/routes/relay-proxy.routes.ts`. - -### Audit log events (extended) -- `wallet.create`, `wallet.send`, `wallet.swap` (existing) -- `wallet.sign_raw_evm` (existing, +`bridgeAmount` meta) -- `wallet.bsc_fee` (existing) / `wallet.app_fee` (NEW — standalone fee endpoint) -- `bridge.execute`, `bridge.execute.broadcast` (NEW — bridge orchestrator) +| POST | **`/api/wallets/{chain}/sign-raw-evm-tx`** | Подписать произвольную EVM tx (для Relay/Swap execute responses) | +| — | `/api/btc/*` `/api/tron/*` `/api/sol/*` `/api/bsc/*` `/api/relay/*` | Proxy endpoints (swap quote/build, bridge quote/execute/status) | ## Security highlights @@ -141,19 +126,12 @@ Fee рассчёт: `feeAmount = amount × 70 / 10000` (BigInt-precision, 0 ес - **BTC** P2WPKH bech32, dust 294, broadcast 20s timeout - **POST mnemonic/reveal** + CSRF + body confirm token + 5/час rate-limit + audit-log - **Logger sanitization** — password/token/mnemonic/hex64/BIP39-phrase patterns маскируются -- **Audit log в stdout** (структурированный JSON с `"level":"audit"`) — `wallet.create` / `wallet.send` / `wallet.swap` / `wallet.sign_raw_evm` / `wallet.bsc_fee` / `wallet.app_fee` / `mnemonic.reveal` / `bridge.execute` / `bridge.execute.broadcast` +- **Audit log в stdout** (структурированный JSON с `"level":"audit"`) — `wallet.create` / `wallet.send` / `mnemonic.reveal` / `wallet.sign_raw_evm` - **Hourly key rotation** — JWT keys + CSRF secret из Vault (master-key НЕ ротируется) - **Fail-fast** — сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE - **Container hardening** — read-only fs, cap_drop ALL, no-new-privileges, pids/mem/cpu limits, non-root uid 1001, loopback-only port - **Relay proxy** whitelist method+path — `/quote` (POST), `/intents/status/v3` (GET), `/execute/{swap|bridge}` (POST). Никаких freeform action'ов -- **Jumper proxy** whitelist аналогично — `/quote`, `/quote-best`, `/chains`, `/tools`, `/tokens`, `/connections`, `/status`, `/advanced/{routes,stepTransaction}`. JWT-binds `fromAddress` к user wallet. -- **NearIntents direct integration** для TRX bridges — `signAndBroadcast(TRX)` через battle-tested sendTrx (4-layer MITM defense сохраняется). Asset map fetched через `/v0/tokens` (auth source) и cached 1h в-memory. depositAddress validated через Tron base58 regex `^T[1-9A-HJ-NP-Za-km-z]{33}$`. -- **App fee 0.7%** atomic enforcement в bridge-execute orchestrator + custodial swap orchestrator. Fee tx FIRST → если fail, main tx НЕ broadcast (atomic abort). Recipients hardcoded — нельзя override через body/env. -- **BridgeSimulationError** для EVM bridges — pre-broadcast `eth_call` dry-run; если revert → 400 `SIMULATION_FAILED`, fees не сгорают. -- **InsufficientBalanceError** pre-check на bridge — отвергаем 400 ДО подписи если balance < amount + reserve. -- **Anti-MEV** в bridge-execute — server re-quotes ПЕРЕД sign, валидирует `minAmountOut ≥ acceptedMinOut` (50 bps cushion). Если price moved → 409. -- **Idempotency-Key** на mutating endpoints (send / sign-raw / swap / bridge-execute / app-fee) — KeyDB cache 10 min, retry-safe. -- **TronGrid 429 retry** с exponential backoff (0/1.5/4/8s) для free-tier rate limits. +- **Bridge UI dropdown** ETH/BSC/SOL only (TRX через Relay убран — плохая ликвидность) ## Schema is non-destructive diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..362a38d --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-alpine AS base +RUN corepack enable && corepack prepare pnpm@10.28.2 --activate +WORKDIR /app + +# ── deps: install all node_modules ─────────────────────────────────────────── +FROM base AS deps +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY apps/api/package.json apps/api/ +RUN pnpm install --frozen-lockfile --prod=false + +# ── build: compile TypeScript ──────────────────────────────────────────────── +FROM base AS build +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY . . +RUN cd apps/api && pnpm build + +# ── prod-deps: production-only dependencies ───────────────────────────────── +FROM base AS prod-deps +RUN apk add --no-cache python3 make g++ +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY apps/api/package.json apps/api/ +RUN pnpm install --frozen-lockfile --prod \ + && BIGINT_DIR="$(find /app -path '*/node_modules/bigint-buffer' -type d 2>/dev/null | head -1)" \ + && if [ -n "$BIGINT_DIR" ]; then (cd "$BIGINT_DIR" && npm run rebuild); fi + +# ── runtime: minimal image ─────────────────────────────────────────────────── +FROM node:20-alpine AS runtime +WORKDIR /app/apps/api +COPY --from=prod-deps /app/node_modules /app/node_modules +COPY --from=prod-deps /app/apps/api/node_modules ./node_modules +COPY --from=build /app/apps/api/dist ./dist +COPY --from=build /app/apps/api/swagger.json ./swagger.json +COPY --from=build /app/apps/api/package.json ./package.json + +EXPOSE 3001 +CMD ["node", "dist/index.js"] diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index 952f41a..a8a3c0f 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -67,7 +67,11 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction): // HMAC verify только после совпадения двух source'ов. const result = verifyCsrfToken(cookieToken); if (!result.valid) { - logger.warn(`CSRF validation failed: ${result.reason}`); + const sigDiag = + result.actualSigLen !== undefined && result.expectedSigLen !== undefined + ? ` (sigLen=${result.actualSigLen} expectedLen=${result.expectedSigLen})` + : ''; + logger.warn(`CSRF validation failed: ${result.reason}${sigDiag}`); res.status(403).json({ success: false, error: 'Invalid CSRF token' }); return; } diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index f58a58e..56fee95 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { fetchVaultKV2 } from '../config/vault'; +import { logger } from '../lib/logger'; /** * CSRF token validation compatible with Python's `itsdangerous` @@ -66,7 +67,9 @@ export async function fetchCsrfConfig( } // sha1 deprecated — accept только sha256/sha512. - let digest: 'sha256' | 'sha512' = 'sha512'; + // Default sha256: совпадает с deploy vault-init и типичным Flask config при явном digest_method. + // itsdangerous 2.x без digest → sha512; wallet API при несовпадении пробует fallback в verifyCsrfToken. + let digest: 'sha256' | 'sha512' = 'sha256'; if (secrets.digest === 'sha256' || secrets.digest === 'sha512') { digest = secrets.digest; } @@ -112,6 +115,27 @@ function encodeTimestamp(unixSeconds: number): string { export interface CsrfVerifyResult { valid: boolean; reason?: string; + actualSigLen?: number; + expectedSigLen?: number; +} + +export interface CsrfConfigSummary { + salt: string; + digest: 'sha256' | 'sha512'; + maxAgeSec: number; +} + +export function getCsrfConfigSummary(): CsrfConfigSummary | null { + if (!current) return null; + return { salt: current.salt, digest: current.digest, maxAgeSec: current.maxAgeSec }; +} + +export function logCsrfConfigLoaded(): void { + const summary = getCsrfConfigSummary(); + if (!summary) return; + logger.info( + `CSRF config loaded: salt="${summary.salt}" digest=${summary.digest} maxAgeSec=${summary.maxAgeSec}`, + ); } export function generateCsrfToken(): { token: string; maxAgeSec: number } { @@ -131,8 +155,7 @@ export function generateCsrfToken(): { token: string; maxAgeSec: number } { }; } -export function verifyCsrfToken(token: string): CsrfVerifyResult { - if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; +function verifyCsrfTokenWithConfig(cfg: CsrfConfig, token: string): CsrfVerifyResult { if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' }; const lastDot = token.lastIndexOf('.'); @@ -146,8 +169,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult { const tsStr = payloadTs.slice(prevDot + 1); - const derived = deriveKey(current.secret, current.salt, current.digest); - const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest(); + const derived = deriveKey(cfg.secret, cfg.salt, cfg.digest); + const expectedSig = crypto.createHmac(cfg.digest, derived).update(payloadTs).digest(); let actualSig: Buffer; try { @@ -157,7 +180,12 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult { } if (expectedSig.length !== actualSig.length) { - return { valid: false, reason: 'Signature length mismatch' }; + return { + valid: false, + reason: 'Signature length mismatch', + expectedSigLen: expectedSig.length, + actualSigLen: actualSig.length, + }; } if (!crypto.timingSafeEqual(expectedSig, actualSig)) { return { valid: false, reason: 'Signature mismatch' }; @@ -167,10 +195,37 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult { const issuedAt = decodeTimestamp(tsStr); const now = Math.floor(Date.now() / 1000); if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' }; - if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' }; + if (now - issuedAt > cfg.maxAgeSec) return { valid: false, reason: 'Token expired' }; } catch { return { valid: false, reason: 'Invalid timestamp' }; } return { valid: true }; } + +/** + * Verify CSRF token. If Vault digest differs from auth-service (sha256 vs sha512), + * retry once with the alternate digest — типичный случай Flask-WTF / itsdangerous 2.x. + */ +export function verifyCsrfToken(token: string): CsrfVerifyResult { + if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; + + const primary = verifyCsrfTokenWithConfig(current, token); + if (primary.valid) return primary; + + if (primary.reason !== 'Signature length mismatch') { + return primary; + } + + const altDigest: 'sha256' | 'sha512' = current.digest === 'sha256' ? 'sha512' : 'sha256'; + const fallback = verifyCsrfTokenWithConfig({ ...current, digest: altDigest }, token); + if (fallback.valid) { + logger.warn( + `CSRF verified with fallback digest ${altDigest} (Vault digest=${current.digest}). ` + + 'Align auth-service URLSafeTimedSerializer digest_method with Vault `digest` field.', + ); + return { valid: true }; + } + + return primary; +} diff --git a/apps/api/src/services/key-rotation.service.ts b/apps/api/src/services/key-rotation.service.ts index ea0517a..d14fe52 100644 --- a/apps/api/src/services/key-rotation.service.ts +++ b/apps/api/src/services/key-rotation.service.ts @@ -1,7 +1,7 @@ import { env } from '../config/env'; import { vaultAppRoleLogin } from '../config/vault'; import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service'; -import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service'; +import { fetchCsrfConfig, swapCsrfConfig, logCsrfConfigLoaded } from './csrf.service'; import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service'; import { logger } from '../lib/logger'; @@ -83,6 +83,7 @@ async function doRefresh(): Promise { swapKeyMap(jwtResult.value); if (csrfResult.status === 'fulfilled' && csrfResult.value) { swapCsrfConfig(csrfResult.value); + logCsrfConfigLoaded(); } if (cryptoResult.status === 'fulfilled' && cryptoResult.value) { if (!isCryptoReady()) { diff --git a/cryptowallet-schema.sql b/cryptowallet-schema.sql new file mode 100644 index 0000000..a1561f6 --- /dev/null +++ b/cryptowallet-schema.sql @@ -0,0 +1,126 @@ +-- ╔══════════════════════════════════════════════════════════════════╗ +-- ║ CryptoWallet API — Production DB schema ║ +-- ║ ║ +-- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║ +-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║ +-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║ +-- ║ вручную — они НЕ будут затронуты. ║ +-- ║ ║ +-- ║ Применять: psql -h -U -d -f cryptowallet-schema.sql ║ +-- ╚══════════════════════════════════════════════════════════════════╝ + +-- NOTE: idempotency_keys и audit_log таблицы НЕ используются. +-- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts +-- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts +-- Скрипт их НЕ дропает (чтобы re-run был non-destructive). +-- Если оператор хочет cleanup — manual one-time: +-- DROP TABLE IF EXISTS audit_log CASCADE; +-- DROP TABLE IF EXISTS idempotency_keys CASCADE; + +-- ── USERS ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(26) NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + last_name VARCHAR(128), + first_name VARCHAR(128), + middle_name VARCHAR(128), + birth_date DATE, + -- DEPRECATED: исторически generic crypto address; для ETH используем erc20 ниже. + crypto_wallet VARCHAR(255), + phone VARCHAR(16), + inn VARCHAR(12), + kyc_verified BOOLEAN NOT NULL DEFAULT FALSE, + kyc_verified_at TIMESTAMP WITH TIME ZONE, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + passport_data VARCHAR(255), + erc20 VARCHAR(255), + -- EXTENSION (custodial wallet support): + -- AES-256-GCM blob: IV(12) || ciphertext || authTag(16), base64. Master-key в Vault. + encrypted_mnemonic TEXT +); + +-- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки) +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; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'erc20') THEN + ALTER TABLE users ADD COLUMN erc20 VARCHAR(255); + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'passport_data') THEN + ALTER TABLE users ADD COLUMN passport_data VARCHAR(255); + END IF; +END $$; + +-- Constraint: blob size check (only ADDs if missing, никогда не DROP). +-- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars). +-- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт. +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 100 AND 512)); + END IF; +END $$; + +-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'users_email_lower_unique') THEN + CREATE UNIQUE INDEX users_email_lower_unique ON users (lower(email)); + END IF; +END $$; + +-- Partial index для active-user queries +CREATE INDEX IF NOT EXISTS idx_users_active ON users(id) WHERE is_deleted = FALSE; + +-- erc20 format check (NULL or 0x + 40 hex) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_erc20_format') THEN + ALTER TABLE users + ADD CONSTRAINT users_erc20_format + CHECK (erc20 IS NULL OR erc20 ~ '^0x[0-9a-fA-F]{40}$'); + END IF; +END $$; + +-- KYC consistency: verified=true requires verified_at NOT NULL +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_kyc_consistency') THEN + ALTER TABLE users + ADD CONSTRAINT users_kyc_consistency + CHECK ((kyc_verified = FALSE) OR (kyc_verified = TRUE AND kyc_verified_at IS NOT NULL)); + END IF; +END $$; + +-- ── WALLETS ───────────────────────────────────────────────────────── +-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets. +-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении. +CREATE TABLE IF NOT EXISTS wallets ( + id VARCHAR(26) NOT NULL PRIMARY KEY, + user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + chain VARCHAR(16) NOT NULL, + address VARCHAR(128) NOT NULL, + derivation_path VARCHAR(64) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, chain), + CHECK (chain IN ('ETH', 'BSC', 'BTC', 'TRX', 'SOL')) +); + +CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id); +CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address); + +-- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT +-- для защиты от fund loss при delete user), оператор делает manual ОДИН раз: +-- +-- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey; +-- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey +-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT; +-- +-- Этот script ничего не дропает — re-run полностью non-destructive. diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..c7f555d --- /dev/null +++ b/start.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -euo pipefail + +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 + chmod 600 .env + echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова" + exit 1 + else + echo "[ERROR] нет ни .env, ни .env.example" + exit 1 + 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 + +# NOTE: logs/ директория НЕ нужна — audit-логи теперь в stdout (Docker logs). +# Контейнер работает с read_only: true (см. docker-compose.yml). + +# Не используйте `docker compose down -v` — удалит keydb_data (кэш/idempotency). +# Не пересоздавайте keydb без бэкапа. Обновление кода: `docker compose build api && docker compose up -d api`. +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://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. Запусти 'docker compose logs --tail=50 api' для диагностики." + exit 1 + fi + sleep 2 +done + +echo "" +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 events: docker compose logs api | grep '\"level\":\"audit\"'"