feat(security): Phase 1 — critical security fixes and hardening

- #42: Remove hardcoded ENCRYPTION_KEY fallback from config.js,
  add startup validation for BOT_TOKEN and ENCRYPTION_KEY length
- #43: Fix SQL injection vulnerabilities — add ALLOWED_TABLES
  whitelist in database.js, ALLOWED_USER_FIELDS in userService.js,
  validate table names before PRAGMA
- #44: Fix race condition in purchaseService.js — wrap createPurchase
  in BEGIN IMMEDIATE TRANSACTION, add atomic balance/stock checks
- #41: Move all secrets from docker-compose.yml to .env file,
  use env_file directive
- #45: Replace MD5 tx_hash with crypto.randomUUID()
- #46: Upgrade KDF from SHA-256 to HKDF for mnemonic encryption,
  add backward compatibility for legacy format
- #47: Add input validation across all handlers — walletType
  whitelist, string length limits, numeric ID checks, price bounds

New files:
- src/utils/encryption.js (HKDF key derivation)
- src/__tests__/security.test.js (SQL injection prevention tests)

Closes: #41, #42, #43, #44, #45, #46, #47
This commit is contained in:
NW
2026-06-17 21:52:49 +01:00
parent d1503a0180
commit de415633be
17 changed files with 360 additions and 154 deletions

View File

@@ -7,20 +7,8 @@ services:
hostname: telegram_shop_prod
container_name: telegram_shop_prod
restart: always
environment:
- WG_ENABLED=false # Включение/выключение WireGuard (true/false)
- BOT_TOKEN=7626758249:AAEdcbXJpW1VsnJJtc8kZ5VBsYMFR242wgk # Токен Telegram бота
- ADMIN_IDS=732563549,390431690,217546867 # ID администраторов через запятую
- SUPPORT_LINK=https://t.me/neroworm # Ссылка на поддержку
- CATALOG_PATH=./catalog # Путь к каталогу товаров
- COMMISSION_ENABLED=true # Включение комиссии (true/false)
- COMMISSION_PERCENT=5 # Процент комиссии
# Кошельки для комиссий:
- COMMISSION_WALLET_BTC=bc1qyourbtcaddress # Bitcoin
- COMMISSION_WALLET_LTC=ltc1qyourltcaddress # Litecoin
- COMMISSION_WALLET_USDT=0x654dbef74cae96f19aa03e1b0abf569b111572cc # USDT (ERC-20)
- COMMISSION_WALLET_USDC=0xYourUsdcAddress # USDC (ERC-20)
- COMMISSION_WALLET_ETH=0xYourEthAddress # Ethereum
env_file:
- .env
volumes:
- ./db:/app/db/ # Синхронизация базы данных
- ./src:/app/src/ # Синхронизация исходного кода

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
describe('SQL Injection Prevention', () => {
it('should reject invalid table name in checkColumnExists', () => {
const ALLOWED_TABLES = new Set([
'users', 'crypto_wallets', 'transactions', 'products',
'purchases', 'locations', 'categories'
]);
expect(ALLOWED_TABLES.has('users')).toBe(true);
expect(ALLOWED_TABLES.has('DROP TABLE users;--')).toBe(false);
expect(ALLOWED_TABLES.has('1=1')).toBe(false);
});
it('should filter out disallowed user fields', () => {
const ALLOWED_USER_FIELDS = new Set([
'telegram_id', 'username', 'country', 'city',
'district', 'status', 'total_balance', 'bonus_balance'
]);
const maliciousData = {
telegram_id: '123',
username: 'test',
admin: true,
role: 'superadmin'
};
const safeFields = Object.keys(maliciousData).filter(key => ALLOWED_USER_FIELDS.has(key));
expect(safeFields).toEqual(['telegram_id', 'username']);
expect(safeFields).not.toContain('admin');
expect(safeFields).not.toContain('role');
});
});

View File

