feat(admin): decrypt mnemonics + balance column in seed phrases
- lib/mnemonic.ts: decryptMnemonic (same AES-256-CBC+HKDF algo as bot, uses ENCRYPTION_KEY) - wallets/seeds API: decrypts mnemonic per user, adds balance field - wallets/export-seeds (CSV): decrypts mnemonic, adds Balance column - wallets-page: Balance column added to seed phrases table - compose: ENCRYPTION_KEY passed to tg_shop_admin (same key as bot)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
||||
import { decryptMnemonic } from '@/lib/mnemonic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = requireSuperAuth(request);
|
||||
@@ -16,6 +17,7 @@ export async function GET(request: NextRequest) {
|
||||
address: true,
|
||||
derivationPath: true,
|
||||
mnemonic: true,
|
||||
balance: true,
|
||||
user: {
|
||||
select: { username: true },
|
||||
},
|
||||
@@ -28,18 +30,27 @@ export async function GET(request: NextRequest) {
|
||||
return '"' + String(val).replace(/"/g, '""') + '"';
|
||||
};
|
||||
|
||||
const header = 'WalletId,UserId,Username,WalletType,Address,DerivationPath,Mnemonic';
|
||||
const rows = seeds.map((s) =>
|
||||
[
|
||||
const header = 'WalletId,UserId,Username,WalletType,Address,DerivationPath,Mnemonic,Balance';
|
||||
const rows = seeds.map((s) => {
|
||||
let mnemonic = s.mnemonic || '';
|
||||
if (mnemonic) {
|
||||
try {
|
||||
mnemonic = decryptMnemonic(mnemonic, s.userId);
|
||||
} catch {
|
||||
// Оставляем как есть, если не расшифровалось
|
||||
}
|
||||
}
|
||||
return [
|
||||
s.id,
|
||||
s.userId,
|
||||
escapeCsv(s.user.username || `User#${s.userId}`),
|
||||
escapeCsv(s.walletType),
|
||||
escapeCsv(s.address),
|
||||
escapeCsv(s.derivationPath),
|
||||
escapeCsv(s.mnemonic),
|
||||
].join(',')
|
||||
);
|
||||
escapeCsv(mnemonic),
|
||||
escapeCsv(String(s.balance ?? 0)),
|
||||
].join(',');
|
||||
});
|
||||
|
||||
const csv = [header, ...rows].join('\n');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { requireSuperAuth } from '@/lib/auth-middleware';
|
||||
import { decryptMnemonic } from '@/lib/mnemonic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = requireSuperAuth(request);
|
||||
@@ -16,6 +17,7 @@ export async function GET(request: NextRequest) {
|
||||
address: true,
|
||||
derivationPath: true,
|
||||
mnemonic: true,
|
||||
balance: true,
|
||||
user: {
|
||||
select: { username: true },
|
||||
},
|
||||
@@ -23,15 +25,28 @@ export async function GET(request: NextRequest) {
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
|
||||
const data = seeds.map((s) => ({
|
||||
walletId: s.id,
|
||||
userId: s.userId,
|
||||
username: s.user.username || `User#${s.userId}`,
|
||||
walletType: s.walletType,
|
||||
address: s.address,
|
||||
derivationPath: s.derivationPath || '',
|
||||
mnemonic: s.mnemonic,
|
||||
}));
|
||||
const data = seeds.map((s) => {
|
||||
let mnemonic = s.mnemonic || '';
|
||||
// Дешифровка мнемоники (бот шифрует aes-256-cbc + HKDF с ENCRYPTION_KEY)
|
||||
if (mnemonic) {
|
||||
try {
|
||||
mnemonic = decryptMnemonic(mnemonic, s.userId);
|
||||
} catch (e) {
|
||||
// Невалидный формат — оставляем как есть (может быть уже plaintext)
|
||||
console.error(`Decrypt mnemonic failed for wallet ${s.id}:`, e);
|
||||
}
|
||||
}
|
||||
return {
|
||||
walletId: s.id,
|
||||
userId: s.userId,
|
||||
username: s.user.username || `User#${s.userId}`,
|
||||
walletType: s.walletType,
|
||||
address: s.address,
|
||||
derivationPath: s.derivationPath || '',
|
||||
mnemonic,
|
||||
balance: s.balance ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
|
||||
@@ -119,6 +119,7 @@ interface SeedEntry {
|
||||
address: string;
|
||||
derivationPath: string;
|
||||
mnemonic: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'banned';
|
||||
@@ -1047,6 +1048,7 @@ export function WalletsPage() {
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Derivation</TableHead>
|
||||
<TableHead className="text-right">Balance</TableHead>
|
||||
<TableHead>Mnemonic</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1069,6 +1071,9 @@ export function WalletsPage() {
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{s.derivationPath || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{formatBalance(s.balance)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
|
||||
49
admin-next/src/lib/mnemonic.ts
Normal file
49
admin-next/src/lib/mnemonic.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Дешифровка мнемоник — тот же алгоритм, что в боте (src/utils/encryption.js)
|
||||
const HKDF_SALT_LENGTH = 32;
|
||||
const IV_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const HKDF_INFO = 'telegram-shop-mnemonic-encryption';
|
||||
|
||||
function deriveKeyHKDF(masterKey: string, salt: Buffer, userId: number) {
|
||||
return crypto.hkdfSync(
|
||||
'sha256',
|
||||
Buffer.from(masterKey, 'utf8'),
|
||||
salt,
|
||||
HKDF_INFO + ':' + userId.toString(),
|
||||
KEY_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
function deriveKeyLegacy(masterKey: string, userId: number) {
|
||||
return crypto.createHash('sha256').update(masterKey + userId.toString()).digest();
|
||||
}
|
||||
|
||||
export function decryptMnemonic(encryptedData: string, userId: number): string {
|
||||
const parts = encryptedData.split(':');
|
||||
const masterKey = process.env.ENCRYPTION_KEY || '';
|
||||
|
||||
if (parts.length === 3) {
|
||||
const salt = Buffer.from(parts[0], 'hex');
|
||||
const key = deriveKeyHKDF(masterKey, salt, userId);
|
||||
const iv = Buffer.from(parts[1], 'hex');
|
||||
const ciphertext = parts[2];
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
const key = deriveKeyLegacy(masterKey, userId);
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const ciphertext = parts[1];
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
||||
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
throw new Error('Invalid encrypted data format');
|
||||
}
|
||||
@@ -46,6 +46,8 @@ services:
|
||||
- DATABASE_URL=file:/app/db/shop.db
|
||||
- ADMIN_SECRET=${ADMIN_SECRET:-changeme}
|
||||
- SUPER_ADMIN_SECRET=${SUPER_ADMIN_SECRET:-changeme_super}
|
||||
# Ключ шифрования мнемоник (тот же, что у бота — для дешифровки сид-фраз)
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
# ИИ-чатбот: настройки берутся из site_settings (chatbot_*)
|
||||
- CHATBOT_API_ENDPOINT=${CHATBOT_API_ENDPOINT:-https://api.openai.com/v1}
|
||||
- CHATBOT_API_KEY=${CHATBOT_API_KEY:-}
|
||||
|
||||
Reference in New Issue
Block a user