diff --git a/README.md b/README.md deleted file mode 100644 index 9521a16..0000000 --- a/README.md +++ /dev/null @@ -1,178 +0,0 @@ -# CryptoWallet API — Deployment Bundle - -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 (только greenfield / первый раз) - -**На уже работающем production НЕ выполнять** `vault kv put` для crypto/csrf/jwt и **НЕ** пересоздавать KeyDB/Postgres volumes. - -```bash -# 1. Master-key в Vault (ТОЛЬКО если ключа ещё нет — иначе все mnemonic станут мёртвыми) -vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32) - -# 2. CSRF secret в Vault -vault kv put dev-secrets/csrf secret_key=$(openssl rand -hex 32) salt=csrf-salt digest=sha256 - -# 3. DB schema — APPEND-ONLY / NON-DESTRUCTIVE -# Безопасно прогонять на existing БД. См. ниже "Schema is non-destructive". -psql -h -U postgres_user -d postgres -f cryptowallet-schema.sql - -# 4. bitok public key в Vault (для kid из JWT header) -vault kv put dev-secrets/jwt/kid active= -vault kv put dev-secrets/jwt/kids/ \ - algorithm=RS256 \ - public_key="$(cat /path/to/bitok-public.pem)" -``` - -⚠️ **Master-key менять нельзя** — все existing `encrypted_mnemonic` станут нерасшифровываемыми. API на старте делает self-test: пытается декриптить любой существующий mnemonic и фейлится если ключ не подошёл. - -## Bundle contents - -Папка `deployserver/` — **только исходники** (`apps/api/src`, `package.json`, `pnpm-lock.yaml`) и Docker-конфиги. -**Не должно быть** `node_modules/`, `dist/`, `.turbo/` — ни в git, ни при `scp` на сервер. - -Локальная сборка bundle из монорепы: - -```bash -node scripts/sync-deployserver.mjs -``` - -Проверка/сборка образа — **только через Docker** (на сервере или локально): - -```bash -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 \ - 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 - -| Method | Path | Описание | -|---|---|---| -| GET | `/api/health` | Liveness (public) | -| GET | `/api/docs` | Swagger UI | -| GET | `/api/docs/swagger.json` | OpenAPI JSON | -| POST | **`/api/wallets/create`** | Сервер создаёт коша (no body, returns 5 addresses) | -| GET | `/api/wallets` | Список адресов юзера | -| 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?}` | -| 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/Swap execute responses) | -| — | `/api/btc/*` `/api/tron/*` `/api/sol/*` `/api/bsc/*` `/api/relay/*` | Proxy endpoints (swap quote/build, bridge quote/execute/status) | - -## Security highlights - -- **AES-256-GCM** для encrypted_mnemonic (12-byte random IV, 16-byte auth tag, fail-secure) -- **Master-key set-once** (rotation запрещена в коде) -- **Crypto self-test на старте** — fail-fast если master-key не декриптит existing mnemonics -- **Race-safe createWallet** — `db.transaction` + `UPDATE WHERE encrypted_mnemonic IS NULL` (set-once primitive) -- **Atomic erc20 update** — ETH-адрес кладётся в `users.erc20` внутри той же транзакции -- **TRX MITM defense** — local recompute txID + 4-layer raw_data verification перед подписью -- **EVM gas cap** 500 gwei (применён к tx, не только check) -- **EVM gas oracle** через `eth_feeHistory` p25/p50/p75 — minimum-but-works fees (BSC floor 0.05, ETH 0.5 gwei) -- **BTC fee** tier-based (slow=144 blocks, normal=6, fast=1) + floor 2 sat/vB -- **TRX fee_limit** cap 30 TRX (раньше 100, излишне) -- **Address checksum validation** (BTC bitcoinjs-lib, TRX bs58check, SOL PublicKey, EVM EIP-55) -- **assertAddressMatch** — derived(mnemonic, path) === DB.address перед подписью -- **SOL confirmTransaction** — ждём подтверждения сети -- **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` / `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'ов -- **Bridge UI dropdown** ETH/BSC/SOL only (TRX через Relay убран — плохая ликвидность) - -## Schema is non-destructive - -`cryptowallet-schema.sql` **append-only**. Re-run на боксе с уже настроенной БД = **zero DDL changes**. Если оператор добавил кастомные таблицы / индексы / constraints вручную — они **никогда** не будут перезаписаны или удалены. - -Что делает script: -- `CREATE TABLE IF NOT EXISTS users` / `wallets` -- `ALTER TABLE users ADD COLUMN ` (только если колонки нет — `encrypted_mnemonic`, `erc20`, `passport_data`) -- `CREATE UNIQUE INDEX users_email_lower_unique` (если индекса нет) -- `CREATE INDEX idx_users_active` / `idx_wallets_*` (если индексов нет) -- `ADD CONSTRAINT` × 4 (только если данного constraint name нет) - -Что script **НЕ делает**: -- ❌ Никогда не `DROP TABLE` -- ❌ Никогда не `DROP CONSTRAINT` -- ❌ Никогда не `DROP COLUMN` -- ❌ Никогда не перезаписывает существующие constraints / indexes - -Legacy cleanup (audit_log, idempotency_keys, sessions от старых версий) — **manual one-time** операторская задача, не часть этого script'а: -```bash -psql ... -c "DROP TABLE IF EXISTS audit_log CASCADE;" -psql ... -c "DROP TABLE IF EXISTS idempotency_keys CASCADE;" -psql ... -c "DROP TABLE IF EXISTS sessions CASCADE;" -``` - -## Logs - -Файловых логов **нет**. Весь код пишет в `process.stdout` (см. `apps/api/src/lib/logger.ts` и `lib/audit-log.ts`). Docker подбирает stdout через json-file driver и показывает через `docker compose logs`: - -```bash -docker compose logs -f api # все логи (structured JSON) -docker compose logs api | grep '"level":"audit"' # только audit events -docker compose logs api | grep '"level":"ERROR"' # только ошибки -``` - -## Production hardening checklist (опционально) - -- [ ] Vault server-mode (raft/file backend) с unseal flow -- [ ] TLS termination на reverse-proxy (Caddy / Nginx) перед `127.0.0.1:3001` -- [ ] Swagger UI скрыть за basic-auth (endpoints всё ещё доступны через `/api/docs/swagger.json`) -- [ ] Postgres backups (pg_dump → S3 по cron) -- [ ] Vault root token ротация -- [ ] Mnemonic-reveal endpoint — 2FA / time-based confirmation tokens -- [ ] Rate-limit tune под реальный трафик diff --git a/apps/api/src/services/csrf.service.ts b/apps/api/src/services/csrf.service.ts index c48f26c..5af5487 100644 --- a/apps/api/src/services/csrf.service.ts +++ b/apps/api/src/services/csrf.service.ts @@ -9,18 +9,13 @@ import { logger } from '../lib/logger'; * Token format: .. * * Digests: sha1 (20-byte sig, legacy Flask-WTF), sha256 (32), sha512 (64, itsdangerous 2.x default). - * - * Default algorithm (itsdangerous ≥ 2.0): - * - digest: SHA-512 (HMAC) - * - salt: "itsdangerous.Signer" (or app-specific, e.g. "csrf-token") - * - derived_key = HMAC(secret, salt + "signer").digest() - * - signature = HMAC(derived_key, payload + "." + timestamp).digest() - * - * Timestamp: seconds since 2011-01-01 (itsdangerous epoch), base64url-encoded big-endian. */ const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time +/** Типичные salt у auth / Flask-WTF — только для verify fallback, не для generate. */ +const LEGACY_VERIFY_SALTS = ['csrf-salt', 'csrf', 'csrf-token', 'itsdangerous.Signer'] as const; + export interface CsrfConfig { secret: string; salt: string; @@ -28,7 +23,6 @@ export interface CsrfConfig { maxAgeSec: number; } -// Live config — атомарно подменяется через swapCsrfConfig() let current: CsrfConfig | null = null; export function swapCsrfConfig(cfg: CsrfConfig | null): void { @@ -43,9 +37,6 @@ function b64urlEncode(buf: Buffer): string { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } -/** - * Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект. - */ export async function fetchCsrfConfig( addr: string, token: string, @@ -68,15 +59,12 @@ export async function fetchCsrfConfig( throw new Error('CSRF salt invalid: must be string >= 8 chars'); } - // sha1 deprecated — accept только sha256/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; } - let maxAgeSec = 60 * 60 * 24 * 7; // 7 days + let maxAgeSec = 60 * 60 * 24 * 7; if (secrets.max_age_sec) { const n = parseInt(secrets.max_age_sec); if (!Number.isNaN(n) && n > 0) maxAgeSec = n; @@ -97,8 +85,6 @@ 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 * 256 + b; return ts + ITSDANGEROUS_EPOCH; @@ -119,6 +105,8 @@ export interface CsrfVerifyResult { reason?: string; actualSigLen?: number; expectedSigLen?: number; + fallbackDigest?: CsrfDigest; + fallbackSalt?: string; } export interface CsrfConfigSummary { @@ -127,6 +115,16 @@ export interface CsrfConfigSummary { maxAgeSec: number; } +type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; + +const DIGEST_BY_SIG_LEN: Record = { + 20: 'sha1', + 32: 'sha256', + 64: 'sha512', +}; + +const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512']; + export function getCsrfConfigSummary(): CsrfConfigSummary | null { if (!current) return null; return { salt: current.salt, digest: current.digest, maxAgeSec: current.maxAgeSec }; @@ -157,17 +155,9 @@ export function generateCsrfToken(): { token: string; maxAgeSec: number } { }; } -type CsrfDigest = 'sha1' | 'sha256' | 'sha512'; - -/** HMAC output length → digest (auth-service legacy часто sha1 → sigLen=20). */ -const DIGEST_BY_SIG_LEN: Record = { - 20: 'sha1', - 32: 'sha256', - 64: 'sha512', -}; - -function verifyCsrfTokenWithDigest( +function verifyCsrfTokenWithParams( cfg: CsrfConfig, + salt: string, digest: CsrfDigest, token: string, ): CsrfVerifyResult { @@ -184,7 +174,7 @@ function verifyCsrfTokenWithDigest( const tsStr = payloadTs.slice(prevDot + 1); - const derived = deriveKey(cfg.secret, cfg.salt, digest); + const derived = deriveKey(cfg.secret, salt, digest); const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest(); let actualSig: Buffer; @@ -218,48 +208,80 @@ function verifyCsrfTokenWithDigest( return { valid: true }; } -function digestsToTry(primary: CsrfVerifyResult): CsrfDigest[] { +/** Минимальная длина salt при verify-fallback (Flask-WTF default `csrf` = 4). */ +const MIN_VERIFY_SALT_LEN = 1; + +function saltsToTry(vaultSalt: string): string[] { + const out: string[] = []; + const add = (s: string, minLen = MIN_VERIFY_SALT_LEN) => { + if (s && s.length >= minLen && !out.includes(s)) out.push(s); + }; + add(vaultSalt, 8); + for (const s of LEGACY_VERIFY_SALTS) add(s); + return out; +} + +function digestsToTry(primary: CsrfVerifyResult, vaultDigest: CsrfDigest): CsrfDigest[] { const order: CsrfDigest[] = []; const add = (d: CsrfDigest) => { if (!order.includes(d)) order.push(d); }; - - add(current!.digest); + add(vaultDigest); if (primary.actualSigLen !== undefined) { const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen]; if (inferred) add(inferred); } - add('sha1'); - add('sha256'); - add('sha512'); + for (const d of ALL_DIGESTS) add(d); return order; } +function isRetryableVerifyFailure(reason?: string): boolean { + return reason === 'Signature length mismatch' || reason === 'Signature mismatch'; +} + /** - * Verify CSRF token. Fallback по длине подписи: sha1 (20) / sha256 (32) / sha512 (64). + * Verify CSRF: Vault salt+digest first, then legacy digest/salt matrix (auth Flask-WTF). */ export function verifyCsrfToken(token: string): CsrfVerifyResult { if (!current) return { valid: false, reason: 'CSRF secret not loaded' }; - const primaryDigest = current.digest; - const primary = verifyCsrfTokenWithDigest(current, primaryDigest, token); + const vaultSalt = current.salt; + const vaultDigest = current.digest as CsrfDigest; + const salts = saltsToTry(vaultSalt); + + const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token); if (primary.valid) return primary; - if (primary.reason !== 'Signature length mismatch' && primary.reason !== 'Signature mismatch') { + if (!isRetryableVerifyFailure(primary.reason)) { return primary; } - for (const digest of digestsToTry(primary)) { - if (digest === primaryDigest) continue; - const attempt = verifyCsrfTokenWithDigest(current, digest, token); - if (attempt.valid) { - logger.warn( - `CSRF verified with fallback digest ${digest} (Vault digest=${primaryDigest}, ` + - `sigLen=${primary.actualSigLen ?? '?'}). Align auth digest_method with Vault.`, - ); - return { valid: true }; + const digests = digestsToTry(primary, vaultDigest); + let lastMismatch: CsrfVerifyResult = primary; + + for (const salt of salts) { + for (const digest of digests) { + if (salt === vaultSalt && digest === vaultDigest) continue; + + const attempt = verifyCsrfTokenWithParams(current, salt, digest, token); + if (attempt.valid) { + logger.warn( + `CSRF verified with fallback digest=${digest} salt="${salt}" ` + + `(Vault digest=${vaultDigest} salt="${vaultSalt}"). ` + + 'Align auth-service with Vault `digest` and `salt` fields.', + ); + return { valid: true, fallbackDigest: digest, fallbackSalt: salt }; + } + if (attempt.reason === 'Signature mismatch' || attempt.reason === 'Signature length mismatch') { + lastMismatch = attempt; + } } } - return primary; + return { + valid: false, + reason: 'Signature mismatch (all digest/salt fallbacks failed)', + actualSigLen: primary.actualSigLen ?? lastMismatch.actualSigLen, + expectedSigLen: primary.expectedSigLen ?? lastMismatch.expectedSigLen, + }; }