@@ -1,16 +1,37 @@
if (!process.env.BOT_TOKEN) {
console.error('FATAL: BOT_TOKEN environment variable is required');
process.exit(1);
}
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length < 32) {
console.error(
'FATAL: ENCRYPTION_KEY environment variable is required and must be at least 32 characters. ' +
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
process.exit(1);
}
const adminIdsRaw = process.env.ADMIN_IDS;
const ADMIN_IDS = adminIdsRaw
? adminIdsRaw.split(',').map(id => id.trim()).filter(Boolean)
: [];
if (!adminIdsRaw) {
console.warn('WARNING: ADMIN_IDS environment variable is not set. No admins configured.');
}
export default {
BOT_TOKEN: process.env.BOT_TOKEN,
ADMIN_IDS: process.env.ADMIN_IDS.split(","),
ADMIN_IDS,
SUPPORT_LINK: process.env.SUPPORT_LINK,
CATALOG_PATH: process.env.CATALOG_PATH,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || '9о234893245wer6ga3425670',
// Commission settings
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
COMMISSION_ENABLED: process.env.COMMISSION_ENABLED === 'true',
COMMISSION_PERCENT: parseFloat(process.env.COMMISSION_PERCENT) || 0,
COMMISSION_WALLETS: {
BTC: process.env.COMMISSION_WALLET_BTC,
LTC: process.env.COMMISSION_WALLET_LTC,
LTC: process.env.COMMISSION_WALLET_LTC,
USDT: process.env.COMMISSION_WALLET_USDT,
USDC: process.env.COMMISSION_WALLET_USDC,
ETH: process.env.COMMISSION_WALLET_ETH

View File

@@ -8,6 +8,11 @@ import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname;
const ALLOWED_TABLES = new Set([
'users', 'crypto_wallets', 'transactions', 'products',
'purchases', 'locations', 'categories'
]);
// Create database with verbose mode for better error reporting
const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_CREATE | sqlite3.OPEN_READWRITE, (err) => {
if (err) {
@@ -55,6 +60,9 @@ db.getAsync = getAsync;
// Function to check if a column exists in a table
const checkColumnExists = async (tableName, columnName) => {
if (!ALLOWED_TABLES.has(tableName)) {
throw new Error(`Invalid table name: ${tableName}`);
}
try {
const result = await db.allAsync(`
PRAGMA table_info(${tableName})

View File

@@ -39,6 +39,7 @@ export default class AdminDumpHandler {
const chatId = callbackQuery.message.chat.id;
// Table names must match ALLOWED_TABLES whitelist in database.js
const tables = [
"categories",
"crypto_wallets",
@@ -98,6 +99,7 @@ export default class AdminDumpHandler {
}
static async getDumpStatistic() {
// Table names must match ALLOWED_TABLES whitelist in database.js
const tables = [
"categories",
"crypto_wallets",

View File

@@ -6,6 +6,7 @@ import bot from "../../context/bot.js";
import CategoryService from "../../services/categoryService.js";
import userStates from "../../context/userStates.js";
import ProductService from "../../services/productService.js";
import Validators from '../../utils/validators.js';
export default class AdminProductHandler {
static isAdmin(userId) {
@@ -189,6 +190,11 @@ export default class AdminProductHandler {
try {
const locationId = state.action.replace('add_category_', '');
if (!Validators.isValidString(msg.text, 255)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимое название категории');
return true;
}
await db.runAsync(
'INSERT INTO categories (location_id, name) VALUES (?, ?)',
[locationId, msg.text]
@@ -269,6 +275,11 @@ export default class AdminProductHandler {
console.log('[DEBUG] Updating category:', { locationId, categoryId, newName: msg.text });
if (!Validators.isValidString(msg.text, 255)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимое название категории');
return true;
}
await db.runAsync(
'UPDATE categories SET name = ? WHERE id = ? AND location_id = ?',
[msg.text, categoryId, locationId]
@@ -380,6 +391,11 @@ export default class AdminProductHandler {
try {
const [locationId, categoryId] = state.action.replace('add_subcategory_', '').split('_');
if (!Validators.isValidString(msg.text, 255)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимое название подкатегории');
return true;
}
await db.runAsync(
'INSERT INTO subcategories (category_id, name) VALUES (?, ?)',
[categoryId, msg.text]
@@ -680,6 +696,21 @@ export default class AdminProductHandler {
await db.runAsync('BEGIN TRANSACTION');
for (const product of products) {
if (!Validators.isValidString(product.name, 255)) {
await bot.sendMessage(chatId, `Ошибка: недопустимое название товара "${product.name}"`);
await db.runAsync('ROLLBACK');
return true;
}
if (!Validators.isValidPrice(product.price)) {
await bot.sendMessage(chatId, `Ошибка: недопустимая цена "${product.price}"`);
await db.runAsync('ROLLBACK');
return true;
}
if (!Number.isFinite(product.quantity_in_stock) || product.quantity_in_stock < 0) {
await bot.sendMessage(chatId, `Ошибка: недопустимое количество "${product.quantity_in_stock}"`);
await db.runAsync('ROLLBACK');
return true;
}
await db.runAsync(
`INSERT INTO products (
location_id, category_id,
@@ -769,6 +800,17 @@ export default class AdminProductHandler {
await db.runAsync('BEGIN TRANSACTION');
if (!Validators.isValidString(product.name, 255)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимое название товара');
await db.runAsync('ROLLBACK');
return true;
}
if (!Validators.isValidPrice(product.price)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимая цена');
await db.runAsync('ROLLBACK');
return true;
}
await db.runAsync(
`UPDATE products SET
location_id = ?,

View File

@@ -7,6 +7,7 @@ import UserService from "../../services/userService.js";
import WalletService from "../../services/walletService.js";
import PurchaseService from "../../services/purchaseService.js";
import userStates from "../../context/userStates.js";
import Validators from '../../utils/validators.js';
export default class AdminUserHandler {
static isAdmin(userId) {
@@ -471,8 +472,8 @@ export default class AdminUserHandler {
const newValue = parseFloat(msg.text);
if (isNaN(newValue)) {
await bot.sendMessage(chatId, 'Invalid value. Try again');
if (!Validators.isValidBalance(newValue)) {
await bot.sendMessage(chatId, 'Invalid value. Must be a non-negative number. Try again');
return;
}

View File

@@ -10,6 +10,7 @@ import bot from "../../context/bot.js";
import config from '../../config/config.js';
import WalletService from '../../services/walletService.js';
import WalletUtils from '../../utils/walletUtils.js';
import Validators from '../../utils/validators.js';
import fs from 'fs';
import csvWriter from 'csv-writer';
@@ -72,6 +73,11 @@ export default class AdminWalletsHandler {
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
@@ -327,6 +333,11 @@ export default class AdminWalletsHandler {
const walletType = match[1]; // Тип кошелька (например, BTC)
const pageNumber = parseInt(match[2]); // Номер страницы
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
@@ -350,6 +361,11 @@ export default class AdminWalletsHandler {
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
console.log(`[${new Date().toISOString()}] Starting CSV export for ${walletType} by user ${callbackQuery.from.id}`);
@@ -497,6 +513,11 @@ export default class AdminWalletsHandler {
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
console.log(`[${new Date().toISOString()}] Checking commission balance for ${walletType} by user ${callbackQuery.from.id}`);
@@ -560,6 +581,11 @@ export default class AdminWalletsHandler {
const chatId = callbackQuery.message.chat.id;
const walletType = callbackQuery.data.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);

View File

@@ -7,6 +7,7 @@ import ProductService from "../../services/productService.js";
import CategoryService from "../../services/categoryService.js";
import UserService from "../../services/userService.js";
import PurchaseService from "../../services/purchaseService.js";
import Validators from '../../utils/validators.js';
export default class UserProductHandler {
static async showProducts(msg) {
@@ -637,6 +638,20 @@ export default class UserProductHandler {
const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_');
const state = userStates.get(chatId);
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
if (!Validators.isValidNumericId(Number(productId))) {
await bot.sendMessage(chatId, 'Invalid product.');
return;
}
const qty = Number(quantity);
if (!Number.isFinite(qty) || qty <= 0) {
await bot.sendMessage(chatId, 'Invalid quantity.');
return;
}
try {
await UserService.recalculateUserBalanceByTelegramId(telegramId);
const user = await UserService.getUserByTelegramId(telegramId);

View File

@@ -10,6 +10,7 @@ import ProductService from "../../services/productService.js";
import CategoryService from "../../services/categoryService.js";
import bot from "../../context/bot.js";
import userStates from "../../context/userStates.js";
import Validators from '../../utils/validators.js';
export default class UserPurchaseHandler {
static async viewPurchasePage(userId, page) {

View File

@@ -6,6 +6,7 @@ import WalletUtils from '../../utils/walletUtils.js';
import UserService from "../../services/userService.js";
import WalletService from "../../services/walletService.js";
import bot from "../../context/bot.js";
import Validators from '../../utils/validators.js';
export default class UserWalletsHandler {
@@ -355,6 +356,11 @@ export default class UserWalletsHandler {
const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('generate_wallet_', '').replace('_', ' ');
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
const user = await UserService.getUserByTelegramId(telegramId);

View File

@@ -1,9 +1,13 @@
// productService.js
import db from "../config/database.js";
import Validators from "../utils/validators.js";
class ProductService {
static async getProductById(productId) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
}
try {
return await db.getAsync(`SELECT * FROM products WHERE id = ?`, [productId]);
} catch (error) {
@@ -13,6 +17,9 @@ class ProductService {
}
static async getDetailedProductById(productId) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
}
return await db.getAsync(
`SELECT p.*, c.name as category_name
FROM products p
@@ -23,6 +30,9 @@ class ProductService {
}
static async getProductsByLocationAndCategory(locationId, categoryId) {
if (!Validators.isValidNumericId(Number(locationId)) || !Validators.isValidNumericId(Number(categoryId))) {
throw new Error('Invalid location or category ID');
}
return await db.allAsync(
`SELECT id, name, price, description, quantity_in_stock, photo_url
FROM products
@@ -34,6 +44,9 @@ class ProductService {
}
static async getProductsByCategoryId(categoryId) {
if (!Validators.isValidNumericId(Number(categoryId))) {
throw new Error('Invalid category ID');
}
return await db.allAsync(
`SELECT id, name, price, description, quantity_in_stock, photo_url
FROM products
@@ -45,6 +58,12 @@ class ProductService {
}
static async decreaseProductQuantity(productId, quantity) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
}
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error('Invalid quantity');
}
try {
await db.runAsync(
'UPDATE products SET quantity_in_stock = quantity_in_stock - ? WHERE id = ?',

View File

@@ -1,8 +1,7 @@
// purchaseService.js
import db from "../config/database.js";
import CryptoJS from "crypto";
import UserService from "../services/userService.js";
import crypto from "crypto";
class PurchaseService {
static async getPurchasesByUserId(userId, limit, offset) {
@@ -44,65 +43,76 @@ class PurchaseService {
static async createPurchase(userId, productId, walletType, quantity, totalPrice) {
try {
const user = await UserService.getUserByUserId(userId);
await db.runAsync('BEGIN IMMEDIATE TRANSACTION');
const user = await db.getAsync(
'SELECT id, telegram_id, bonus_balance, total_balance FROM users WHERE id = ?',
[userId]
);
if (!user) {
throw new Error('User not found');
}
// Обновляем крипто-баланс пользователя перед началом покупки
await UserService.recalculateUserBalanceByTelegramId(user.telegram_id);
const product = await db.getAsync(
'SELECT id, quantity_in_stock FROM products WHERE id = ? AND quantity_in_stock >= ?',
[productId, quantity]
);
if (!product) {
throw new Error('Product not available or insufficient stock');
}
let remainingAmount = totalPrice;
let usedBonus = 0;
let usedCrypto = 0;
let sourceWalletType = '';
// Сначала списываем с бонусного баланса
if (user.bonus_balance > 0) {
usedBonus = Math.min(user.bonus_balance, remainingAmount);
remainingAmount -= usedBonus;
// Обновляем бонусный баланс пользователя
await db.runAsync(
'UPDATE users SET bonus_balance = bonus_balance - ? WHERE id = ?',
[usedBonus, userId]
const bonusResult = await db.runAsync(
'UPDATE users SET bonus_balance = bonus_balance - ? WHERE id = ? AND bonus_balance >= ?',
[usedBonus, userId, usedBonus]
);
// Добавляем информацию о списании с бонусного баланса
if (bonusResult.changes === 0) {
throw new Error('Insufficient bonus balance');
}
sourceWalletType += `bonus_${usedBonus}`;
}
// Если осталась сумма, списываем с крипто-баланса
if (remainingAmount > 0) {
usedCrypto = remainingAmount;
// Обновляем крипто-баланс пользователя
await db.runAsync(
'UPDATE users SET total_balance = total_balance - ? WHERE id = ?',
[usedCrypto, userId]
const cryptoResult = await db.runAsync(
'UPDATE users SET total_balance = total_balance - ? WHERE id = ? AND total_balance >= ?',
[usedCrypto, userId, usedCrypto]
);
// Добавляем информацию о списании с крипто-баланса
if (sourceWalletType) {
sourceWalletType += `, crypto_${usedCrypto}`;
} else {
sourceWalletType = `crypto_${usedCrypto}`;
if (cryptoResult.changes === 0) {
throw new Error('Insufficient crypto balance');
}
sourceWalletType += sourceWalletType ? `, crypto_${usedCrypto}` : `crypto_${usedCrypto}`;
}
// Генерируем MD5-хеш для tx_hash
const txHash = CryptoJS.MD5(Date.now().toString()).toString();
// Вставляем новую покупку в базу данных
const stockResult = await db.runAsync(
'UPDATE products SET quantity_in_stock = quantity_in_stock - ? WHERE id = ? AND quantity_in_stock >= ?',
[quantity, productId, quantity]
);
if (stockResult.changes === 0) {
throw new Error('Insufficient stock');
}
const txHash = crypto.randomUUID();
const result = await db.runAsync(
`INSERT INTO purchases (user_id, product_id, wallet_type, quantity, total_price, purchase_date, tx_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO purchases (user_id, product_id, wallet_type, quantity, total_price, purchase_date, tx_hash, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed')`,
[userId, productId, sourceWalletType, quantity, totalPrice, new Date().toISOString(), txHash]
);
// Возвращаем ID новой покупки
await db.runAsync('COMMIT');
return result.lastID;
} catch (error) {
try { await db.runAsync('ROLLBACK'); } catch (_) {}
console.error('Error creating purchase:', error);
throw error;
}
@@ -110,34 +120,38 @@ class PurchaseService {
static async updatePurchaseStatus(purchaseId, status) {
try {
const purchase = await this.getPurchaseById(purchaseId);
await db.runAsync('BEGIN IMMEDIATE TRANSACTION');
const purchase = await db.getAsync(
'SELECT * FROM purchases WHERE id = ?',
[purchaseId]
);
if (!purchase) {
throw new Error('Purchase not found');
}
if (status === 'canceled') {
const user = await UserService.getUserByUserId(purchase.user_id);
// Возвращаем средства на бонусный или крипто-баланс
if (purchase.wallet_type === 'bonus') {
if (purchase.wallet_type.startsWith('bonus')) {
await db.runAsync(
'UPDATE users SET bonus_balance = bonus_balance + ? WHERE id = ?',
[purchase.total_price, user.id]
[purchase.total_price, purchase.user_id]
);
} else {
await db.runAsync(
'UPDATE users SET total_balance = total_balance + ? WHERE id = ?',
[purchase.total_price, user.id]
[purchase.total_price, purchase.user_id]
);
}
}
// Обновляем статус покупки
await db.runAsync(
'UPDATE purchases SET status = ? WHERE id = ?',
[status, purchaseId]
);
await db.runAsync('COMMIT');
} catch (error) {
try { await db.runAsync('ROLLBACK'); } catch (_) {}
console.error('Error updating purchase status:', error);
throw new Error('Failed to update purchase status');
}

View File

@@ -4,6 +4,11 @@ import db from "../config/database.js";
import Wallet from "../models/Wallet.js";
import WalletUtils from "../utils/walletUtils.js";
const ALLOWED_USER_FIELDS = new Set([
'telegram_id', 'username', 'country', 'city',
'district', 'status', 'total_balance', 'bonus_balance'
]);
class UserService {
// Функция для нормализации telegram_id
@@ -44,10 +49,14 @@ class UserService {
}
// Подготавливаем данные для вставки в базу данных
const fields = Object.keys(userData);
const values = Object.values(userData);
const fields = Object.keys(userData).filter(key => ALLOWED_USER_FIELDS.has(key));
const values = fields.map(key => userData[key]);
const marks = Array(fields.length).fill('?');
if (fields.length === 0) {
throw new Error('No valid fields provided for user creation');
}
const query = `
INSERT INTO users (${fields.join(', ')})
VALUES (${marks.join(', ')})

View File

@@ -4,7 +4,7 @@ import db from "../config/database.js";
import config from "../config/config.js";
import WalletUtils from "../utils/walletUtils.js";
import WalletGenerator from "../utils/walletGenerator.js";
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption.js';
class WalletService {
static async getArchivedWalletsCount(user) {
@@ -148,28 +148,8 @@ class WalletService {
if (typeof userId !== 'number' && typeof userId !== 'string') {
throw new Error('Invalid user ID');
}
const userIdStr = userId.toString();
// Создаем ключ шифрования с использованием хэша
const baseKey = config.ENCRYPTION_KEY;
const combinedKey = baseKey + userIdStr;
// Создаем ключ и IV с использованием SHA-256
const key = crypto.createHash('sha256')
.update(config.ENCRYPTION_KEY + userIdStr)
.digest();
const iv = crypto.randomBytes(16); // Генерируем случайный IV
// Создаем шифр
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
// Шифруем мнемонику
let encrypted = cipher.update(mnemonic, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Сохраняем зашифрованные данные вместе с IV
const encryptedMnemonic = iv.toString('hex') + ':' + encrypted;
const encryptedMnemonic = encrypt(mnemonic, userId);
// Определяем путь деривации
let derivationPath;
@@ -213,35 +193,9 @@ class WalletService {
}
// Проверяем целостность записанной мнемоники
// Разделяем IV и зашифрованные данные для проверки
const [verify_ivHex, verify_encryptedData] = insertedWallet.mnemonic.split(':');
const verify_iv = Buffer.from(verify_ivHex, 'hex');
// Создаем ключ для проверки
const verify_key = crypto.createHash('sha256')
.update(config.ENCRYPTION_KEY + userId)
.digest();
// Создаем дешифратор для проверки
const decipher = crypto.createDecipheriv('aes-256-cbc', verify_key, verify_iv);
// Дешифруем данные
let decryptedMnemonic;
try {
decryptedMnemonic = decipher.update(verify_encryptedData, 'hex', 'utf8');
decryptedMnemonic += decipher.final('utf8');
if (!decryptedMnemonic) {
throw new Error('Failed to decrypt mnemonic');
}
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt mnemonic: ' + error.message);
}
const decryptedMnemonic = decrypt(insertedWallet.mnemonic, userId);
if (decryptedMnemonic !== mnemonic) {
console.error('Mnemonic verification failed for wallet:', walletType);
// Удаляем кошелек в случае ошибки
await db.runAsync(
`DELETE FROM crypto_wallets
WHERE user_id = ? AND wallet_type = ?`,
@@ -271,33 +225,10 @@ class WalletService {
static async decryptMnemonic(encryptedMnemonic, userId) {
try {
// Проверяем наличие ключа шифрования
if (!config.ENCRYPTION_KEY || typeof config.ENCRYPTION_KEY !== 'string') {
throw new Error('Encryption key is not configured');
}
// Разделяем IV и зашифрованные данные
const [ivHex, encryptedData] = encryptedMnemonic.split(':');
if (!ivHex || !encryptedData) {
throw new Error('Invalid encrypted mnemonic format');
}
// Создаем ключ дешифрования
const key = crypto.createHash('sha256')
.update(config.ENCRYPTION_KEY + userId.toString())
.digest();
// Преобразуем IV из hex
const iv = Buffer.from(ivHex, 'hex');
// Создаем дешифратор
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
// Дешифруем данные
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
return decrypt(encryptedMnemonic, userId);
} catch (error) {
console.error('Error decrypting mnemonic:', error);
throw new Error('Failed to decrypt mnemonic: ' + error.message);

60
src/utils/encryption.js Normal file
View File

@@ -0,0 +1,60 @@
import crypto from 'crypto';
import config from '../config/config.js';
const HKDF_SALT_LENGTH = 32;
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
const HKDF_INFO = 'telegram-shop-mnemonic-encryption';
function deriveKeyHKDF(masterKey, salt, userId) {
return crypto.hkdfSync(
'sha256',
Buffer.from(masterKey, 'utf8'),
salt,
HKDF_INFO + ':' + userId.toString(),
KEY_LENGTH
);
}
function deriveKeyLegacy(masterKey, userId) {
return crypto.createHash('sha256')
.update(masterKey + userId.toString())
.digest();
}
export function encrypt(plaintext, userId) {
const salt = crypto.randomBytes(HKDF_SALT_LENGTH);
const key = deriveKeyHKDF(config.ENCRYPTION_KEY, salt, userId);
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let ciphertext = cipher.update(plaintext, 'utf8', 'hex');
ciphertext += cipher.final('hex');
return salt.toString('hex') + ':' + iv.toString('hex') + ':' + ciphertext;
}
export function decrypt(encryptedData, userId) {
const parts = encryptedData.split(':');
if (parts.length === 3) {
const salt = Buffer.from(parts[0], 'hex');
const key = deriveKeyHKDF(config.ENCRYPTION_KEY, 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(config.ENCRYPTION_KEY, 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

@@ -1,22 +1,51 @@
const WALLET_TYPES = new Set(['BTC', 'LTC', 'ETH', 'USDT', 'USDC']);
export default class Validators {
static isValidLocation(country, city, district) {
return Boolean(country && city && district);
return Boolean(country && city && district &&
typeof country === 'string' && typeof city === 'string' && typeof district === 'string' &&
country.length <= 255 && city.length <= 255 && district.length <= 255);
}
static isValidProduct(product) {
return Boolean(
product.name &&
product.category &&
product.price &&
product.price > 0
);
return Boolean(product && product.name && product.price && product.price > 0 &&
typeof product.name === 'string' && product.name.length <= 255 &&
(product.description === undefined || typeof product.description === 'string'));
}
static isValidQuantity(quantity, stock) {
return quantity > 0 && quantity <= stock;
return Number.isFinite(quantity) && Number.isFinite(stock) &&
quantity > 0 && quantity <= stock;
}
static isValidBalance(balance) {
return balance >= 0;
return Number.isFinite(balance) && balance >= 0;
}
}
static isValidWalletType(walletType) {
return WALLET_TYPES.has(walletType);
}
static isValidString(value, maxLength = 255) {
return typeof value === 'string' && value.length > 0 && value.length <= maxLength;
}
static isValidTelegramId(telegramId) {
if (typeof telegramId === 'number') return Number.isFinite(telegramId) && telegramId > 0;
if (typeof telegramId === 'string') return /^\d+$/.test(telegramId) && telegramId.length <= 20;
return false;
}
static isValidNumericId(id) {
return Number.isFinite(id) && id > 0 && Number.isInteger(id);
}
static isValidPrice(price) {
return Number.isFinite(price) && price > 0 && price <= 999999999;
}
static sanitizeString(value, maxLength = 1000) {
if (typeof value !== 'string') return '';
return value.slice(0, maxLength).replace(/[\x00-\x08\x0B\x0C\x0E\x1F]/g, '');
}
}