initluyhgul
This commit is contained in:
178
README.md
178
README.md
@@ -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 <db-host> -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=<kid-from-bitok>
|
|
||||||
vault kv put dev-secrets/jwt/kids/<kid-from-bitok> \
|
|
||||||
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@<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
|
|
||||||
|
|
||||||
| 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 <X>` (только если колонки нет — `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 под реальный трафик
|
|
||||||
@@ -9,18 +9,13 @@ import { logger } from '../lib/logger';
|
|||||||
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
* Token format: <b64url_payload>.<b64url_timestamp>.<b64url_signature>
|
||||||
*
|
*
|
||||||
* Digests: sha1 (20-byte sig, legacy Flask-WTF), sha256 (32), sha512 (64, itsdangerous 2.x default).
|
* 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
|
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 {
|
export interface CsrfConfig {
|
||||||
secret: string;
|
secret: string;
|
||||||
salt: string;
|
salt: string;
|
||||||
@@ -28,7 +23,6 @@ export interface CsrfConfig {
|
|||||||
maxAgeSec: number;
|
maxAgeSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live config — атомарно подменяется через swapCsrfConfig()
|
|
||||||
let current: CsrfConfig | null = null;
|
let current: CsrfConfig | null = null;
|
||||||
|
|
||||||
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
|
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, '');
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
|
|
||||||
*/
|
|
||||||
export async function fetchCsrfConfig(
|
export async function fetchCsrfConfig(
|
||||||
addr: string,
|
addr: string,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -68,15 +59,12 @@ export async function fetchCsrfConfig(
|
|||||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
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';
|
let digest: 'sha256' | 'sha512' = 'sha256';
|
||||||
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||||
digest = secrets.digest;
|
digest = secrets.digest;
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
|
let maxAgeSec = 60 * 60 * 24 * 7;
|
||||||
if (secrets.max_age_sec) {
|
if (secrets.max_age_sec) {
|
||||||
const n = parseInt(secrets.max_age_sec);
|
const n = parseInt(secrets.max_age_sec);
|
||||||
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
|
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 {
|
function decodeTimestamp(encoded: string): number {
|
||||||
const raw = b64urlDecode(encoded);
|
const raw = b64urlDecode(encoded);
|
||||||
// Использовать арифметику вместо bitwise — bitwise overflowит на 32-bit signed
|
|
||||||
// после 2038 если timestamp encoding станет 5-байтным.
|
|
||||||
let ts = 0;
|
let ts = 0;
|
||||||
for (const b of raw) ts = ts * 256 + b;
|
for (const b of raw) ts = ts * 256 + b;
|
||||||
return ts + ITSDANGEROUS_EPOCH;
|
return ts + ITSDANGEROUS_EPOCH;
|
||||||
@@ -119,6 +105,8 @@ export interface CsrfVerifyResult {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
actualSigLen?: number;
|
actualSigLen?: number;
|
||||||
expectedSigLen?: number;
|
expectedSigLen?: number;
|
||||||
|
fallbackDigest?: CsrfDigest;
|
||||||
|
fallbackSalt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsrfConfigSummary {
|
export interface CsrfConfigSummary {
|
||||||
@@ -127,6 +115,16 @@ export interface CsrfConfigSummary {
|
|||||||
maxAgeSec: number;
|
maxAgeSec: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CsrfDigest = 'sha1' | 'sha256' | 'sha512';
|
||||||
|
|
||||||
|
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
|
||||||
|
20: 'sha1',
|
||||||
|
32: 'sha256',
|
||||||
|
64: 'sha512',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL_DIGESTS: CsrfDigest[] = ['sha1', 'sha256', 'sha512'];
|
||||||
|
|
||||||
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
|
export function getCsrfConfigSummary(): CsrfConfigSummary | null {
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
return { salt: current.salt, digest: current.digest, maxAgeSec: current.maxAgeSec };
|
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';
|
function verifyCsrfTokenWithParams(
|
||||||
|
|
||||||
/** HMAC output length → digest (auth-service legacy часто sha1 → sigLen=20). */
|
|
||||||
const DIGEST_BY_SIG_LEN: Record<number, CsrfDigest> = {
|
|
||||||
20: 'sha1',
|
|
||||||
32: 'sha256',
|
|
||||||
64: 'sha512',
|
|
||||||
};
|
|
||||||
|
|
||||||
function verifyCsrfTokenWithDigest(
|
|
||||||
cfg: CsrfConfig,
|
cfg: CsrfConfig,
|
||||||
|
salt: string,
|
||||||
digest: CsrfDigest,
|
digest: CsrfDigest,
|
||||||
token: string,
|
token: string,
|
||||||
): CsrfVerifyResult {
|
): CsrfVerifyResult {
|
||||||
@@ -184,7 +174,7 @@ function verifyCsrfTokenWithDigest(
|
|||||||
|
|
||||||
const tsStr = payloadTs.slice(prevDot + 1);
|
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();
|
const expectedSig = crypto.createHmac(digest, derived).update(payloadTs).digest();
|
||||||
|
|
||||||
let actualSig: Buffer;
|
let actualSig: Buffer;
|
||||||
@@ -218,48 +208,80 @@ function verifyCsrfTokenWithDigest(
|
|||||||
return { valid: true };
|
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 order: CsrfDigest[] = [];
|
||||||
const add = (d: CsrfDigest) => {
|
const add = (d: CsrfDigest) => {
|
||||||
if (!order.includes(d)) order.push(d);
|
if (!order.includes(d)) order.push(d);
|
||||||
};
|
};
|
||||||
|
add(vaultDigest);
|
||||||
add(current!.digest);
|
|
||||||
if (primary.actualSigLen !== undefined) {
|
if (primary.actualSigLen !== undefined) {
|
||||||
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
|
const inferred = DIGEST_BY_SIG_LEN[primary.actualSigLen];
|
||||||
if (inferred) add(inferred);
|
if (inferred) add(inferred);
|
||||||
}
|
}
|
||||||
add('sha1');
|
for (const d of ALL_DIGESTS) add(d);
|
||||||
add('sha256');
|
|
||||||
add('sha512');
|
|
||||||
return order;
|
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 {
|
export function verifyCsrfToken(token: string): CsrfVerifyResult {
|
||||||
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
|
||||||
|
|
||||||
const primaryDigest = current.digest;
|
const vaultSalt = current.salt;
|
||||||
const primary = verifyCsrfTokenWithDigest(current, primaryDigest, token);
|
const vaultDigest = current.digest as CsrfDigest;
|
||||||
|
const salts = saltsToTry(vaultSalt);
|
||||||
|
|
||||||
|
const primary = verifyCsrfTokenWithParams(current, vaultSalt, vaultDigest, token);
|
||||||
if (primary.valid) return primary;
|
if (primary.valid) return primary;
|
||||||
|
|
||||||
if (primary.reason !== 'Signature length mismatch' && primary.reason !== 'Signature mismatch') {
|
if (!isRetryableVerifyFailure(primary.reason)) {
|
||||||
return primary;
|
return primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const digest of digestsToTry(primary)) {
|
const digests = digestsToTry(primary, vaultDigest);
|
||||||
if (digest === primaryDigest) continue;
|
let lastMismatch: CsrfVerifyResult = primary;
|
||||||
const attempt = verifyCsrfTokenWithDigest(current, digest, token);
|
|
||||||
if (attempt.valid) {
|
for (const salt of salts) {
|
||||||
logger.warn(
|
for (const digest of digests) {
|
||||||
`CSRF verified with fallback digest ${digest} (Vault digest=${primaryDigest}, ` +
|
if (salt === vaultSalt && digest === vaultDigest) continue;
|
||||||
`sigLen=${primary.actualSigLen ?? '?'}). Align auth digest_method with Vault.`,
|
|
||||||
);
|
const attempt = verifyCsrfTokenWithParams(current, salt, digest, token);
|
||||||
return { valid: true };
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user