security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user