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 { 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');
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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
|
- 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:-}
|
||||||
|
|||||||
Reference in New Issue
Block a user