initjirefr
This commit is contained in:
51
.env.example
51
.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=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,9 +1,12 @@
|
||||
.env
|
||||
.env.local
|
||||
# Никогда не коммитить артефакты установки/сборки — только Docker build на сервере
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
.turbo/
|
||||
**/.turbo/
|
||||
*.log
|
||||
logs/
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
142
README.md
142
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/<kid-from-bitok> \
|
||||
|
||||
⚠️ **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@<host>:~/cryptowallet/
|
||||
|
||||
# На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...)
|
||||
ssh server@<host> -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@<host>:~/cryptowallet/deployserver/
|
||||
ssh server@<host> -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@<host>:~/cryptowallet/
|
||||
|
||||
ssh server@<host> -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@<host>:~/cryptowallet/
|
||||
|
||||
# На сервере — только API, KeyDB volume не трогаем
|
||||
ssh server@<host> -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@<host> -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
|
||||
|
||||
|
||||
37
apps/api/Dockerfile
Normal file
37
apps/api/Dockerfile
Normal file
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<RefreshResult> {
|
||||
swapKeyMap(jwtResult.value);
|
||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||
swapCsrfConfig(csrfResult.value);
|
||||
logCsrfConfigLoaded();
|
||||
}
|
||||
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
|
||||
if (!isCryptoReady()) {
|
||||
|
||||
126
cryptowallet-schema.sql
Normal file
126
cryptowallet-schema.sql
Normal file
@@ -0,0 +1,126 @@
|
||||
-- ╔══════════════════════════════════════════════════════════════════╗
|
||||
-- ║ CryptoWallet API — Production DB schema ║
|
||||
-- ║ ║
|
||||
-- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║
|
||||
-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║
|
||||
-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║
|
||||
-- ║ вручную — они НЕ будут затронуты. ║
|
||||
-- ║ ║
|
||||
-- ║ Применять: psql -h <host> -U <user> -d <db> -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.
|
||||
56
start.sh
Normal file
56
start.sh
Normal file
@@ -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\"'"
|
||||
Reference in New Issue
Block a user