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:
@@ -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/ # Синхронизация исходного кода
|
||||
|
||||
34
src/__tests__/security.test.js
Normal file
34
src/__tests__/security.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = ?,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = ?',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(', ')})
|
||||
|
||||
@@ -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
60
src/utils/encryption.js
Normal 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');
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user