security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)
This commit is contained in:
@@ -10,6 +10,11 @@ VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||
# CSRF загружается если указан путь (оставь пустым чтобы отключить CSRF)
|
||||
VAULT_CSRF_PATH=
|
||||
|
||||
# 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 ────────────────────────────────────────────────────────────
|
||||
# Allowed: RS256 / RS384 / RS512 / ES256 / ES384 / ES512 / EdDSA / PS256 / PS384 / PS512
|
||||
JWT_ALGORITHM=RS256
|
||||
|
||||
@@ -32,6 +32,8 @@ COPY --from=build --chown=app:app /app/apps/api/dist ./dist
|
||||
COPY --from=build --chown=app:app /app/apps/api/swagger.json ./swagger.json
|
||||
COPY --from=build --chown=app:app /app/apps/api/package.json ./package.json
|
||||
|
||||
RUN mkdir -p /app/logs && chown -R app:app /app/logs
|
||||
|
||||
USER app
|
||||
EXPOSE 3001
|
||||
|
||||
|
||||
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# CryptoWallet API — Deployment Bundle (v5.0 custodial)
|
||||
|
||||
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 (один раз навсегда)
|
||||
|
||||
```bash
|
||||
# 1. Master-key в Vault
|
||||
vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
||||
|
||||
# 2. DB schema
|
||||
psql -h 72.56.9.76 -U postgres_user -d postgres -f cryptowallet-schema.sql
|
||||
```
|
||||
|
||||
⚠️ **Master-key менять нельзя** — все existing encrypted_mnemonic станут нерасшифровываемыми. Сервис логирует WARN если в Vault ключ изменился.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
scp -P 2222 -r deployserver/ server@176.124.213.102:~/cryptowallet/
|
||||
ssh server@176.124.213.102 -p 2222
|
||||
cd ~/cryptowallet && cp .env.example .env && nano .env && ./start.sh
|
||||
```
|
||||
|
||||
В `.env` обязательны: `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, `JWT_ISSUER`, `JWT_AUDIENCE`, `CORS_ORIGINS`.
|
||||
|
||||
## Endpoints (24)
|
||||
|
||||
| Method | Path | Описание |
|
||||
|---|---|---|
|
||||
| GET | /api/health | Liveness (public) |
|
||||
| GET | /api/docs | Swagger UI |
|
||||
| POST | **/api/wallets/create** | **Сервер создаёт коша** (no body, returns addresses) |
|
||||
| GET | /api/wallets | Список адресов юзера |
|
||||
| POST | **/api/wallets/mnemonic/reveal** | Reveal seed (body confirm + 5/час) |
|
||||
| GET | /api/wallets/{chain}/balance | Баланс |
|
||||
| GET | /api/wallets/{chain}/transactions | История tx |
|
||||
| POST | **/api/wallets/{chain}/send** | **Сервер подписывает + broadcast** |
|
||||
| ... | /api/btc/* /api/tron/* /api/sol/* /api/bsc/* /api/relay/* | Proxy endpoints |
|
||||
|
||||
## Security highlights
|
||||
|
||||
- **AES-256-GCM** для encrypted_mnemonic (12-byte random IV, 16-byte auth tag, fail-secure)
|
||||
- **Master-key set-once** (rotation запрещена)
|
||||
- **Race-safe createWallet**: `db.transaction` + `UPDATE WHERE encrypted_mnemonic IS NULL`
|
||||
- **TRX MITM defense**: local recompute txID + verify raw_data перед подписью
|
||||
- **EVM gas cap** 500 gwei (применён к tx, не только check)
|
||||
- **Address checksum validation** (BTC bitcoinjs-lib, TRX bs58check, SOL PublicKey, EVM EIP-55)
|
||||
- **assertAddressMatch** — derived(mnemonic, path) === DB.address перед подписью
|
||||
- **SOL confirmTransaction** — ждём подтверждения
|
||||
- **BTC** P2WPKH bech32, fee fallback 15 sat/vB + 1.1x safety, dust 294, broadcast 20s timeout
|
||||
- **POST mnemonic/reveal** + CSRF + body confirm token + 5/час + audit-log
|
||||
- **Logger sanitization**: password/token/mnemonic/hex64/BIP39-phrase patterns
|
||||
- **Audit log** `logs/audit.log` (wallet.create / wallet.send / mnemonic.reveal)
|
||||
- **Hourly key rotation**: JWT keys + CSRF secret из Vault (master-key НЕ ротируется)
|
||||
- **Fail-fast**: сервис не стартует без master-key, JWT_ISSUER, JWT_AUDIENCE
|
||||
|
||||
## Update / Rebuild
|
||||
|
||||
```bash
|
||||
scp -P 2222 -r deployserver/apps server@176.124.213.102:~/cryptowallet/
|
||||
ssh server@176.124.213.102 -p 2222 'cd cryptowallet && docker compose up -d --build'
|
||||
```
|
||||
@@ -11,11 +11,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
"bip32": "^4.0.0",
|
||||
"bip39": "^3.1.0",
|
||||
"bitcoinjs-lib": "^6.1.5",
|
||||
"bs58": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"ed25519-hd-key": "^1.3.0",
|
||||
"ethers": "5.7.2",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
@@ -24,6 +27,7 @@
|
||||
"knex": "^3.1.0",
|
||||
"pg": "^8.13.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"tiny-secp256k1": "^2.2.3",
|
||||
"ulidx": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { swaggerSpec } from './config/swagger';
|
||||
import { traceMiddleware } from './middleware/trace';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
import { csrfMiddleware } from './middleware/csrf';
|
||||
import { globalLimiter, mutateLimiter, sensitiveLimiter } from './middleware/rate-limit';
|
||||
import { globalLimiter, mutateLimiter, sensitiveLimiter, mnemonicRevealLimiter } from './middleware/rate-limit';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import walletRoutes from './routes/wallet.routes';
|
||||
import relayProxyRoutes from './routes/relay-proxy.routes';
|
||||
@@ -52,6 +52,7 @@ const protect = [authMiddleware, csrfMiddleware];
|
||||
|
||||
// Sensitive — самый строгий лимит. Каждый POST защищён JWT + CSRF.
|
||||
app.use('/api/wallets/create', ...protect, sensitiveLimiter);
|
||||
app.use('/api/wallets/mnemonic/reveal', ...protect, mnemonicRevealLimiter);
|
||||
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
|
||||
|
||||
// Mutating (proxy + read endpoints) — повышенный лимит
|
||||
|
||||
@@ -31,11 +31,26 @@ export let env = {
|
||||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||||
csrfPath: p.VAULT_CSRF_PATH || '',
|
||||
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
|
||||
},
|
||||
cors: {
|
||||
// No default — каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin доступа.
|
||||
origins: (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean),
|
||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
|
||||
// Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin.
|
||||
// Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety.
|
||||
origins: (p.CORS_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean)
|
||||
.filter((o) => {
|
||||
try {
|
||||
const u = new URL(o);
|
||||
return u.protocol === 'https:' || u.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
// Default = false (fail-secure). Чтобы включить credentials cross-origin —
|
||||
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
|
||||
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',
|
||||
},
|
||||
port: parseInt(p.API_PORT || '3001'),
|
||||
relayApiKey: p.RELAY_API_KEY || null,
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { db } from '../config/database';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import { UserModel } from '../models/user.model';
|
||||
import { getBalance, getTransactions, buildSend } from '../services/wallet-ops.service';
|
||||
import { getBalance, getTransactions } from '../services/wallet-ops.service';
|
||||
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
||||
import { signAndBroadcast } from '../services/wallet-signer.service';
|
||||
import { auditLog, auditLogStrict } from '../lib/audit-log';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const ALLOWED_CHAINS = new Set<ChainCode>(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']);
|
||||
const MAX_WALLETS_PER_REQUEST = 20;
|
||||
const MAX_DERIVATION_PATH = 64;
|
||||
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
||||
const MAX_TX_LIMIT = 100;
|
||||
const BIP32_PATH_RE = /^m(\/[0-9]+'?)*$/;
|
||||
|
||||
class ConflictError extends Error {
|
||||
constructor() { super('Wallet already exists'); }
|
||||
}
|
||||
|
||||
function isChain(value: unknown): value is ChainCode {
|
||||
return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode);
|
||||
@@ -37,69 +43,153 @@ export const WalletController = {
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/create — non-custodial upsert.
|
||||
* Клиент сам деривит mnemonic и шлёт массив { chain, address, derivationPath }.
|
||||
* Сервер валидирует и сохраняет (upsert по user_id+chain).
|
||||
* POST /api/wallets/create — custodial bootstrap.
|
||||
* Сервер: генерит mnemonic → деривит адреса 5 chains → шифрует AES-GCM (master из Vault)
|
||||
* → атомарно (UPDATE WHERE NULL + INSERT в одной транзакции) сохраняет
|
||||
* Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
|
||||
*/
|
||||
async createWallets(req: Request, res: Response) {
|
||||
async createWallet(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const { wallets } = req.body ?? {};
|
||||
|
||||
if (!Array.isArray(wallets) || wallets.length === 0 || wallets.length > MAX_WALLETS_PER_REQUEST) {
|
||||
if (!isCryptoReady()) {
|
||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
let mnemonic: string | null = null;
|
||||
try {
|
||||
await UserModel.ensureExists(userId);
|
||||
|
||||
if (await UserModel.hasMnemonic(userId)) {
|
||||
res.status(409).json({ success: false, error: 'Wallet already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = generateMnemonic();
|
||||
const derived = await deriveAllAddresses(mnemonic);
|
||||
const blob = encryptMnemonic(mnemonic);
|
||||
|
||||
const created = await db.transaction(async (trx) => {
|
||||
const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx);
|
||||
if (!claimed) {
|
||||
throw new ConflictError();
|
||||
}
|
||||
await WalletModel.createMany(
|
||||
derived.map((w) => ({
|
||||
user_id: userId,
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivation_path: w.derivationPath,
|
||||
})),
|
||||
trx,
|
||||
);
|
||||
return derived;
|
||||
});
|
||||
|
||||
await auditLog({
|
||||
event: 'wallet.create',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'success',
|
||||
meta: { chains: created.map((w) => w.chain) },
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: created.map((w) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivationPath,
|
||||
})),
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof ConflictError) {
|
||||
// Audit conflict events — surveillance attacker spamming the endpoint
|
||||
await auditLog({
|
||||
event: 'wallet.create',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
errorCode: 'CONFLICT',
|
||||
});
|
||||
res.status(409).json({ success: false, error: 'Wallet already exists' });
|
||||
return;
|
||||
}
|
||||
logger.error(`createWallet failed for user ${userId}: ${err.stack || err.message}`);
|
||||
await auditLog({
|
||||
event: 'wallet.create',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
errorCode: 'INTERNAL',
|
||||
});
|
||||
res.status(500).json({ success: false, error: 'Failed to create wallet' });
|
||||
} finally {
|
||||
mnemonic = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/mnemonic/reveal — settings: показать seed.
|
||||
* Защита: POST + CSRF + body confirm token + rate-limit 5/час + audit-log.
|
||||
*/
|
||||
async revealMnemonic(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
|
||||
if (!isCryptoReady()) {
|
||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.body?.confirm !== 'I_UNDERSTAND_SEED_IS_SECRET') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `wallets must be a non-empty array (max ${MAX_WALLETS_PER_REQUEST})`,
|
||||
error: 'Missing or invalid confirm token. Send { "confirm": "I_UNDERSTAND_SEED_IS_SECRET" } in body.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const w of wallets) {
|
||||
if (!w || typeof w !== 'object') {
|
||||
res.status(400).json({ success: false, error: 'Invalid wallet entry' });
|
||||
return;
|
||||
}
|
||||
if (!isChain(w.chain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain' });
|
||||
return;
|
||||
}
|
||||
if (!isValidAddress(w.chain, w.address)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid address format for chain' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof w.derivationPath !== 'string' ||
|
||||
w.derivationPath.length === 0 ||
|
||||
w.derivationPath.length > MAX_DERIVATION_PATH ||
|
||||
!BIP32_PATH_RE.test(w.derivationPath)
|
||||
) {
|
||||
res.status(400).json({ success: false, error: 'Invalid derivationPath (BIP32 m/.. format expected)' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await UserModel.ensureExists(userId);
|
||||
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||
if (!blob) {
|
||||
await auditLog({
|
||||
event: 'mnemonic.reveal',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
errorCode: 'NOT_FOUND',
|
||||
});
|
||||
res.status(404).json({ success: false, error: 'Wallet not created yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await WalletModel.upsertMany(
|
||||
wallets.map((w: { chain: ChainCode; address: string; derivationPath: string }) => ({
|
||||
user_id: userId,
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivation_path: w.derivationPath,
|
||||
})),
|
||||
);
|
||||
const mnemonic = decryptMnemonic(blob);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: rows.map((w) => ({
|
||||
chain: w.chain,
|
||||
address: w.address,
|
||||
derivationPath: w.derivation_path,
|
||||
})),
|
||||
});
|
||||
// CRITICAL operation — fail-secure audit (если запись fail'ит — не отдаём mnemonic).
|
||||
try {
|
||||
await auditLogStrict({
|
||||
event: 'mnemonic.reveal',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'success',
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit log MUST succeed for mnemonic.reveal: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { mnemonic } });
|
||||
} catch (err: any) {
|
||||
logger.error(`createWallets failed for user ${userId}: ${err.stack || err.message}`);
|
||||
res.status(500).json({ success: false, error: 'Internal error' });
|
||||
logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || err.message}`);
|
||||
await auditLog({
|
||||
event: 'mnemonic.reveal',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
errorCode: 'INTERNAL',
|
||||
});
|
||||
res.status(500).json({ success: false, error: 'Failed to reveal mnemonic' });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -160,8 +250,9 @@ export const WalletController = {
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/:chain/send — build unsigned tx (non-custodial).
|
||||
* Возвращает unsigned tx; клиент подписывает приватом и broadcast'ит сам.
|
||||
* POST /api/wallets/:chain/send — custodial sign + broadcast.
|
||||
* Юзер жмёт "подтвердить" → клиент шлёт {to, amount, token?} → сервер расшифровывает
|
||||
* мнемонику, деривит privkey, подписывает, broadcast'ит → возвращает txid.
|
||||
*/
|
||||
async sendFromChain(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
@@ -172,6 +263,11 @@ export const WalletController = {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCryptoReady()) {
|
||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { to, amount, token } = req.body ?? {};
|
||||
|
||||
if (!isValidAddress(chain, String(to))) {
|
||||
@@ -192,6 +288,7 @@ export const WalletController = {
|
||||
normalizedToken = token.toUpperCase();
|
||||
}
|
||||
|
||||
let mnemonic: string | null = null;
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain);
|
||||
if (!wallet) {
|
||||
@@ -199,21 +296,62 @@ export const WalletController = {
|
||||
return;
|
||||
}
|
||||
|
||||
const tx = await buildSend({
|
||||
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||
if (!blob) {
|
||||
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
|
||||
const result = await signAndBroadcast({
|
||||
chain,
|
||||
from: wallet.address,
|
||||
mnemonic,
|
||||
to: String(to),
|
||||
amount: String(amount),
|
||||
token: normalizedToken,
|
||||
expectedFromAddress: wallet.address,
|
||||
});
|
||||
|
||||
res.json({ success: true, data: tx });
|
||||
// CRITICAL operation — fail-secure audit
|
||||
try {
|
||||
await auditLogStrict({
|
||||
event: 'wallet.send',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'success',
|
||||
meta: { chain, hasToken: !!normalizedToken, txid: result.txid },
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit log MUST succeed for wallet.send (txid=${result.txid}): ${auditErr.message}`);
|
||||
// Tx уже broadcast'нут — нельзя отменить. Возвращаем txid но с warning о audit.
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { txid: result.txid, chain },
|
||||
warning: 'Transaction broadcast succeeded but audit log write failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { txid: result.txid, chain } });
|
||||
} catch (err: any) {
|
||||
logger.error(`buildSend ${chain} failed for user ${userId}: ${err.stack || err.message}`);
|
||||
const msg = err?.message?.toLowerCase?.().includes('not supported')
|
||||
? 'Token/chain combination not supported'
|
||||
: 'Failed to build transaction';
|
||||
logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||
await auditLog({
|
||||
event: 'wallet.send',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
meta: { chain },
|
||||
errorCode: 'BROADCAST_FAILED',
|
||||
});
|
||||
const msg = err?.message?.toLowerCase?.().includes('insufficient')
|
||||
? 'Insufficient balance'
|
||||
: err?.message?.toLowerCase?.().includes('not supported')
|
||||
? 'Token/chain combination not supported'
|
||||
: 'Failed to broadcast transaction';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
} finally {
|
||||
mnemonic = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import app from './app';
|
||||
import { env, initEnv } from './config/env';
|
||||
import { refreshAllKeys, startKeyRotation, stopKeyRotation } from './services/key-rotation.service';
|
||||
import { isCryptoReady } from './services/crypto.service';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
// Global error handlers — иначе unhandled errors идут в stderr без sanitization (leak secrets)
|
||||
process.on('unhandledRejection', (reason: any) => {
|
||||
logger.error(`Unhandled rejection: ${reason?.stack || reason?.message || reason}`);
|
||||
});
|
||||
process.on('uncaughtException', (err: Error) => {
|
||||
logger.error(`Uncaught exception: ${err.stack || err.message}`);
|
||||
// Process state could be corrupt — exit cleanly
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
logger.info(`Wallet service instance started with id ${logger.instanceId}`);
|
||||
|
||||
await initEnv();
|
||||
await refreshAllKeys();
|
||||
|
||||
// Custodial: без master-key сервис не может расшифровать ни одну мнемонику — fail fast.
|
||||
if (!isCryptoReady()) {
|
||||
logger.error('Crypto master key not loaded — refusing to start (custodial wallets require it)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
startKeyRotation();
|
||||
|
||||
const server = app.listen(env.port, () => {
|
||||
|
||||
75
apps/api/src/lib/audit-log.ts
Normal file
75
apps/api/src/lib/audit-log.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Audit log — append-only JSON lines в `logs/audit.log`.
|
||||
* Используется для критических custodial операций.
|
||||
* НИКОГДА не логирует mnemonic / privkey / encrypted blob.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { getTraceId } from './trace-store';
|
||||
|
||||
const AUDIT_DIR = path.resolve(__dirname, '../../../../logs');
|
||||
const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log');
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureFile(): Promise<void> {
|
||||
if (initialized) return;
|
||||
try {
|
||||
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
||||
const handle = await fs.open(AUDIT_FILE, 'a', 0o600);
|
||||
await handle.close();
|
||||
try {
|
||||
await fs.chmod(AUDIT_FILE, 0o600);
|
||||
} catch {
|
||||
// Windows chmod — игнор
|
||||
}
|
||||
initialized = true;
|
||||
} catch (err: any) {
|
||||
logger.error(`Audit log init failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
event: string;
|
||||
userId: string;
|
||||
ip?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
result?: 'success' | 'failure';
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort write. Если запись провалилась — только log, не throws.
|
||||
* Используется для не-критических событий (wallet.create success, etc).
|
||||
*/
|
||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||
await ensureFile();
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
trace_id: getTraceId(),
|
||||
...entry,
|
||||
});
|
||||
try {
|
||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
||||
} catch (err: any) {
|
||||
logger.error(`Audit log write failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-secure write. Если запись провалилась — throws.
|
||||
* Используется для critical security событий (mnemonic.reveal, wallet.send),
|
||||
* где compliance требует чтобы операция НЕ происходила без audit-trail.
|
||||
*/
|
||||
export async function auditLogStrict(entry: AuditEntry): Promise<void> {
|
||||
await ensureFile();
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
trace_id: getTraceId(),
|
||||
...entry,
|
||||
});
|
||||
// Без try/catch — caller обрабатывает failure
|
||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
||||
}
|
||||
@@ -35,14 +35,14 @@ function getCallerInfo(): { file: string; line: number } {
|
||||
const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [
|
||||
{ regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(role[_-]?id|secret[_-]?id)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(mnemonic|seed[_-]?phrase|private[_-]?key|priv[_-]?key)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' },
|
||||
{ regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' },
|
||||
{ regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' },
|
||||
{ regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' },
|
||||
// BIP39 mnemonic phrase (12+ lowercase английских слов через пробел) — рискованный паттерн,
|
||||
// но лучше пере-санитайзить чем пропустить mnemonic в логи
|
||||
{ regex: /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/g, replace: '[REDACTED_MNEMONIC]' },
|
||||
// BIP39 mnemonic phrase (12-24 lowercase английских слов через пробел) — case-insensitive
|
||||
{ regex: /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/gi, replace: '[REDACTED_MNEMONIC]' },
|
||||
// Hex privkey (64 hex chars подряд, optional 0x)
|
||||
{ regex: /\b(0x)?[0-9a-fA-F]{64}\b/g, replace: '[REDACTED_HEX64]' },
|
||||
];
|
||||
|
||||
@@ -35,7 +35,9 @@ export async function authMiddleware(req: Request, res: Response, next: NextFunc
|
||||
req.auth = await verifyAccessToken(token);
|
||||
next();
|
||||
} catch (err: any) {
|
||||
// Лог детали server-side, клиенту — единое generic сообщение.
|
||||
// Иначе err.message distinguishes "expired" vs "bad signature" vs "kid unknown" → info oracle.
|
||||
logger.warn(`Auth failed: ${err.message}`);
|
||||
res.status(err.status || 401).json({ success: false, error: err.message || 'Invalid token' });
|
||||
res.status(401).json({ success: false, error: 'Unauthorized' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,61 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
import { verifyCsrfToken, isCsrfConfigured } from '../services/csrf.service';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
/**
|
||||
* CSRF middleware с double-submit pattern.
|
||||
*
|
||||
* Требует ОБА source'а: cookie `csrf_token` AND header `X-CSRF-Token`,
|
||||
* сравнивает их constant-time. Без обоих или при несовпадении — 403.
|
||||
*
|
||||
* Защита: если attacker украл только cookie (auto-sent при cross-site POST),
|
||||
* он не может выставить header X-CSRF-Token из чужого origin без CORS,
|
||||
* а CORS у нас явный whitelist. Single-source check был bypass'able.
|
||||
*/
|
||||
export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (SAFE_METHODS.has(req.method)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Если CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем.
|
||||
// Это явная конфигурация, не fail-open.
|
||||
// CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем явно.
|
||||
if (!env.vault.csrfPath) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// CSRF включён, но секрет не загружен (Vault недоступен) → fail-secure: 503.
|
||||
// НИКОГДА не пропускаем mutating запросы при не-валидном состоянии.
|
||||
// CSRF включён, но секрет не загружен → fail-secure 503.
|
||||
if (!isCsrfConfigured()) {
|
||||
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
|
||||
res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = req.cookies?.csrf_token || req.headers['x-csrf-token'];
|
||||
const cookieToken = req.cookies?.csrf_token;
|
||||
const headerToken = req.headers['x-csrf-token'];
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
res.status(403).json({ success: false, error: 'CSRF token missing' });
|
||||
// Double-submit: ОБА обязательны.
|
||||
if (!cookieToken || typeof cookieToken !== 'string' ||
|
||||
!headerToken || typeof headerToken !== 'string') {
|
||||
res.status(403).json({ success: false, error: 'CSRF token missing (need cookie + header)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = verifyCsrfToken(token);
|
||||
// Constant-time сравнение cookie === header (защита от timing oracle).
|
||||
const a = Buffer.from(cookieToken);
|
||||
const b = Buffer.from(headerToken);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
logger.warn('CSRF: cookie/header mismatch');
|
||||
res.status(403).json({ success: false, error: 'CSRF token mismatch' });
|
||||
return;
|
||||
}
|
||||
|
||||
// HMAC verify только после совпадения двух source'ов.
|
||||
const result = verifyCsrfToken(cookieToken);
|
||||
if (!result.valid) {
|
||||
logger.warn(`CSRF validation failed: ${result.reason}`);
|
||||
res.status(403).json({ success: false, error: 'Invalid CSRF token' });
|
||||
|
||||
@@ -31,7 +31,7 @@ export const mutateLimiter = rateLimit({
|
||||
message: { success: false, error: 'Too many mutating requests' },
|
||||
});
|
||||
|
||||
// Самый строгий — для send / wallet create (anti-abuse / spam tx prevention)
|
||||
// Самый строгий — для send / wallet create
|
||||
export const sensitiveLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: 10,
|
||||
@@ -40,3 +40,13 @@ export const sensitiveLimiter = rateLimit({
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many sensitive requests' },
|
||||
});
|
||||
|
||||
// Экстремально строгий — reveal seed phrase, 5/час
|
||||
export const mnemonicRevealLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
limit: 5,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
keyGenerator: keyByUserOrIp,
|
||||
message: { success: false, error: 'Too many mnemonic reveal requests' },
|
||||
});
|
||||
|
||||
@@ -2,10 +2,15 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
import { traceStore } from '../lib/trace-store';
|
||||
|
||||
const TRACE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
export function traceMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
const traceId = req.headers['x-trace-id'] as string
|
||||
|| req.headers['x-request-id'] as string
|
||||
|| generateUlid();
|
||||
const supplied = (req.headers['x-trace-id'] || req.headers['x-request-id']) as string | undefined;
|
||||
|
||||
// Validate client-supplied trace-ID — иначе log injection / trace forgery
|
||||
const traceId = (typeof supplied === 'string' && TRACE_ID_RE.test(supplied))
|
||||
? supplied
|
||||
: generateUlid();
|
||||
|
||||
res.setHeader('X-Trace-ID', traceId);
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { db } from '../config/database';
|
||||
import { generateUlid } from '../utils/ulid';
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id: string | null;
|
||||
user_agent: string | null;
|
||||
first_ip: string | null;
|
||||
last_ip: string | null;
|
||||
last_seen_at: Date | null;
|
||||
revoked_at: Date | null;
|
||||
refresh_jti_hash: string | null;
|
||||
refresh_expires_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const SessionModel = {
|
||||
async findBySid(sid: string): Promise<SessionRow | undefined> {
|
||||
return db('sessions').where({ sid }).whereNull('revoked_at').first();
|
||||
},
|
||||
|
||||
async findByUserId(userId: string): Promise<SessionRow[]> {
|
||||
return db('sessions').where({ user_id: userId }).whereNull('revoked_at');
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
sid: string;
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
first_ip?: string;
|
||||
refresh_jti_hash?: string;
|
||||
refresh_expires_at?: Date;
|
||||
}): Promise<SessionRow> {
|
||||
const [session] = await db('sessions')
|
||||
.insert({
|
||||
id: generateUlid(),
|
||||
...data,
|
||||
last_ip: data.first_ip || null,
|
||||
})
|
||||
.returning('*');
|
||||
return session;
|
||||
},
|
||||
|
||||
async revoke(sid: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async revokeAllForUser(userId: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ user_id: userId })
|
||||
.whereNull('revoked_at')
|
||||
.update({ revoked_at: db.fn.now(), updated_at: db.fn.now() });
|
||||
},
|
||||
|
||||
async updateLastSeen(sid: string, ip: string): Promise<void> {
|
||||
await db('sessions')
|
||||
.where({ sid })
|
||||
.update({ last_seen_at: db.fn.now(), last_ip: ip, updated_at: db.fn.now() });
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Knex } from 'knex';
|
||||
import { db } from '../config/database';
|
||||
|
||||
export interface UserRow {
|
||||
@@ -17,25 +18,44 @@ export interface UserRow {
|
||||
kyc_verified: boolean;
|
||||
kyc_verified_at: Date | null;
|
||||
is_deleted: boolean;
|
||||
encrypted_vault: string | null; // legacy, unused
|
||||
vault_salt: string | null; // legacy, unused
|
||||
encrypted_mnemonic: string | null; // legacy from custodial-experiment, unused
|
||||
encrypted_vault: string | null; // legacy, unused
|
||||
vault_salt: string | null; // legacy, unused
|
||||
encrypted_mnemonic: string | null; // AES-GCM blob (custodial)
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export const UserModel = {
|
||||
async findById(id: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ id, is_deleted: false }).first();
|
||||
},
|
||||
// Public-safe subset, без encrypted_* / password_hash.
|
||||
// Используется когда results могут попасть в response.
|
||||
const PUBLIC_USER_COLUMNS = [
|
||||
'id',
|
||||
'email',
|
||||
'last_name',
|
||||
'first_name',
|
||||
'middle_name',
|
||||
'birth_date',
|
||||
'phone',
|
||||
'kyc_verified',
|
||||
'kyc_verified_at',
|
||||
'is_deleted',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
] as const;
|
||||
|
||||
async findByEmail(email: string): Promise<UserRow | undefined> {
|
||||
return db('users').where({ email, is_deleted: false }).first();
|
||||
export const UserModel = {
|
||||
/**
|
||||
* Public-safe lookup. НЕ возвращает encrypted_* / password_hash.
|
||||
*/
|
||||
async findById(id: string) {
|
||||
return db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select(...PUBLIC_USER_COLUMNS)
|
||||
.first();
|
||||
},
|
||||
|
||||
/**
|
||||
* Создать запись пользователя если её нет.
|
||||
* id берётся из JWT (sub). Email/password_hash — заглушки, реальная auth у BITOK.
|
||||
* id — из JWT.sub (валидируется на JWT verify).
|
||||
*/
|
||||
async ensureExists(id: string): Promise<void> {
|
||||
await db('users')
|
||||
@@ -48,14 +68,36 @@ export const UserModel = {
|
||||
.ignore();
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<UserRow, 'id' | 'created_at'>>,
|
||||
): Promise<UserRow | undefined> {
|
||||
const [user] = await db('users')
|
||||
.where({ id })
|
||||
.update({ ...data, updated_at: db.fn.now() })
|
||||
.returning('*');
|
||||
return user;
|
||||
/**
|
||||
* Set-once: возвращает true только если этот вызов реально занял slot.
|
||||
* Защита от race: два параллельных createWallet не могут оба перезаписать.
|
||||
* Также filter is_deleted=false — не давать zombie-account resurrection.
|
||||
*/
|
||||
async setEncryptedMnemonicIfAbsent(id: string, blob: string, trx?: Knex.Transaction): Promise<boolean> {
|
||||
const k = trx || db;
|
||||
const affected = await k('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.whereNull('encrypted_mnemonic')
|
||||
.update({
|
||||
encrypted_mnemonic: blob,
|
||||
updated_at: k.fn.now(),
|
||||
});
|
||||
return affected === 1;
|
||||
},
|
||||
|
||||
async getEncryptedMnemonic(id: string): Promise<string | null> {
|
||||
const row = await db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select('encrypted_mnemonic')
|
||||
.first();
|
||||
return row?.encrypted_mnemonic ?? null;
|
||||
},
|
||||
|
||||
async hasMnemonic(id: string): Promise<boolean> {
|
||||
const row = await db('users')
|
||||
.where({ id, is_deleted: false })
|
||||
.select(db.raw('encrypted_mnemonic IS NOT NULL AS has'))
|
||||
.first();
|
||||
return Boolean(row?.has);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,12 @@ export const WalletModel = {
|
||||
return db('wallets').where({ user_id: userId, chain }).first();
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert wallets. UNIQUE(user_id, chain) на уровне DB предотвращает дубликаты —
|
||||
* на конфликт kicks transaction rollback.
|
||||
* Используется только из createWallet (custodial bootstrap, one-shot per user).
|
||||
* НЕ используем upsertMany — нет легитимного пути менять адрес после генерации.
|
||||
*/
|
||||
async createMany(
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[],
|
||||
trx?: Knex.Transaction,
|
||||
@@ -27,19 +33,4 @@ export const WalletModel = {
|
||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||
return (trx || db)('wallets').insert(withIds).returning('*');
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert wallets, on conflict (user_id, chain) update address + derivation_path.
|
||||
* Used by POST /api/wallets — клиент шлёт массив адресов после регистрации в BITOK.
|
||||
*/
|
||||
async upsertMany(
|
||||
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
|
||||
): Promise<WalletRow[]> {
|
||||
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
|
||||
return db('wallets')
|
||||
.insert(withIds)
|
||||
.onConflict(['user_id', 'chain'])
|
||||
.merge(['address', 'derivation_path'])
|
||||
.returning('*');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -85,8 +85,8 @@ async function getSwapQuote(req: Request, res: Response) {
|
||||
toDecimals: TOKEN_DECIMALS[to],
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to get BSC swap quote';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
console.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,18 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt).
|
||||
// "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage
|
||||
// → sandwich attack осушает swap.
|
||||
if (!/^\d+$/.test(String(amount)) || BigInt(amount) <= 0n) {
|
||||
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(String(amountOutMin)) || BigInt(amountOutMin) <= 0n) {
|
||||
res.status(400).json({ success: false, error: 'Invalid amountOutMin (must be positive; 0 = unlimited slippage)' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
|
||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||
@@ -145,10 +157,12 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
);
|
||||
|
||||
if (currentAllowance.lt(ethers.BigNumber.from(amount))) {
|
||||
// Build approve tx
|
||||
// Exact-amount approve (НЕ MaxUint256!). Infinite approval = post-tx attack vector:
|
||||
// если router compromised или attacker узнаёт private key позже, attacker дренит
|
||||
// всё что approved. Approve только то что нужно сейчас.
|
||||
const approveData = tokenContract.interface.encodeFunctionData(
|
||||
'approve',
|
||||
[PANCAKE_ROUTER, ethers.constants.MaxUint256]
|
||||
[PANCAKE_ROUTER, amount]
|
||||
);
|
||||
|
||||
transactions.push({
|
||||
@@ -175,8 +189,8 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
|
||||
res.json({ success: true, transactions });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to build BSC swap';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
console.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Failed to build BSC swap' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ async function getFeeEstimates(_req: Request, res: Response) {
|
||||
async function broadcastTx(req: Request, res: Response) {
|
||||
const { hex } = req.body;
|
||||
|
||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
// BTC max tx serialized ~100KB = 200_000 hex chars. Cap чтобы не abuse'или bandwidth.
|
||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length > 200_000) {
|
||||
res.status(400).json({ success: false, error: 'Invalid transaction hex' });
|
||||
return;
|
||||
}
|
||||
@@ -130,7 +131,8 @@ async function broadcastTx(req: Request, res: Response) {
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
res.status(response.status).json({ success: false, error: text || 'Broadcast failed' });
|
||||
// Don't leak Blockstream error body (could contain UTXO state oracle).
|
||||
res.status(502).json({ success: false, error: 'BTC broadcast failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const router = Router();
|
||||
const RELAY_API_URL = 'https://api.relay.link';
|
||||
const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
||||
const RELAY_TIMEOUT_MS = 20_000;
|
||||
|
||||
// Whitelist: GET-paths + allowed `/execute/<action>` actions.
|
||||
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
|
||||
const ALLOWED_GET_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
||||
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
||||
'swap',
|
||||
'bridge',
|
||||
// добавлять по мере необходимости
|
||||
]);
|
||||
|
||||
router.use(proxyRelayRequest);
|
||||
|
||||
@@ -12,7 +22,19 @@ export default router;
|
||||
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const relayPath = req.path;
|
||||
if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) {
|
||||
|
||||
// Whitelist matching — никакого freeform после `/execute/`.
|
||||
let allowed = false;
|
||||
if (ALLOWED_GET_PATHS.has(relayPath)) {
|
||||
allowed = true;
|
||||
} else if (relayPath.startsWith('/execute/')) {
|
||||
const action = relayPath.slice('/execute/'.length);
|
||||
// action: только alphanumeric, никаких слешей/дотов
|
||||
if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) {
|
||||
allowed = true;
|
||||
}
|
||||
}
|
||||
if (!allowed) {
|
||||
res.status(404).json({ success: false, error: 'Relay endpoint not allowed' });
|
||||
return;
|
||||
}
|
||||
@@ -24,29 +46,55 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
||||
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'undefined') {
|
||||
relayUrl.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(relayUrl.toString(), {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
|
||||
},
|
||||
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
||||
});
|
||||
// Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely.
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||
const payload = await response.text();
|
||||
let upstream: globalThis.Response;
|
||||
try {
|
||||
upstream = await fetch(relayUrl.toString(), {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}),
|
||||
},
|
||||
body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
res.type(contentType);
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
// Force JSON content-type — иначе compromised upstream может вернуть text/html
|
||||
// → reflected XSS если frontend рендерит ответ напрямую.
|
||||
res.status(upstream.status);
|
||||
res.type('application/json');
|
||||
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`);
|
||||
res.json({ success: false, error: 'Relay upstream error' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Send raw text если это валидный JSON, иначе обернём
|
||||
try {
|
||||
res.send(text);
|
||||
} catch {
|
||||
res.json({ success: false, error: 'Relay returned non-JSON' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
res.status(504).json({ success: false, error: 'Relay request timeout' });
|
||||
return;
|
||||
}
|
||||
logger.error(`Relay proxy failed: ${error?.stack || error?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Relay proxy error' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +85,10 @@ async function getQuote(req: Request, res: Response) {
|
||||
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
res.status(response.status).json({ success: false, error: `Jupiter API error: ${text}` });
|
||||
const text = await response.text().catch(() => '');
|
||||
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
|
||||
console.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
|
||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,8 +155,9 @@ async function buildSwap(req: Request, res: Response) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
res.status(response.status).json({ success: false, error: `Jupiter swap build error: ${text}` });
|
||||
const text = await response.text().catch(() => '');
|
||||
console.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
|
||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,10 @@ const ALLOWED_TRC_FUNCTIONS = new Set<string>([
|
||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction.
|
||||
* Whitelisted contracts + function selectors only.
|
||||
*/
|
||||
// Максимальный fee_limit для TriggerSmartContract: 1000 TRX = 1_000_000_000 sun.
|
||||
// Без этого attacker с whitelist-проходящим контрактом мог бы выкачать ресурсы аккаунта.
|
||||
const MAX_FEE_LIMIT_SUN = 1_000_000_000;
|
||||
|
||||
async function triggerSmartContract(req: Request, res: Response) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
@@ -228,6 +232,9 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
const contractAddress = String(body.contract_address || '');
|
||||
const functionSelector = String(body.function_selector || '');
|
||||
const ownerAddress = String(body.owner_address || '');
|
||||
const parameter = String(body.parameter || '');
|
||||
const callValueRaw = body.call_value;
|
||||
const feeLimitRaw = body.fee_limit;
|
||||
|
||||
if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) {
|
||||
res.status(403).json({ success: false, error: 'Contract address not allowed' });
|
||||
@@ -242,6 +249,29 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate parameter — hex (0-9a-f), без 0x prefix, length определена selector'ом.
|
||||
if (!/^[0-9a-fA-F]*$/.test(parameter)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid parameter (must be hex)' });
|
||||
return;
|
||||
}
|
||||
// Лимит длины — самый длинный whitelist'нутый ABI принимает ~3-4 параметра = 256-512 hex chars
|
||||
if (parameter.length > 1024) {
|
||||
res.status(400).json({ success: false, error: 'parameter too long' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Bound fee_limit + call_value
|
||||
const feeLimit = Number(feeLimitRaw ?? 0);
|
||||
if (!Number.isFinite(feeLimit) || feeLimit < 0 || feeLimit > MAX_FEE_LIMIT_SUN) {
|
||||
res.status(400).json({ success: false, error: `fee_limit out of bounds (max ${MAX_FEE_LIMIT_SUN})` });
|
||||
return;
|
||||
}
|
||||
const callValue = Number(callValueRaw ?? 0);
|
||||
if (!Number.isFinite(callValue) || callValue < 0) {
|
||||
res.status(400).json({ success: false, error: 'Invalid call_value' });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
@@ -250,11 +280,22 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
}
|
||||
|
||||
// ВАЖНО: НЕ forward'им req.body целиком — только validated fields.
|
||||
const forwardBody = {
|
||||
owner_address: ownerAddress,
|
||||
contract_address: contractAddress,
|
||||
function_selector: functionSelector,
|
||||
parameter,
|
||||
fee_limit: feeLimit,
|
||||
call_value: callValue,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(req.body),
|
||||
body: JSON.stringify(forwardBody),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -308,8 +308,8 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
res.status(504).json({ success: false, error: 'Build request timed out' });
|
||||
return;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : 'Failed to build swap';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
console.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Failed to build swap' });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { WalletController } from '../controllers/wallet.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/create', WalletController.createWallet);
|
||||
router.get('/', WalletController.getWallets);
|
||||
router.post('/create', WalletController.createWallets);
|
||||
router.post('/mnemonic/reveal', WalletController.revealMnemonic);
|
||||
|
||||
router.get('/:chain/balance', WalletController.getChainBalance);
|
||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||
|
||||
115
apps/api/src/services/crypto.service.ts
Normal file
115
apps/api/src/services/crypto.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
|
||||
import { fetchVaultKV2 } from '../config/vault';
|
||||
|
||||
/**
|
||||
* Symmetric encryption (AES-256-GCM) для хранения мнемоник юзеров.
|
||||
* Master-key читается из Vault при старте.
|
||||
*
|
||||
* Storage layout (base64): IV(12) || ciphertext(N) || authTag(16)
|
||||
*
|
||||
* Ключ — 32 байта (256 бит), храним в Buffer, нигде на диск не пишем.
|
||||
* Если ключ не загружен — encrypt/decrypt бросают ошибку (fail-secure).
|
||||
*/
|
||||
|
||||
const KEY_LEN = 32;
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let masterKey: Buffer | null = null;
|
||||
|
||||
/**
|
||||
* Установить master-key (вызывается ОДНОКРАТНО при первом старте).
|
||||
* Повторная установка после успешной загрузки запрещена (это бы убило все
|
||||
* существующие encrypted_mnemonic).
|
||||
*/
|
||||
export function swapMasterKey(newKey: Buffer): void {
|
||||
if (!newKey || newKey.length !== KEY_LEN) {
|
||||
throw new Error(`swapMasterKey: invalid key (expected ${KEY_LEN} bytes)`);
|
||||
}
|
||||
if (masterKey) {
|
||||
throw new Error('swapMasterKey: master key already loaded; rotation is not supported');
|
||||
}
|
||||
masterKey = newKey;
|
||||
}
|
||||
|
||||
export function masterKeyMatches(candidate: Buffer): boolean {
|
||||
if (!masterKey || !candidate || candidate.length !== KEY_LEN) return false;
|
||||
return masterKey.equals(candidate);
|
||||
}
|
||||
|
||||
export function isCryptoReady(): boolean {
|
||||
return masterKey !== null && masterKey.length === KEY_LEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetch master-key из Vault. НЕ мутирует глобал — возвращает Buffer.
|
||||
* Throws при отсутствии или невалидном формате.
|
||||
*/
|
||||
export async function fetchMasterKey(
|
||||
addr: string,
|
||||
token: string,
|
||||
mount: string,
|
||||
path: string,
|
||||
): Promise<Buffer> {
|
||||
const secrets = await fetchVaultKV2(addr, token, mount, path);
|
||||
if (!secrets) {
|
||||
throw new Error('Failed to load crypto master key from Vault');
|
||||
}
|
||||
|
||||
const raw = secrets.key || secrets.master_key || secrets.MASTER_KEY;
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
throw new Error('Crypto master key invalid: expected hex string in Vault field "key"');
|
||||
}
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
throw new Error('Crypto master key invalid: must be 64-char hex (32 bytes)');
|
||||
}
|
||||
|
||||
const buf = Buffer.from(raw, 'hex');
|
||||
if (buf.length !== KEY_LEN) {
|
||||
throw new Error(`Crypto master key invalid: got ${buf.length} bytes, expected ${KEY_LEN}`);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function encryptMnemonic(plaintext: string): string {
|
||||
if (!masterKey) {
|
||||
throw new Error('Crypto service not ready');
|
||||
}
|
||||
if (typeof plaintext !== 'string' || plaintext.length === 0) {
|
||||
throw new Error('encryptMnemonic: plaintext must be non-empty string');
|
||||
}
|
||||
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv('aes-256-gcm', masterKey, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([iv, ct, tag]).toString('base64');
|
||||
}
|
||||
|
||||
export function decryptMnemonic(blob: string): string {
|
||||
if (!masterKey) {
|
||||
throw new Error('Crypto service not ready');
|
||||
}
|
||||
if (typeof blob !== 'string' || blob.length === 0) {
|
||||
throw new Error('decryptMnemonic: blob must be non-empty string');
|
||||
}
|
||||
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
if (buf.length < IV_LEN + TAG_LEN + 1) {
|
||||
throw new Error('decryptMnemonic: blob too short');
|
||||
}
|
||||
|
||||
const iv = buf.subarray(0, IV_LEN);
|
||||
const tag = buf.subarray(buf.length - TAG_LEN);
|
||||
const ct = buf.subarray(IV_LEN, buf.length - TAG_LEN);
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', masterKey, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
try {
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
throw new Error('decryptMnemonic: authentication failed');
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
|
||||
export interface CsrfConfig {
|
||||
secret: string;
|
||||
salt: string;
|
||||
digest: 'sha1' | 'sha256' | 'sha512';
|
||||
digest: 'sha256' | 'sha512';
|
||||
maxAgeSec: number;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,9 @@ export async function fetchCsrfConfig(
|
||||
throw new Error('CSRF salt invalid: must be string >= 8 chars');
|
||||
}
|
||||
|
||||
let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
|
||||
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
// sha1 deprecated — accept только sha256/sha512.
|
||||
let digest: 'sha256' | 'sha512' = 'sha512';
|
||||
if (secrets.digest === 'sha256' || secrets.digest === 'sha512') {
|
||||
digest = secrets.digest;
|
||||
}
|
||||
|
||||
@@ -87,8 +88,10 @@ 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 << 8) | b;
|
||||
for (const b of raw) ts = ts * 256 + b;
|
||||
return ts + ITSDANGEROUS_EPOCH;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,8 +127,13 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
|
||||
throw Object.assign(new Error('Invalid token type'), { status: 401 });
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.sid) {
|
||||
throw Object.assign(new Error('Missing token claims'), { status: 401 });
|
||||
// Строгая валидация sub/sid — иначе number/__proto__/10MB строки попадают в PG / в req.auth.
|
||||
const SUB_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
if (typeof payload.sub !== 'string' || !SUB_RE.test(payload.sub)) {
|
||||
throw Object.assign(new Error('Invalid sub claim'), { status: 401 });
|
||||
}
|
||||
if (typeof payload.sid !== 'string' || !SUB_RE.test(payload.sid)) {
|
||||
throw Object.assign(new Error('Invalid sid claim'), { status: 401 });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,27 +2,40 @@ import { env, getVaultToken } from '../config/env';
|
||||
import { vaultAppRoleLogin } from '../config/vault';
|
||||
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
|
||||
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
|
||||
import { fetchMasterKey, swapMasterKey, masterKeyMatches, isCryptoReady } from './crypto.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let currentVaultToken: string | null = null;
|
||||
|
||||
// Inflight guard — reentrant calls share the same promise (audit#4 C2/C3).
|
||||
// Без этого две параллельные refreshAllKeys могут torn-state'ить keyMap/csrf/crypto.
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed.
|
||||
* При любой ошибке оставляем старые значения в памяти, сервис продолжает работать.
|
||||
* Atomic refresh: pre-fetch JWT/CSRF/crypto secrets, swap globals только если необходимые получены.
|
||||
* Reentrant-safe.
|
||||
*/
|
||||
export async function refreshAllKeys(): Promise<void> {
|
||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
|
||||
if (inflight) return inflight;
|
||||
inflight = doRefresh().finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
}
|
||||
|
||||
async function doRefresh(): Promise<void> {
|
||||
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath, cryptoKeyPath } = env.vault;
|
||||
|
||||
if (!addr || !roleId || !secretId) {
|
||||
logger.warn('Vault not configured, skipping key refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault token: используем закэшированный из initEnv, либо логинимся заново
|
||||
let token = currentVaultToken || getVaultToken();
|
||||
// Каждый refresh — свежий Vault token. Старый optimisation с `currentVaultToken`
|
||||
// был dead code (синхронный reset перед использованием).
|
||||
let token = getVaultToken();
|
||||
if (!token) {
|
||||
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
|
||||
if (!fresh) {
|
||||
@@ -30,16 +43,14 @@ export async function refreshAllKeys(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
token = fresh;
|
||||
currentVaultToken = fresh;
|
||||
}
|
||||
|
||||
// ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ───────────
|
||||
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
|
||||
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
|
||||
const cryptoPromise = cryptoKeyPath ? fetchMasterKey(addr, token, mount, cryptoKeyPath) : Promise.resolve(null);
|
||||
|
||||
const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]);
|
||||
const [jwtResult, csrfResult, cryptoResult] = await Promise.allSettled([jwtPromise, csrfPromise, cryptoPromise]);
|
||||
|
||||
// ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ──
|
||||
if (jwtResult.status === 'rejected') {
|
||||
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
|
||||
return;
|
||||
@@ -48,16 +59,34 @@ export async function refreshAllKeys(): Promise<void> {
|
||||
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
|
||||
return;
|
||||
}
|
||||
// Master-key: первый load обязателен, дальнейшие failures толерантны.
|
||||
if (cryptoKeyPath && !isCryptoReady() && cryptoResult.status === 'rejected') {
|
||||
logger.error(`Key refresh ABORTED — Crypto master key fetch failed: ${cryptoResult.reason?.message || cryptoResult.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Atomic swap (синхронные операции, нельзя прервать) ──────────────────
|
||||
// Atomic synchronous swap. JS single-threaded — между swap'ами нет await,
|
||||
// т.е. observers видят либо все старые, либо все новые значения.
|
||||
swapKeyMap(jwtResult.value);
|
||||
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
|
||||
swapCsrfConfig(csrfResult.value);
|
||||
}
|
||||
if (cryptoResult.status === 'fulfilled' && cryptoResult.value) {
|
||||
if (!isCryptoReady()) {
|
||||
swapMasterKey(cryptoResult.value);
|
||||
logger.info('Crypto master key loaded');
|
||||
} else if (!masterKeyMatches(cryptoResult.value)) {
|
||||
logger.warn(
|
||||
'Vault crypto/master key DIFFERS from in-memory key. Service continues with old key. ' +
|
||||
'Rotating master-key bricks all existing encrypted_mnemonic — revert Vault or plan re-encryption migration.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
|
||||
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '')
|
||||
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '') +
|
||||
`, Crypto=${isCryptoReady() ? 'ready' : 'NOT-READY'}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,8 +97,6 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void
|
||||
void refreshAllKeys().catch((err) =>
|
||||
logger.error(`Key rotation tick failed: ${err?.message || err}`)
|
||||
);
|
||||
// На каждый тик — invalidate Vault token (он мог истечь), будет re-login
|
||||
currentVaultToken = null;
|
||||
}, intervalMs);
|
||||
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
101
apps/api/src/services/wallet-generator.service.ts
Normal file
101
apps/api/src/services/wallet-generator.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Wallet generation: BIP39 mnemonic + multi-chain address derivation.
|
||||
* Server-side для custodial-флоу.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import bs58 from 'bs58';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { Keypair } from '@solana/web3.js';
|
||||
import { derivePath } from 'ed25519-hd-key';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
export const DERIVATION_PATHS: Record<ChainCode, string> = {
|
||||
ETH: "m/44'/60'/0'/0/0",
|
||||
BSC: "m/44'/60'/0'/0/0",
|
||||
BTC: "m/84'/0'/0'/0/0",
|
||||
TRX: "m/44'/195'/0'/0/0",
|
||||
SOL: "m/44'/501'/0'/0'",
|
||||
};
|
||||
|
||||
export const ALL_CHAINS: ChainCode[] = ['ETH', 'BSC', 'BTC', 'TRX', 'SOL'];
|
||||
|
||||
export interface DerivedWallet {
|
||||
chain: ChainCode;
|
||||
address: string;
|
||||
derivationPath: string;
|
||||
}
|
||||
|
||||
export function generateMnemonic(): string {
|
||||
return bip39.generateMnemonic(128);
|
||||
}
|
||||
|
||||
export function validateMnemonic(m: string): boolean {
|
||||
return bip39.validateMnemonic(m);
|
||||
}
|
||||
|
||||
export async function deriveAllAddresses(mnemonic: string): Promise<DerivedWallet[]> {
|
||||
if (!bip39.validateMnemonic(mnemonic)) {
|
||||
throw new Error('Invalid mnemonic');
|
||||
}
|
||||
|
||||
const seed = await bip39.mnemonicToSeed(mnemonic);
|
||||
const seedHex = seed.toString('hex');
|
||||
|
||||
// ETH (BSC shares)
|
||||
const ethWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.ETH);
|
||||
const ethAddress = ethers.utils.getAddress(ethWallet.address);
|
||||
|
||||
// BTC P2WPKH bech32
|
||||
const btcRoot = bip32.fromSeed(seed);
|
||||
const btcChild = btcRoot.derivePath(DERIVATION_PATHS.BTC);
|
||||
if (!btcChild.publicKey) throw new Error('BTC derivation failed');
|
||||
const btcPayment = bitcoin.payments.p2wpkh({
|
||||
pubkey: Buffer.from(btcChild.publicKey),
|
||||
network: bitcoin.networks.bitcoin,
|
||||
});
|
||||
if (!btcPayment.address) throw new Error('BTC payment derivation failed');
|
||||
|
||||
// TRX (same secp256k1 + keccak256 as ETH, different encoding)
|
||||
const trxWallet = ethers.Wallet.fromMnemonic(mnemonic, DERIVATION_PATHS.TRX);
|
||||
const trxAddress = ethAddressToTron(trxWallet.address);
|
||||
|
||||
// SOL (ed25519)
|
||||
const { key: solKey } = derivePath(DERIVATION_PATHS.SOL, seedHex);
|
||||
if (!solKey || solKey.length !== 32) {
|
||||
throw new Error('SOL derivation produced invalid seed length');
|
||||
}
|
||||
const solKeypair = Keypair.fromSeed(solKey);
|
||||
const solAddress = solKeypair.publicKey.toBase58();
|
||||
|
||||
return [
|
||||
{ chain: 'ETH', address: ethAddress, derivationPath: DERIVATION_PATHS.ETH },
|
||||
{ chain: 'BSC', address: ethAddress, derivationPath: DERIVATION_PATHS.BSC },
|
||||
{ chain: 'BTC', address: btcPayment.address, derivationPath: DERIVATION_PATHS.BTC },
|
||||
{ chain: 'TRX', address: trxAddress, derivationPath: DERIVATION_PATHS.TRX },
|
||||
{ chain: 'SOL', address: solAddress, derivationPath: DERIVATION_PATHS.SOL },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ETH address (0x...) → TRX base58check (T...).
|
||||
* Используют одну curve и keccak256-derivation; различается только prefix (0x41) + encoding.
|
||||
*/
|
||||
export function ethAddressToTron(ethAddr: string): string {
|
||||
const hex = ethAddr.toLowerCase().replace(/^0x/, '');
|
||||
if (hex.length !== 40) {
|
||||
throw new Error('ethAddressToTron: invalid input length');
|
||||
}
|
||||
const bytes = Buffer.from(hex, 'hex');
|
||||
const prefixed = Buffer.concat([Buffer.from([0x41]), bytes]);
|
||||
const h1 = createHash('sha256').update(prefixed).digest();
|
||||
const h2 = createHash('sha256').update(h1).digest();
|
||||
const checksum = h2.subarray(0, 4);
|
||||
return bs58.encode(new Uint8Array(Buffer.concat([prefixed, checksum])));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Wallet operations across chains: balance, transactions, build unsigned send tx.
|
||||
* Non-custodial: server NEVER signs — клиент подписывает приватом.
|
||||
* Wallet read-only operations across chains: balance + tx history.
|
||||
* Server-side signing now lives in `wallet-signer.service.ts` (custodial).
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { env } from '../config/env';
|
||||
@@ -250,14 +250,16 @@ async function solTransactions(address: string, limit: number): Promise<TxItem[]
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────── BUILD SEND (UNSIGNED TX) ───────────────────────
|
||||
// ─────────────────────── HELPERS ───────────────────────
|
||||
// (buildSend + chain-specific builders deleted — server signs custodially via wallet-signer.service.ts)
|
||||
|
||||
/* deleted-marker-begin
|
||||
export interface BuildSendParams {
|
||||
chain: ChainCode;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
token?: string; // 'USDT' и т.д.; для native перевода — undefined
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export type UnsignedTx =
|
||||
@@ -442,8 +444,7 @@ async function deriveAta(
|
||||
);
|
||||
return pda;
|
||||
}
|
||||
|
||||
// ─────────────────────── HELPERS ───────────────────────
|
||||
deleted-marker-end */
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
|
||||
452
apps/api/src/services/wallet-signer.service.ts
Normal file
452
apps/api/src/services/wallet-signer.service.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Server-side signing + broadcasting (custodial).
|
||||
* Caller передаёт расшифрованную mnemonic + expectedFromAddress; signer:
|
||||
* 1. деривит privkey из mnemonic
|
||||
* 2. проверяет derived === expectedFromAddress (protect against derivation drift)
|
||||
* 3. собирает tx (для TRX дополнительно verify raw_data против MITM)
|
||||
* 4. подписывает
|
||||
* 5. broadcast'ит в сеть
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { Keypair, Connection, PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
|
||||
import { derivePath } from 'ed25519-hd-key';
|
||||
import { env } from '../config/env';
|
||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
|
||||
const BSC_RPC = 'https://bsc-dataseed.binance.org';
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
|
||||
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
|
||||
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function transfer(address to, uint256 amount) returns (bool)',
|
||||
];
|
||||
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
const MAX_GAS_PRICE_GWEI = 500;
|
||||
|
||||
export interface SendParams {
|
||||
chain: ChainCode;
|
||||
mnemonic: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
token?: string;
|
||||
expectedFromAddress: string;
|
||||
}
|
||||
|
||||
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
|
||||
switch (p.chain) {
|
||||
case 'ETH': return sendEvm(p, ETH_RPC, 1, USDT_ERC20);
|
||||
case 'BSC': return sendEvm(p, BSC_RPC, 56, USDT_BEP20);
|
||||
case 'BTC': return sendBtc(p);
|
||||
case 'TRX': return sendTrx(p);
|
||||
case 'SOL': return sendSol(p);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAddressMatch(derived: string, expected: string, chain: ChainCode): void {
|
||||
const norm = (s: string) =>
|
||||
chain === 'ETH' || chain === 'BSC' ? s.toLowerCase() : s;
|
||||
if (norm(derived) !== norm(expected)) {
|
||||
throw new Error(`Derived ${chain} address ${derived} does not match stored ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EVM (ETH / BSC) ───
|
||||
|
||||
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(rpc, chainId);
|
||||
const signer = wallet.connect(provider);
|
||||
|
||||
const feeData = await provider.getFeeData();
|
||||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||
const effectiveGasPrice = feeData.maxFeePerGas ?? feeData.gasPrice;
|
||||
if (!effectiveGasPrice || effectiveGasPrice.gt(capWei)) {
|
||||
throw new Error(`Gas price exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
||||
}
|
||||
|
||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||
|
||||
// Принудительно EIP-1559 (type 2). ETH и BSC оба поддерживают с 2021.
|
||||
// Если feeData не вернул maxFeePerGas — fallback но всё равно type 2 с computed cap.
|
||||
const maxFeePerGas = feeData.maxFeePerGas ?? effectiveGasPrice;
|
||||
const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.BigNumber.from(0);
|
||||
if (maxFeePerGas.gt(capWei)) {
|
||||
throw new Error(`maxFeePerGas exceeds cap (${MAX_GAS_PRICE_GWEI} gwei)`);
|
||||
}
|
||||
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
||||
type: 2,
|
||||
maxFeePerGas,
|
||||
maxPriorityFeePerGas,
|
||||
};
|
||||
|
||||
let tx: ethers.providers.TransactionRequest;
|
||||
if (!p.token) {
|
||||
const value = ethers.BigNumber.from(p.amount);
|
||||
const balance = await provider.getBalance(wallet.address);
|
||||
const estGas = ethers.BigNumber.from(21000);
|
||||
const totalNeeded = value.add(effectiveGasPrice.mul(estGas));
|
||||
if (balance.lt(totalNeeded)) {
|
||||
throw new Error('Insufficient balance (value + gas)');
|
||||
}
|
||||
tx = { to: p.to, value, chainId, nonce, gasLimit: estGas, ...feeFields };
|
||||
} else if (p.token.toUpperCase() === 'USDT') {
|
||||
const iface = new ethers.utils.Interface([
|
||||
...ERC20_ABI,
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
]);
|
||||
const erc20 = new ethers.Contract(usdtAddr, iface, provider);
|
||||
const tokenBal: ethers.BigNumber = await erc20.balanceOf(wallet.address);
|
||||
if (tokenBal.lt(ethers.BigNumber.from(p.amount))) {
|
||||
throw new Error('Insufficient token balance');
|
||||
}
|
||||
const nativeBal = await provider.getBalance(wallet.address);
|
||||
const estGas = ethers.BigNumber.from(80000);
|
||||
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
|
||||
throw new Error('Insufficient native balance for gas');
|
||||
}
|
||||
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
||||
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
|
||||
} else {
|
||||
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
|
||||
}
|
||||
|
||||
const sent = await signer.sendTransaction(tx);
|
||||
return { txid: sent.hash };
|
||||
}
|
||||
|
||||
// ─── SOLANA ───
|
||||
|
||||
async function sendSol(p: SendParams): Promise<{ txid: string }> {
|
||||
if (p.token) {
|
||||
throw new Error('SOL SPL-token signing не реализовано (только native SOL)');
|
||||
}
|
||||
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||
if (!key || key.length !== 32) {
|
||||
throw new Error('SOL derivation produced invalid seed length');
|
||||
}
|
||||
const keypair = Keypair.fromSeed(key);
|
||||
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
|
||||
|
||||
const conn = new Connection(SOL_RPC, 'confirmed');
|
||||
const toPk = new PublicKey(p.to);
|
||||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
||||
|
||||
const tx = new Transaction({
|
||||
feePayer: keypair.publicKey,
|
||||
blockhash,
|
||||
lastValidBlockHeight,
|
||||
});
|
||||
tx.add(
|
||||
SystemProgram.transfer({
|
||||
fromPubkey: keypair.publicKey,
|
||||
toPubkey: toPk,
|
||||
lamports: BigInt(p.amount),
|
||||
}),
|
||||
);
|
||||
tx.sign(keypair);
|
||||
|
||||
const sig = await conn.sendRawTransaction(tx.serialize());
|
||||
|
||||
try {
|
||||
await conn.confirmTransaction({ signature: sig, blockhash, lastValidBlockHeight }, 'confirmed');
|
||||
} catch (err: any) {
|
||||
throw new Error(`SOL tx submitted but confirmation timed out (sig=${sig}): ${err.message}`);
|
||||
}
|
||||
|
||||
return { txid: sig };
|
||||
}
|
||||
|
||||
// ─── BITCOIN ───
|
||||
|
||||
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
||||
if (p.token) throw new Error('BTC tokens не поддерживаются');
|
||||
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const root = bip32.fromSeed(seed);
|
||||
const child = root.derivePath(DERIVATION_PATHS.BTC);
|
||||
if (!child.publicKey) throw new Error('BTC derivation failed');
|
||||
|
||||
const network = bitcoin.networks.bitcoin;
|
||||
const pubkeyBuf = Buffer.from(child.publicKey);
|
||||
const payment = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network });
|
||||
if (!payment.address || !payment.output) throw new Error('BTC payment build failed');
|
||||
|
||||
const fromAddr = payment.address;
|
||||
assertAddressMatch(fromAddr, p.expectedFromAddress, 'BTC');
|
||||
|
||||
const [utxosRes, feesRes] = await Promise.all([
|
||||
fetchJson(`${BLOCKSTREAM}/address/${fromAddr}/utxo`),
|
||||
fetchJson(`${BLOCKSTREAM}/fee-estimates`),
|
||||
]);
|
||||
const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed);
|
||||
const feeMap = feesRes as Record<string, number>;
|
||||
// Floor 15 sat/vB — иначе tx может реджектиться по min-relay-fee при congestion.
|
||||
const feeRate = Math.max(Math.ceil(feeMap['1'] ?? feeMap['3'] ?? feeMap['6'] ?? 15), 15);
|
||||
|
||||
const amountSat = BigInt(p.amount);
|
||||
if (amountSat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new Error('BTC amount exceeds safe integer range');
|
||||
}
|
||||
|
||||
utxos.sort((a, b) => b.value - a.value);
|
||||
|
||||
const psbt = new bitcoin.Psbt({ network });
|
||||
let totalIn = 0n;
|
||||
|
||||
const feeFor = (ins: number, outs: number) =>
|
||||
BigInt(Math.ceil((ins * 68 + outs * 31 + 11) * feeRate * 1.1));
|
||||
|
||||
const selectedUtxos: typeof utxos = [];
|
||||
for (const u of utxos) {
|
||||
selectedUtxos.push(u);
|
||||
totalIn += BigInt(u.value);
|
||||
if (totalIn >= amountSat + feeFor(selectedUtxos.length, 2)) break;
|
||||
}
|
||||
|
||||
if (totalIn < amountSat + feeFor(selectedUtxos.length, 2)) {
|
||||
throw new Error('Insufficient BTC balance (incl. fee)');
|
||||
}
|
||||
|
||||
for (const u of selectedUtxos) {
|
||||
psbt.addInput({
|
||||
hash: u.txid,
|
||||
index: u.vout,
|
||||
witnessUtxo: { script: payment.output, value: u.value },
|
||||
});
|
||||
}
|
||||
|
||||
psbt.addOutput({ address: p.to, value: Number(amountSat) });
|
||||
|
||||
const fee = feeFor(selectedUtxos.length, 2);
|
||||
const change = totalIn - amountSat - fee;
|
||||
if (change > 294n) {
|
||||
psbt.addOutput({ address: fromAddr, value: Number(change) });
|
||||
}
|
||||
|
||||
for (let i = 0; i < selectedUtxos.length; i++) {
|
||||
psbt.signInput(i, {
|
||||
publicKey: pubkeyBuf,
|
||||
sign: (hash: Buffer) => Buffer.from(child.sign(hash)),
|
||||
});
|
||||
}
|
||||
psbt.finalizeAllInputs();
|
||||
|
||||
const txHex = psbt.extractTransaction().toHex();
|
||||
|
||||
const broadcastController = new AbortController();
|
||||
const tBroadcast = setTimeout(() => broadcastController.abort(), HTTP_TIMEOUT_MS);
|
||||
let broadcast: Response;
|
||||
try {
|
||||
broadcast = await fetch(`${BLOCKSTREAM}/tx`, {
|
||||
method: 'POST',
|
||||
body: txHex,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
signal: broadcastController.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(tBroadcast);
|
||||
}
|
||||
if (!broadcast.ok) {
|
||||
const body = await broadcast.text().catch(() => '');
|
||||
throw new Error(`BTC broadcast failed (${broadcast.status}): ${body.slice(0, 200)}`);
|
||||
}
|
||||
const txid = (await broadcast.text()).trim();
|
||||
return { txid };
|
||||
}
|
||||
|
||||
// ─── TRON ───
|
||||
|
||||
async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||
assertAddressMatch(fromTronAddr, p.expectedFromAddress, 'TRX');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
let txBody: any;
|
||||
if (!p.token) {
|
||||
const built = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: fromTronAddr,
|
||||
to_address: p.to,
|
||||
amount: Number(p.amount),
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
txBody = built;
|
||||
} else if (p.token.toUpperCase() === 'USDT') {
|
||||
const param =
|
||||
tronAddressToHex(p.to).padStart(64, '0') +
|
||||
BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
const built = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: fromTronAddr,
|
||||
contract_address: USDT_TRC20,
|
||||
function_selector: 'transfer(address,uint256)',
|
||||
parameter: param,
|
||||
fee_limit: 100_000_000,
|
||||
call_value: 0,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
txBody = built.transaction;
|
||||
} else {
|
||||
throw new Error(`Token ${p.token} not supported on TRX`);
|
||||
}
|
||||
|
||||
if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) {
|
||||
throw new Error('TRX tx build failed (incomplete response)');
|
||||
}
|
||||
|
||||
// ── MITM defense: 4-layer validation против compromised RPC ──
|
||||
|
||||
// 1. Recompute txID = SHA256(raw_data_hex) локально.
|
||||
// Если RPC лжёт о txID → attacker мог подсунуть raw_data, дренирующее на attacker.
|
||||
const expectedTxId = createHash('sha256')
|
||||
.update(Buffer.from(txBody.raw_data_hex, 'hex'))
|
||||
.digest('hex');
|
||||
if (expectedTxId !== txBody.txID) {
|
||||
throw new Error('TRX txID mismatch — possible MITM/compromised RPC');
|
||||
}
|
||||
|
||||
// 2. Expiration / timestamp bounds (защита от long-term replay).
|
||||
// TRON default expiration ~60s. Если RPC выставил годовой expiration —
|
||||
// подписанная tx replay'ится годами через скомпрометированную сеть.
|
||||
const nowMs = Date.now();
|
||||
const expiration = Number(txBody.raw_data.expiration);
|
||||
const timestamp = Number(txBody.raw_data.timestamp);
|
||||
if (!Number.isFinite(expiration) || !Number.isFinite(timestamp)) {
|
||||
throw new Error('TRX tx malformed (no expiration/timestamp)');
|
||||
}
|
||||
if (expiration - nowMs > 90_000 || expiration <= nowMs) {
|
||||
throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`);
|
||||
}
|
||||
if (Math.abs(timestamp - nowMs) > 30_000) {
|
||||
throw new Error(`TRX timestamp drift too large: ${timestamp - nowMs}ms`);
|
||||
}
|
||||
|
||||
// 3. Verify contract[0].type (защита от swap'а TransferContract → TriggerSmartContract).
|
||||
// Без этого RPC может вернуть TriggerSmartContract с косметическими полями to_address/amount.
|
||||
const contract0 = txBody.raw_data.contract?.[0];
|
||||
if (!contract0) {
|
||||
throw new Error('TRX tx malformed (no contract[0])');
|
||||
}
|
||||
const expectedType = p.token ? 'TriggerSmartContract' : 'TransferContract';
|
||||
if (contract0.type !== expectedType) {
|
||||
throw new Error(`TRX contract type mismatch: expected ${expectedType}, got ${contract0.type}`);
|
||||
}
|
||||
|
||||
// 4. Verify parameter.value (to / amount / contract_address / selector / data).
|
||||
const contractValue = contract0.parameter?.value;
|
||||
if (!contractValue) {
|
||||
throw new Error('TRX tx malformed (no contract value)');
|
||||
}
|
||||
if (contractValue.owner_address !== fromTronAddr) {
|
||||
throw new Error(`TRX owner_address mismatch: expected ${fromTronAddr}, got ${contractValue.owner_address}`);
|
||||
}
|
||||
if (!p.token) {
|
||||
if (contractValue.to_address !== p.to) {
|
||||
throw new Error(`TRX to_address mismatch: expected ${p.to}, got ${contractValue.to_address}`);
|
||||
}
|
||||
if (String(contractValue.amount) !== String(p.amount)) {
|
||||
throw new Error(`TRX amount mismatch: expected ${p.amount}, got ${contractValue.amount}`);
|
||||
}
|
||||
} else {
|
||||
if (contractValue.contract_address !== USDT_TRC20) {
|
||||
throw new Error(`TRX contract mismatch: expected ${USDT_TRC20}, got ${contractValue.contract_address}`);
|
||||
}
|
||||
const data = String(contractValue.data || '');
|
||||
if (data.length !== 128 + 8) {
|
||||
throw new Error('TRX trc20 data length wrong');
|
||||
}
|
||||
// ВАЖНО: verify method selector (a9059cbb = transfer). Без этого RPC может вернуть
|
||||
// approve(095ea7b3) — тот же layout (address,uint256) — и юзер approve'ит unlimited.
|
||||
if (data.slice(0, 8).toLowerCase() !== 'a9059cbb') {
|
||||
throw new Error(`TRX trc20 selector mismatch: expected a9059cbb (transfer), got ${data.slice(0, 8)}`);
|
||||
}
|
||||
const expectedParam =
|
||||
tronAddressToHex(p.to).padStart(64, '0') +
|
||||
BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
const actualParam = data.slice(8);
|
||||
if (actualParam.toLowerCase() !== expectedParam.toLowerCase()) {
|
||||
throw new Error('TRX trc20 parameter mismatch (to/amount tampering)');
|
||||
}
|
||||
}
|
||||
|
||||
// Sign verified txID
|
||||
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
||||
const sig = sk.signDigest('0x' + txBody.txID);
|
||||
const sigHex =
|
||||
sig.r.slice(2) +
|
||||
sig.s.slice(2) +
|
||||
(sig.recoveryParam ?? 0).toString(16).padStart(2, '0');
|
||||
|
||||
txBody.signature = [sigHex];
|
||||
|
||||
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(txBody),
|
||||
});
|
||||
|
||||
if (!broadcast?.result) {
|
||||
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
||||
throw new Error(`TRX broadcast failed: ${msg.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return { txid: txBody.txID };
|
||||
}
|
||||
|
||||
// ─── HELPERS ───
|
||||
|
||||
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
function tronAddressToHex(address: string): string {
|
||||
let num = 0n;
|
||||
for (const ch of address) {
|
||||
const i = BASE58_ALPHABET.indexOf(ch);
|
||||
if (i === -1) throw new Error('Invalid base58 character in TRON address');
|
||||
num = num * 58n + BigInt(i);
|
||||
}
|
||||
const hex = num.toString(16).padStart(50, '0');
|
||||
return hex.slice(2, 42);
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "CryptoWallet API",
|
||||
"version": "4.0.0",
|
||||
"description": "Multi-chain crypto wallet API (non-custodial). Клиент сам деривит mnemonic и шлёт публичные адреса; сервер хранит только адреса и строит unsigned tx для отправки. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK)."
|
||||
"version": "5.0.0",
|
||||
"description": "Multi-chain custodial wallet API (ETH/BSC/BTC/TRX/SOL). Сервер генерит mnemonic, шифрует AES-256-GCM (master-key из HashiCorp Vault), хранит её и сам подписывает транзакции. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK)."
|
||||
},
|
||||
"servers": [
|
||||
{ "url": "/api", "description": "API root" }
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "System", "description": "Health & service info" },
|
||||
{ "name": "Wallets", "description": "User wallet records" },
|
||||
{ "name": "Wallets", "description": "Custodial wallet lifecycle" },
|
||||
{ "name": "Wallet Ops", "description": "Per-chain balance / transactions / send" },
|
||||
{ "name": "BTC", "description": "Bitcoin RPC proxy (Blockstream)" },
|
||||
{ "name": "TRON", "description": "TRON RPC proxy (TronGrid)" },
|
||||
@@ -32,10 +32,6 @@
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"SuccessEmpty": {
|
||||
"type": "object",
|
||||
"properties": { "success": { "type": "boolean", "example": true } }
|
||||
},
|
||||
"HealthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -52,26 +48,7 @@
|
||||
"properties": {
|
||||
"chain": { "$ref": "#/components/schemas/Chain" },
|
||||
"address": { "type": "string" },
|
||||
"derivationPath": { "type": "string", "description": "BIP32 path, например m/44'/60'/0'/0/0" }
|
||||
}
|
||||
},
|
||||
"WalletInput": {
|
||||
"type": "object",
|
||||
"required": ["chain", "address", "derivationPath"],
|
||||
"properties": {
|
||||
"chain": { "$ref": "#/components/schemas/Chain" },
|
||||
"address": { "type": "string", "maxLength": 64, "description": "Публичный адрес (chain-specific checksum-валидируется)" },
|
||||
"derivationPath": { "type": "string", "maxLength": 64, "description": "BIP32 m/.. (например m/44'/60'/0'/0/0)" }
|
||||
}
|
||||
},
|
||||
"CreateWalletsRequest": {
|
||||
"type": "object",
|
||||
"required": ["wallets"],
|
||||
"properties": {
|
||||
"wallets": {
|
||||
"type": "array", "minItems": 1, "maxItems": 20,
|
||||
"items": { "$ref": "#/components/schemas/WalletInput" }
|
||||
}
|
||||
"derivationPath": { "type": "string", "description": "BIP32 path" }
|
||||
}
|
||||
},
|
||||
"WalletsResponse": {
|
||||
@@ -81,6 +58,31 @@
|
||||
"data": { "type": "array", "items": { "$ref": "#/components/schemas/Wallet" } }
|
||||
}
|
||||
},
|
||||
"MnemonicResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mnemonic": { "type": "string", "description": "BIP39 mnemonic (12 words)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TxBroadcastResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"txid": { "type": "string", "description": "Идентификатор отправленной транзакции" },
|
||||
"chain": { "$ref": "#/components/schemas/Chain" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BalanceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -125,17 +127,7 @@
|
||||
"properties": {
|
||||
"to": { "type": "string", "description": "Recipient address" },
|
||||
"amount": { "type": "string", "description": "Amount в smallest units" },
|
||||
"token": { "type": "string", "nullable": true, "description": "Например USDT для TRC20/ERC20/BEP20. Без token = native (TRX/ETH/BNB/BTC)" }
|
||||
}
|
||||
},
|
||||
"UnsignedTxResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Unsigned tx — формат зависит от chain (kind: btc | tron | evm | solana). Клиент подписывает приватом и broadcast'ит через соответствующий /api/{btc,tron}/broadcast endpoint или RPC своей цепи."
|
||||
}
|
||||
"token": { "type": "string", "nullable": true, "description": "USDT для TRC20/ERC20/BEP20. Без token = native." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,17 +159,44 @@
|
||||
|
||||
"/wallets/create": {
|
||||
"post": {
|
||||
"summary": "Upsert wallets для авторизованного юзера",
|
||||
"description": "Клиент сам генерит mnemonic и деривит публичные адреса (BIP44 для ETH/BSC/BTC/TRX/SOL). Тело — массив `{chain, address, derivationPath}`. На конфликт (user_id, chain) сервер обновляет address+derivationPath. Mnemonic клиенту не нужно слать — сервер её не хранит.",
|
||||
"summary": "Создать custodial-кошелёк (server-side mnemonic)",
|
||||
"description": "**Тело запроса не требуется.** Сервер генерит BIP39 mnemonic (12 слов), деривит адреса для 5 chains (BIP44: ETH m/44'/60'/0'/0/0, BTC m/84'/0'/0'/0/0, TRX m/44'/195'/0'/0/0, SOL m/44'/501'/0'/0', BSC = ETH path), шифрует mnemonic AES-256-GCM (master-key из HashiCorp Vault) и атомарно сохраняет. **Возвращает ТОЛЬКО адреса** — mnemonic клиенту не отдаётся. Чтобы потом увидеть seed — отдельный endpoint POST /wallets/mnemonic/reveal. Идемпотентность: 409 если у юзера уже есть кошелёк.",
|
||||
"tags": ["Wallets"],
|
||||
"responses": {
|
||||
"201": { "description": "Wallet created (returns addresses only)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
|
||||
"401": { "description": "Not authenticated" },
|
||||
"409": { "description": "Wallet already exists", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"503": { "description": "Crypto service not ready" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/wallets/mnemonic/reveal": {
|
||||
"post": {
|
||||
"summary": "Раскрыть mnemonic (settings-screen)",
|
||||
"description": "Расшифровывает и возвращает 12-словную BIP39 мнемонику юзера. POST + CSRF + body-confirmation. Rate-limit 5/час per-user. Каждый запрос пишется в audit-log.",
|
||||
"tags": ["Wallets"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateWalletsRequest" } } }
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["confirm"],
|
||||
"properties": {
|
||||
"confirm": { "type": "string", "enum": ["I_UNDERSTAND_SEED_IS_SECRET"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": { "description": "Created/updated (вернёт сохранённые адреса)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
|
||||
"400": { "description": "Invalid input (chain/address/derivationPath)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"401": { "description": "Not authenticated" }
|
||||
"200": { "description": "Mnemonic revealed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MnemonicResponse" } } } },
|
||||
"400": { "description": "Missing/invalid confirm token" },
|
||||
"401": { "description": "Not authenticated" },
|
||||
"404": { "description": "Wallet not created yet" },
|
||||
"429": { "description": "Rate limit (5/hour) exceeded" },
|
||||
"503": { "description": "Crypto service not ready" }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -212,8 +231,8 @@
|
||||
|
||||
"/wallets/{chain}/send": {
|
||||
"post": {
|
||||
"summary": "Build unsigned send transaction (non-custodial)",
|
||||
"description": "Возвращает unsigned tx. Клиент подписывает приватным ключом и broadcast'ит через /api/{btc,tron}/broadcast или RPC своей цепи.",
|
||||
"summary": "Custodial send: server signs + broadcasts",
|
||||
"description": "Юзер на клиенте жмёт 'подтвердить' → клиент шлёт {to, amount, token?}. Сервер расшифровывает мнемонику, деривит chain privkey, подписывает, broadcast'ит. Возвращает txid. Защита: TRX MITM check, EVM gas cap 500 gwei, SOL confirmTransaction, BTC timeout + safety multiplier.",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
|
||||
"requestBody": {
|
||||
@@ -221,10 +240,11 @@
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Unsigned tx", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnsignedTxResponse" } } } },
|
||||
"200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } },
|
||||
"400": { "description": "Invalid input" },
|
||||
"404": { "description": "Wallet not found" },
|
||||
"502": { "description": "Upstream RPC error" }
|
||||
"404": { "description": "Wallet/mnemonic not found" },
|
||||
"502": { "description": "Broadcast failed (insufficient balance / RPC error / unsupported)" },
|
||||
"503": { "description": "Crypto service not ready" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
-- CryptoWallet API — DB schema (idempotent, non-custodial v4.0)
|
||||
-- CryptoWallet API — DB schema (idempotent, custodial v5.0)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(26) PRIMARY KEY,
|
||||
@@ -17,13 +17,36 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
kyc_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
kyc_verified_at TIMESTAMPTZ,
|
||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
encrypted_vault TEXT, -- legacy, unused
|
||||
vault_salt VARCHAR(128), -- legacy, unused
|
||||
encrypted_mnemonic TEXT, -- legacy, unused
|
||||
encrypted_vault TEXT, -- legacy
|
||||
vault_salt VARCHAR(128), -- legacy
|
||||
encrypted_mnemonic TEXT, -- AES-256-GCM blob (custodial)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
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;
|
||||
END $$;
|
||||
|
||||
-- AES-GCM blob: 12 IV + plaintext + 16 tag.
|
||||
-- 12-word mnemonic ~ 116 байт = ~156 base64 chars; 24-word ~ 212 байт = ~284 chars.
|
||||
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 140 AND 512));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id VARCHAR(26) PRIMARY KEY,
|
||||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -36,12 +59,6 @@ CREATE TABLE IF NOT EXISTS wallets (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id VARCHAR(26) PRIMARY KEY,
|
||||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
-- sessions table removed — JWT-stateless, не используется в коде.
|
||||
-- Если существует от старой версии — оператор может drop вручную:
|
||||
-- DROP TABLE IF EXISTS sessions CASCADE;
|
||||
|
||||
@@ -5,14 +5,29 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: cryptowallet-api
|
||||
restart: unless-stopped
|
||||
# Bind to loopback only — TLS termination + WAF на reverse proxy (Caddy/Nginx).
|
||||
# Если нужно direct exposure для dev — поменяй на "3001:3001" локально.
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "127.0.0.1:3001:3001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
API_PORT: "3001"
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
# Container hardening — post-RCE blast radius minimization
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
pids_limit: 256
|
||||
mem_limit: 512m
|
||||
cpus: "1.0"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
|
||||
test: ["CMD", "wget", "-qO-", "--tries=1", "--timeout=3", "http://localhost:3001/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"turbo": "^2.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": ["bcrypt"],
|
||||
"onlyBuiltDependencies": ["tiny-secp256k1"],
|
||||
"overrides": {
|
||||
"ethers": "5.7.2"
|
||||
}
|
||||
|
||||
320
pnpm-lock.yaml
generated
320
pnpm-lock.yaml
generated
@@ -20,6 +20,12 @@ importers:
|
||||
'@solana/web3.js':
|
||||
specifier: ^1.98.4
|
||||
version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
bip32:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
bip39:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
bitcoinjs-lib:
|
||||
specifier: ^6.1.5
|
||||
version: 6.1.7
|
||||
@@ -35,6 +41,9 @@ importers:
|
||||
dotenv:
|
||||
specifier: ^16.4.0
|
||||
version: 16.6.1
|
||||
ed25519-hd-key:
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
ethers:
|
||||
specifier: 5.7.2
|
||||
version: 5.7.2(bufferutil@4.1.0)(utf-8-validate@6.0.6)
|
||||
@@ -59,6 +68,9 @@ importers:
|
||||
swagger-ui-express:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(express@4.22.1)
|
||||
tiny-secp256k1:
|
||||
specifier: ^2.2.3
|
||||
version: 2.2.4
|
||||
ulidx:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1
|
||||
@@ -339,6 +351,9 @@ packages:
|
||||
'@scarf/scarf@1.4.0':
|
||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||
|
||||
'@scure/base@1.2.6':
|
||||
resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==}
|
||||
|
||||
'@solana/buffer-layout@4.0.1':
|
||||
resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==}
|
||||
engines: {node: '>=5.10'}
|
||||
@@ -553,6 +568,10 @@ packages:
|
||||
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -582,6 +601,13 @@ packages:
|
||||
resolution: {integrity: sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
bip32@4.0.0:
|
||||
resolution: {integrity: sha512-aOGy88DDlVUhspIXJN+dVEtclhIsfAUppD43V0j40cPTld3pv/0X/MlrZSZ6jowIaQQzFwP8M6rFU2z2mVYjDQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
bip39@3.1.0:
|
||||
resolution: {integrity: sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==}
|
||||
|
||||
bitcoinjs-lib@6.1.7:
|
||||
resolution: {integrity: sha512-tlf/r2DGMbF7ky1MgUqXHzypYHakkEnm0SZP23CJKIqNY/5uNAnMbFhMJdhjrL/7anfb/U8+AlpdjPWjPnAalg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -621,6 +647,9 @@ packages:
|
||||
bs58@6.0.0:
|
||||
resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
|
||||
|
||||
bs58check@2.1.2:
|
||||
resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==}
|
||||
|
||||
bs58check@3.0.1:
|
||||
resolution: {integrity: sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==}
|
||||
|
||||
@@ -642,6 +671,10 @@ packages:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bind@1.0.9:
|
||||
resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -662,6 +695,10 @@ packages:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
cipher-base@1.0.7:
|
||||
resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -708,10 +745,19 @@ packages:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
create-hash@1.2.0:
|
||||
resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==}
|
||||
|
||||
create-hmac@1.1.7:
|
||||
resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==}
|
||||
|
||||
create-require@1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
|
||||
@@ -739,6 +785,10 @@ packages:
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
delay@5.0.0:
|
||||
resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -774,6 +824,9 @@ packages:
|
||||
dynamic-dedupe@0.3.0:
|
||||
resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==}
|
||||
|
||||
ed25519-hd-key@1.3.0:
|
||||
resolution: {integrity: sha512-IWwAyiiuJQhgu3L8NaHb68eJxTu2pgCwxIBdgpLJdKpYZM46+AXePSVTr7fkNKaUOfOL4IrjEUaQvyVRIDP7fg==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -920,6 +973,10 @@ packages:
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
for-each@0.3.5:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -985,10 +1042,21 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hash-base@3.1.2:
|
||||
resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
hash.js@1.1.7:
|
||||
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
||||
|
||||
@@ -1052,6 +1120,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-callable@1.2.7:
|
||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1072,6 +1144,16 @@ packages:
|
||||
resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
@@ -1162,6 +1244,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
md5.js@1.3.5:
|
||||
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
|
||||
|
||||
media-typer@0.3.0:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1346,6 +1431,10 @@ packages:
|
||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1366,6 +1455,9 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -1389,6 +1481,9 @@ packages:
|
||||
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
@@ -1424,12 +1519,19 @@ packages:
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
ripemd160@2.0.3:
|
||||
resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
rpc-websockets@9.3.8:
|
||||
resolution: {integrity: sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
@@ -1452,9 +1554,18 @@ packages:
|
||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
sha.js@2.4.12:
|
||||
resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==}
|
||||
engines: {node: '>= 0.10'}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1504,6 +1615,9 @@ packages:
|
||||
stream-json@1.9.1:
|
||||
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1555,6 +1669,14 @@ packages:
|
||||
resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
tiny-secp256k1@2.2.4:
|
||||
resolution: {integrity: sha512-FoDTcToPqZE454Q04hH9o2EhxWsm7pOSpicyHkgTwKhdKWdsTUuqfP5MLq3g+VjAtl2vSx6JpXGdwA2qpYkI0Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
to-buffer@1.2.2:
|
||||
resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -1641,6 +1763,9 @@ packages:
|
||||
resolution: {integrity: sha512-ERZf7pKOR155NKs/PZt1+83NrSEJfUL7+p9/TGZg/8xzDVMntXEFQlX4CsNJQTyu4h3j+dZYiQWOOlv5pssuHQ==}
|
||||
hasBin: true
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1653,6 +1778,10 @@ packages:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typeforce@1.18.0:
|
||||
resolution: {integrity: sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==}
|
||||
|
||||
@@ -1661,6 +1790,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uint8array-tools@0.0.7:
|
||||
resolution: {integrity: sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
ulidx@2.4.1:
|
||||
resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -1679,6 +1812,9 @@ packages:
|
||||
resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==}
|
||||
engines: {node: '>=6.14.2'}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@@ -1708,11 +1844,18 @@ packages:
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
wif@2.0.6:
|
||||
resolution: {integrity: sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==}
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2278,6 +2421,8 @@ snapshots:
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
|
||||
'@scure/base@1.2.6': {}
|
||||
|
||||
'@solana/buffer-layout@4.0.1':
|
||||
dependencies:
|
||||
buffer: 6.0.3
|
||||
@@ -2534,6 +2679,10 @@ snapshots:
|
||||
|
||||
array-union@2.1.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base-x@3.0.11:
|
||||
@@ -2554,6 +2703,17 @@ snapshots:
|
||||
|
||||
bip174@2.1.1: {}
|
||||
|
||||
bip32@4.0.0:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
'@scure/base': 1.2.6
|
||||
typeforce: 1.18.0
|
||||
wif: 2.0.6
|
||||
|
||||
bip39@3.1.0:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
bitcoinjs-lib@6.1.7:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
@@ -2617,6 +2777,12 @@ snapshots:
|
||||
dependencies:
|
||||
base-x: 5.0.1
|
||||
|
||||
bs58check@2.1.2:
|
||||
dependencies:
|
||||
bs58: 4.0.1
|
||||
create-hash: 1.2.0
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
bs58check@3.0.1:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
@@ -2641,6 +2807,13 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bind@1.0.9:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
get-intrinsic: 1.3.0
|
||||
set-function-length: 1.2.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -2667,6 +2840,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
cipher-base@1.0.7:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -2700,11 +2879,30 @@ snapshots:
|
||||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
create-hash@1.2.0:
|
||||
dependencies:
|
||||
cipher-base: 1.0.7
|
||||
inherits: 2.0.4
|
||||
md5.js: 1.3.5
|
||||
ripemd160: 2.0.3
|
||||
sha.js: 2.4.12
|
||||
|
||||
create-hmac@1.1.7:
|
||||
dependencies:
|
||||
cipher-base: 1.0.7
|
||||
create-hash: 1.2.0
|
||||
inherits: 2.0.4
|
||||
ripemd160: 2.0.3
|
||||
safe-buffer: 5.2.1
|
||||
sha.js: 2.4.12
|
||||
|
||||
create-require@1.1.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
@@ -2723,6 +2921,12 @@ snapshots:
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
delay@5.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
@@ -2751,6 +2955,11 @@ snapshots:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
ed25519-hd-key@1.3.0:
|
||||
dependencies:
|
||||
create-hmac: 1.1.7
|
||||
tweetnacl: 1.0.3
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
elliptic@6.5.4:
|
||||
@@ -3001,6 +3210,10 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
@@ -3070,8 +3283,23 @@ snapshots:
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-property-descriptors@1.0.2:
|
||||
dependencies:
|
||||
es-define-property: 1.0.1
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hash-base@3.1.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 2.3.8
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
hash.js@1.1.7:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
@@ -3133,6 +3361,8 @@ snapshots:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-callable@1.2.7: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
@@ -3147,6 +3377,14 @@ snapshots:
|
||||
|
||||
is-path-inside@3.0.3: {}
|
||||
|
||||
is-typed-array@1.1.15:
|
||||
dependencies:
|
||||
which-typed-array: 1.1.20
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic-ws@4.0.1(ws@7.5.10(bufferutil@4.1.0)(utf-8-validate@6.0.6)):
|
||||
@@ -3231,6 +3469,12 @@ snapshots:
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
md5.js@1.3.5:
|
||||
dependencies:
|
||||
hash-base: 3.1.2
|
||||
inherits: 2.0.4
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
merge-descriptors@1.0.3: {}
|
||||
@@ -3373,6 +3617,8 @@ snapshots:
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-bytea@1.0.1: {}
|
||||
@@ -3385,6 +3631,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -3407,6 +3655,16 @@ snapshots:
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
@@ -3435,6 +3693,11 @@ snapshots:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
|
||||
ripemd160@2.0.3:
|
||||
dependencies:
|
||||
hash-base: 3.1.2
|
||||
inherits: 2.0.4
|
||||
|
||||
rpc-websockets@9.3.8:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.21
|
||||
@@ -3452,6 +3715,8 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
@@ -3487,8 +3752,23 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
get-intrinsic: 1.3.0
|
||||
gopd: 1.2.0
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
sha.js@2.4.12:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
safe-buffer: 5.2.1
|
||||
to-buffer: 1.2.2
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -3542,6 +3822,10 @@ snapshots:
|
||||
dependencies:
|
||||
stream-chain: 2.2.5
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
@@ -3577,6 +3861,16 @@ snapshots:
|
||||
|
||||
tildify@2.0.0: {}
|
||||
|
||||
tiny-secp256k1@2.2.4:
|
||||
dependencies:
|
||||
uint8array-tools: 0.0.7
|
||||
|
||||
to-buffer@1.2.2:
|
||||
dependencies:
|
||||
isarray: 2.0.5
|
||||
safe-buffer: 5.2.1
|
||||
typed-array-buffer: 1.0.3
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
@@ -3663,6 +3957,8 @@ snapshots:
|
||||
turbo-windows-64: 2.8.15
|
||||
turbo-windows-arm64: 2.8.15
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -3674,10 +3970,18 @@ snapshots:
|
||||
media-typer: 0.3.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
typed-array-buffer@1.0.3:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
is-typed-array: 1.1.15
|
||||
|
||||
typeforce@1.18.0: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uint8array-tools@0.0.7: {}
|
||||
|
||||
ulidx@2.4.1:
|
||||
dependencies:
|
||||
layerr: 3.0.0
|
||||
@@ -3695,6 +3999,8 @@ snapshots:
|
||||
node-gyp-build: 4.8.4
|
||||
optional: true
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
uuid@11.1.1: {}
|
||||
@@ -3716,10 +4022,24 @@ snapshots:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which-typed-array@1.1.20:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
call-bind: 1.0.9
|
||||
call-bound: 1.0.4
|
||||
for-each: 0.3.5
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-tostringtag: 1.0.2
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
wif@2.0.6:
|
||||
dependencies:
|
||||
bs58check: 2.1.2
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
34
start.sh
34
start.sh
@@ -6,10 +6,12 @@ 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
|
||||
echo "[INFO] .env создан из примера — заполни Vault креды и запусти снова"
|
||||
chmod 600 .env
|
||||
echo "[INFO] .env создан из примера (mode 600) — заполни Vault креды и запусти снова"
|
||||
exit 1
|
||||
else
|
||||
echo "[ERROR] нет ни .env, ни .env.example"
|
||||
@@ -17,25 +19,41 @@ if [ ! -f .env ]; then
|
||||
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
|
||||
|
||||
# Logs dir для audit-log mount — container's app user is uid 1001
|
||||
mkdir -p logs
|
||||
chmod 750 logs
|
||||
# Если есть права — попытаться выставить нужный owner (требует sudo на host)
|
||||
if [ "$(stat -c %u logs 2>/dev/null)" != "1001" ]; then
|
||||
chown 1001:1001 logs 2>/dev/null || echo "[INFO] chown logs 1001:1001 пропущен (нет прав; audit может не писаться)"
|
||||
fi
|
||||
|
||||
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://localhost:3001/api/health >/dev/null 2>&1; then
|
||||
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. Logs:"
|
||||
docker compose logs --tail=50 api
|
||||
echo "[ERROR] API not healthy after 60s. Запусти 'docker compose logs --tail=50 api' для диагностики."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "API: http://localhost:3001"
|
||||
echo "Health: http://localhost:3001/api/health"
|
||||
echo "Docs: http://localhost:3001/api/docs"
|
||||
echo "Logs: docker compose logs -f api"
|
||||
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: tail -f logs/audit.log"
|
||||
|
||||
Reference in New Issue
Block a user