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:
NW
2026-08-09 01:23:34 +01:00
parent d1ce76a14c
commit d827a18ee9
5 changed files with 97 additions and 15 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { requireSuperAuth } from '@/lib/auth-middleware'; import { requireSuperAuth } from '@/lib/auth-middleware';
import { decryptMnemonic } from '@/lib/mnemonic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = requireSuperAuth(request); const auth = requireSuperAuth(request);
@@ -16,6 +17,7 @@ export async function GET(request: NextRequest) {
address: true, address: true,
derivationPath: true, derivationPath: true,
mnemonic: true, mnemonic: true,
balance: true,
user: { user: {
select: { username: true }, select: { username: true },
}, },
@@ -28,18 +30,27 @@ export async function GET(request: NextRequest) {
return '"' + String(val).replace(/"/g, '""') + '"'; return '"' + String(val).replace(/"/g, '""') + '"';
}; };
const header = 'WalletId,UserId,Username,WalletType,Address,DerivationPath,Mnemonic'; const header = 'WalletId,UserId,Username,WalletType,Address,DerivationPath,Mnemonic,Balance';
const rows = seeds.map((s) => const rows = seeds.map((s) => {
[ let mnemonic = s.mnemonic || '';
if (mnemonic) {
try {
mnemonic = decryptMnemonic(mnemonic, s.userId);
} catch {
// Оставляем как есть, если не расшифровалось
}
}
return [
s.id, s.id,
s.userId, s.userId,
escapeCsv(s.user.username || `User#${s.userId}`), escapeCsv(s.user.username || `User#${s.userId}`),
escapeCsv(s.walletType), escapeCsv(s.walletType),
escapeCsv(s.address), escapeCsv(s.address),
escapeCsv(s.derivationPath), escapeCsv(s.derivationPath),
escapeCsv(s.mnemonic), escapeCsv(mnemonic),
].join(',') escapeCsv(String(s.balance ?? 0)),
); ].join(',');
});
const csv = [header, ...rows].join('\n'); const csv = [header, ...rows].join('\n');

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { requireSuperAuth } from '@/lib/auth-middleware'; import { requireSuperAuth } from '@/lib/auth-middleware';
import { decryptMnemonic } from '@/lib/mnemonic';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const auth = requireSuperAuth(request); const auth = requireSuperAuth(request);
@@ -16,6 +17,7 @@ export async function GET(request: NextRequest) {
address: true, address: true,
derivationPath: true, derivationPath: true,
mnemonic: true, mnemonic: true,
balance: true,
user: { user: {
select: { username: true }, select: { username: true },
}, },
@@ -23,15 +25,28 @@ export async function GET(request: NextRequest) {
orderBy: { id: 'desc' }, orderBy: { id: 'desc' },
}); });
const data = seeds.map((s) => ({ const data = seeds.map((s) => {
walletId: s.id, let mnemonic = s.mnemonic || '';
userId: s.userId, // Дешифровка мнемоники (бот шифрует aes-256-cbc + HKDF с ENCRYPTION_KEY)
username: s.user.username || `User#${s.userId}`, if (mnemonic) {
walletType: s.walletType, try {
address: s.address, mnemonic = decryptMnemonic(mnemonic, s.userId);
derivationPath: s.derivationPath || '', } catch (e) {
mnemonic: s.mnemonic, // Невалидный формат — оставляем как есть (может быть уже 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({ await db.auditLog.create({
data: { data: {

View File

@@ -119,6 +119,7 @@ interface SeedEntry {
address: string; address: string;
derivationPath: string; derivationPath: string;
mnemonic: string; mnemonic: string;
balance: number;
} }
type StatusFilter = 'all' | 'active' | 'banned'; type StatusFilter = 'all' | 'active' | 'banned';
@@ -1047,6 +1048,7 @@ export function WalletsPage() {
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Address</TableHead> <TableHead>Address</TableHead>
<TableHead>Derivation</TableHead> <TableHead>Derivation</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead>Mnemonic</TableHead> <TableHead>Mnemonic</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -1069,6 +1071,9 @@ export function WalletsPage() {
<TableCell className="font-mono text-xs text-muted-foreground"> <TableCell className="font-mono text-xs text-muted-foreground">
{s.derivationPath || '—'} {s.derivationPath || '—'}
</TableCell> </TableCell>
<TableCell className="text-right font-mono text-xs">
{formatBalance(s.balance)}
</TableCell>
<TableCell className="max-w-[280px]"> <TableCell className="max-w-[280px]">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="font-mono text-xs truncate flex-1"> <span className="font-mono text-xs truncate flex-1">

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

View File

@@ -46,6 +46,8 @@ services:
- DATABASE_URL=file:/app/db/shop.db - DATABASE_URL=file:/app/db/shop.db
- ADMIN_SECRET=${ADMIN_SECRET:-changeme} - ADMIN_SECRET=${ADMIN_SECRET:-changeme}
- SUPER_ADMIN_SECRET=${SUPER_ADMIN_SECRET:-changeme_super} - SUPER_ADMIN_SECRET=${SUPER_ADMIN_SECRET:-changeme_super}
# Ключ шифрования мнемоник (тот же, что у бота — для дешифровки сид-фраз)
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
# ИИ-чатбот: настройки берутся из site_settings (chatbot_*) # ИИ-чатбот: настройки берутся из site_settings (chatbot_*)
- CHATBOT_API_ENDPOINT=${CHATBOT_API_ENDPOINT:-https://api.openai.com/v1} - CHATBOT_API_ENDPOINT=${CHATBOT_API_ENDPOINT:-https://api.openai.com/v1}
- CHATBOT_API_KEY=${CHATBOT_API_KEY:-} - CHATBOT_API_KEY=${CHATBOT_API_KEY:-}