security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)

This commit is contained in:
ZOMBIIIIIII
2026-05-12 01:47:58 +03:00
parent c8bc40af97
commit 8dc0855827
37 changed files with 1852 additions and 318 deletions

View File

@@ -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) — повышенный лимит

View File

@@ -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,

View File

@@ -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;
}
},
};

View File

@@ -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, () => {

View 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 });
}

View File

@@ -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]' },
];

View File

@@ -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' });
}
}

View File

@@ -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' });

View File

@@ -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' },
});

View File

@@ -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);

View File

@@ -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() });
},
};

View File

@@ -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);
},
};

View File

@@ -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('*');
},
};

View File

@@ -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' });
}
}

View File

@@ -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;
}

View File

@@ -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' });
}
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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);

View 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');
}
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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)`);
}

View 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])));
}

View File

@@ -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';

View 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);
}
}