commit fa9d24a0c5a70f8195e90f27fb3c32cdb7855d9d Author: Red Date: Sat Sep 27 02:16:54 2025 +0500 First init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e7b467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +db.sqlite +node_modules/ +package-lock.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..21f01c7 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Сервер-менеджер для ключей. + +### Этот сервер может понадобиться в разработке безопасного api, авторизации и аутентификации. + + +### Ключевые особенности: + - Авторотация ключей (с возможностью указать интервал обновления) + - Генерация ключей (пока нельзя свой вставить и навсегда) + - Идеален для JWT и задач криптографии. + - Плавная ротация поддерживается (старые ключи живут некоторое время после создания нового). + - Оформлено в виде удобного api, с понятными конечными маршрутами. + - Хранилище в виде базы данных(sqlite) с собственной оберткой. + - AES-шифрование из коробки. Обязательно нужно обновить ключ в /keys перед продакшн-деплоем. + - Дружелюбный язык и стэк - node js, express. Это вам не сложный java и Spring Boot. + + + +# Краткое руководство +### Суть +Предполагается, что: + +1. Сервис будет запускаться локально (извне обращения игнорироваться). +2. Использоваться для благих целей. + +### Как пользоваться +Пользоваться им очень просто, как два пальца об асфальт. + +#### Для создания ключа: +Нужно обратиться к /reg-key с такой структурой: +``` +POST /reg-key +body: { + "name": "Example", + "update": 24 +} +``` + +где: +- update - интервал обновления +- name - название ключа + +#### Для получения ключа: +Тут тоже все просто, отличие лишь в способе передачи информации: +``` +GET /get-key +query-параметры: + - name + - kid (опционально) + +примеры: + - http://localhost/get-key?name=Example + - http://localhost/get-key?name=Example&kid=4adc0149-49e7-45e7-b0af-5e0c4da5fe3f +``` +Все, это все что вам нужно знать, чтобы использовать этот сервис. diff --git a/keys/aes-256.key b/keys/aes-256.key new file mode 100644 index 0000000..a573592 --- /dev/null +++ b/keys/aes-256.key @@ -0,0 +1 @@ +bU| 7yH@uɮ#I \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..09432c5 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "better-sqlite3": "^12.4.1", + "express": "^5.1.0", + "node-cron": "^4.2.1" + }, + "type": "module" +} diff --git a/src/lib/db/AsyncTableWrap.js b/src/lib/db/AsyncTableWrap.js new file mode 100644 index 0000000..0209d24 --- /dev/null +++ b/src/lib/db/AsyncTableWrap.js @@ -0,0 +1,88 @@ +import { Worker } from 'worker_threads' +import { writeFileSync } from 'fs' +import path from 'path' +import { unlink } from 'fs/promises' +import { getClassMethodNames } from '../util.js' +import { Table } from './Table.js' + +export class AsyncTableWrapper { + static _sharedWorker = null + static _sharedCallbacks = new Map() + static _messageId = 0 + static _initOnce(dbFile, tableConfigs) { + if (this._sharedWorker) return + + const workerScript = ` + import { parentPort, workerData } from 'worker_threads' + import { Database } from './lib/db/Database.js' + import { Table } from './lib/db/Table.js' + + const db = new Database(workerData.dbName) + const tables = new Map() + + for (const config of workerData.tableConfigs) { + tables.set(config.name, new Table(db, config)) + } + + parentPort.on('message', async ({ id, tableName, method, args }) => { + const table = tables.get(tableName) + if (!table) { + return parentPort.postMessage({ id, error: 'Table not found: ' + tableName }) + } + + try { + const result = await table[method](...args) + parentPort.postMessage({ id, result }) + } catch (err) { + parentPort.postMessage({ id, error: err.message }) + } + }) + ` + + this._filename = path.resolve('./src/.tmp-worker-shared.mjs') + writeFileSync(this._filename, workerScript) + + this._sharedWorker = new Worker(this._filename, { + type: 'module', + workerData: { + dbName: dbFile, + tableConfigs: tableConfigs + } + }) + + this._sharedWorker.on('message', ({ id, result, error }) => { + const cb = this._sharedCallbacks.get(id) + if (!cb) return + const { resolve, reject } = cb + this._sharedCallbacks.delete(id) + if (error) reject(new Error(error)) + else resolve(result) + }) + } + + static async terminate() { + if (this._sharedWorker) { + await unlink(this._filename) + await this._sharedWorker.terminate() + this._sharedWorker = null + } + } + + constructor(tableName) { + this.tableName = tableName + + for (const methodName of getClassMethodNames(Table)) { + this[methodName] = async (...args) => { + return AsyncTableWrapper._call(this.tableName, methodName, args) + } + } + } + + static _call(tableName, method, args) { + const id = ++this._messageId + return new Promise((resolve, reject) => { + this._sharedCallbacks.set(id, { resolve, reject }) + this._sharedWorker.postMessage({ id, tableName, method, args }) + }) + } +} diff --git a/src/lib/db/Database.js b/src/lib/db/Database.js new file mode 100644 index 0000000..614870f --- /dev/null +++ b/src/lib/db/Database.js @@ -0,0 +1,48 @@ +import openDB from 'better-sqlite3' + +export class Database { + + /** + * @param {string} filename Имя файла базы данных + */ + constructor (filename) { + this.filename = filename + this.db = openDB(`db/${filename}.sqlite`) + } + + /** + * @param {Object} tableConfig + * @returns {boolean} + */ + createTable (tableConfig) { + try { + this.db.exec(` + CREATE TABLE IF NOT EXISTS ${tableConfig.name} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ${Object.entries(tableConfig.columns).map( + ([name, type]) => name + ' ' + type).join(', ') + } + ) + `) + return true + } catch (e) { + console.error("Проблема при создании таблицы :", e) + throw e + } + } + + /** + * @param {string} tableName + * @returns {boolean} + */ + deleteTable(tableName) { + try { + this.db.exec(`DROP TABLE IF EXISTS ${tableName}`) + return true + } catch (e) { + console.error("Проблема при удалении таблицы :", e) + throw e + } + } + +} \ No newline at end of file diff --git a/src/lib/db/Table.js b/src/lib/db/Table.js new file mode 100644 index 0000000..9ce6751 --- /dev/null +++ b/src/lib/db/Table.js @@ -0,0 +1,248 @@ +import { Database } from "./Database.js" + +function sanitizeValue(value) { + if (value === undefined) return null + if (typeof value === 'boolean') return value ? 1 : 0 + if (typeof value === 'object' && value !== null) return JSON.stringify(value) + return value +} + +export class Table { + /** + * @param {Database} db + * @param {Object} tableConfig + */ + constructor(db, tableConfig) { + this.database = db + this.tableName = tableConfig.name + this.columns = Object.entries(tableConfig.columns).map(([name, _]) => name) + + db.createTable(tableConfig) + + // Подготовленные запросы + this.getByIdStmt = db.db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`) + this.getAllStmt = db.db.prepare(`SELECT * FROM ${this.tableName}`) + this.delByIdStmt = db.db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`) + + this.addStmt = db.db.prepare( + `INSERT INTO ${this.tableName} (${this.columns.join(', ')}) VALUES (${Array(this.columns.length).fill('?').join(', ')})` + ) + + const setColumns = this.columns.filter(col => col !== 'id').map(col => `${col} = ?`).join(', ') + this.updateByIdStmt = db.db.prepare(`UPDATE ${this.tableName} SET ${setColumns} WHERE id = ?`) + + // Для динамических запросов по полям + this.getByFieldStmts = new Map() + this.getAllByFieldStmts = new Map() + } + + // Получение по id + getById(id) { + try { + return this.getByIdStmt.get(id) + } catch (e) { + console.error("Ошибка при getById:", e) + throw e + } + } + + // Получение всех записей + getAll() { + try { + return this.getAllStmt.all() + } catch (e) { + console.error("Ошибка при getAll:", e) + throw e + } + } + + // Добавление одной записи + add(data) { + try { + if (Array.isArray(data)) { + const sanitized = data.map(sanitizeValue) + this.addStmt.run(sanitized) + return true + } else if (typeof data === 'object' && data) { + const dataArr = this.columns.map(key => sanitizeValue(data[key])) + this.addStmt.run(dataArr) + return true + } + throw new Error("Некорректный аргумент для add") + } catch (e) { + console.error("Ошибка при вставке:", e) + throw e + } + } + + // Добавление многих записей в транзакции + addMany(dataArr) { + this.database.db.exec('BEGIN TRANSACTION') + try { + for (const data of dataArr) { + if (Array.isArray(data)) { + const sanitized = data.map(sanitizeValue) + this.addStmt.run(sanitized) + } else if (typeof data === 'object' && data) { + const arr = this.columns.map(key => sanitizeValue(data[key])) + this.addStmt.run(arr) + } else { + throw new Error("Некорректный аргумент для addMany") + } + } + this.database.db.exec('COMMIT') + return true + } catch (e) { + this.database.db.exec('ROLLBACK') + console.error('Ошибка при множественной вставке:', e.message) + throw e + } + } + + // Обновление по id + updateById(id, data) { + try { + if (typeof data !== 'object' || data === null) { + throw new Error("Некорректный аргумент для updateById") + } + const updateColumns = this.columns.filter(col => col !== 'id') + const values = updateColumns.map(col => sanitizeValue(data[col])) + values.push(id) + this.updateByIdStmt.run(values) + return true + } catch (e) { + console.error("Ошибка при updateById:", e) + throw e + } + } + + // Множественное обновление по id в транзакции + updateManyById(dataArr) { + this.database.db.exec('BEGIN TRANSACTION') + try { + for (const data of dataArr) { + if (!data || typeof data !== 'object' || data.id === undefined) { + throw new Error("Каждый объект должен содержать id для updateManyById") + } + this.updateById(data.id, data) + } + this.database.db.exec('COMMIT') + return true + } catch (e) { + this.database.db.exec('ROLLBACK') + console.error("Ошибка при множественном обновлении:", e.message) + throw e + } + } + + // Удаление по id + delById(id) { + try { + this.delByIdStmt.run(id) + return true + } catch (e) { + console.error("Ошибка при delById:", e) + throw e + } + } + + // Множественное удаление по id в транзакции + delManyByIds(ids) { + this.database.db.exec('BEGIN TRANSACTION') + try { + for (const id of ids) { + try { + this.delByIdStmt.run(id) + } catch (e) { + console.error('Ошибка при удалении id:', id, e.message) + } + } + this.database.db.exec('COMMIT') + return true + } catch (e) { + this.database.db.exec('ROLLBACK') + console.error('Ошибка при множественном удалении:', e.message) + throw e + } + } + + // Получение одной записи по полю + getBy(field, value) { + try { + if (!this.getByFieldStmts.has(field)) { + const stmt = this.database.db.prepare( + `SELECT * FROM ${this.tableName} WHERE ${field} = ? LIMIT 1` + ) + this.getByFieldStmts.set(field, stmt) + } + const stmt = this.getByFieldStmts.get(field) + return stmt.get(value) + } catch (e) { + console.error(`Ошибка при getBy(${field}):`, e) + throw e + } + } + + // Получение всех записей по полю + getAllBy(field, value) { + try { + if (!this.getAllByFieldStmts.has(field)) { + const stmt = this.database.db.prepare( + `SELECT * FROM ${this.tableName} WHERE ${field} = ?` + ) + this.getAllByFieldStmts.set(field, stmt) + } + const stmt = this.getAllByFieldStmts.get(field) + return stmt.all(value) + } catch (e) { + console.error(`Ошибка при getAllBy(${field}):`, e) + throw e + } + } + + // Получение по множеству условий (AND) + getByFields(fieldsObj) { + try { + const keys = Object.keys(fieldsObj) + if (keys.length === 0) throw new Error("Пустой объект условий") + + const whereClause = keys.map(k => `${k} = ?`).join(' AND ') + const values = keys.map(k => fieldsObj[k]) + + const stmt = this.database.db.prepare( + `SELECT * FROM ${this.tableName} WHERE ${whereClause}` + ) + return stmt.all(values) + } catch (e) { + console.error("Ошибка при getByFields:", e) + throw e + } + } + + // Получить первую запись (по id ASC) + getFirst() { + try { + const stmt = this.database.db.prepare( + `SELECT * FROM ${this.tableName} ORDER BY id ASC LIMIT 1` + ) + return stmt.get() + } catch (e) { + console.error("Ошибка при getFirst:", e) + throw e + } + } + + // Получить последнюю запись (по id DESC) + getLast() { + try { + const stmt = this.database.db.prepare( + `SELECT * FROM ${this.tableName} ORDER BY id DESC LIMIT 1` + ) + return stmt.get() + } catch (e) { + console.error("Ошибка при getLast:", e) + throw e + } + } + +} diff --git a/src/lib/genKey.js b/src/lib/genKey.js new file mode 100644 index 0000000..df0a250 --- /dev/null +++ b/src/lib/genKey.js @@ -0,0 +1,16 @@ +import { generateKeyPairSync } from 'crypto'; + +export function genRsaKey(size = 2048) { + const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: size, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem' + } + }); + return {privateKey, publicKey} +} \ No newline at end of file diff --git a/src/lib/sec.js b/src/lib/sec.js new file mode 100644 index 0000000..c5b1106 --- /dev/null +++ b/src/lib/sec.js @@ -0,0 +1,24 @@ +import crypto from 'crypto'; + +export function encrypt(text, key) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + + const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, tag, encrypted]).toString('base64'); +} + +export function decrypt(data, key) { + const bData = Buffer.from(data, 'base64'); + const iv = bData.slice(0, 16); + const tag = bData.slice(16, 32); + const text = bData.slice(32); + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + + const decrypted = decipher.update(text) + decipher.final('utf8'); + return decrypted; +} diff --git a/src/lib/util.js b/src/lib/util.js new file mode 100644 index 0000000..511af86 --- /dev/null +++ b/src/lib/util.js @@ -0,0 +1,4 @@ +export function getClassMethodNames(klass) { + return Object.getOwnPropertyNames(klass.prototype) + .filter(name => typeof klass.prototype[name] === 'function' && name !== 'constructor') +} \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..cb588cf --- /dev/null +++ b/src/server.js @@ -0,0 +1,157 @@ +import fs from 'fs'; +import express from 'express' +import { AsyncTableWrapper } from "./lib/db/AsyncTableWrap.js" +import { encrypt, decrypt } from './lib/sec.js'; +import { genRsaKey } from './lib/genKey.js'; +import { randomUUID } from 'crypto'; +import cron from 'node-cron' + +const KEY = fs.readFileSync('./keys/aes-256.key') +const PORT = 39391 +const TABLE_CONFIG = [ + { + name: "rsa_keys", + columns: { + name: "varchar(64)", + kid: "varchar(64)", + publicKey: "text", + privateKey: "text", + actual: "boolean" + } + }, + { + name: "keys", + columns: { + name: "varchar(64)", + updateInterval: "integer" //в часах + } + } +] + +AsyncTableWrapper._initOnce('db', TABLE_CONFIG) + +const app = express() +let keyTable, rsaTable + +async function updateKey (name) { + + const keys = await rsaTable.getAllBy("name", name) + + if(keys.length >= 1) { + const key = keys.at(-1) + key.actual = false + await rsaTable.updateById(key.id, key) + } + + const {privateKey, publicKey} = genRsaKey() + const kid = randomUUID() + + console.log("Обновение ключа \"" + name + "\" c kid: " + kid) + + await rsaTable.add({ + name, + kid, + publicKey: encrypt(publicKey, KEY), + privateKey: encrypt(privateKey, KEY), + actual: true + }) +} + +function autoUpdate (name, update) { + cron.schedule(`0 */${update} * * *`, () => updateKey(name)) + // cron.schedule(`*/1 * * * *`, () => updateKey(name)) +} + +app.use(express.json()) +app.use(express.urlencoded({ extended: true })) + +app.post("/reg-key", async (req, res) => { + const {name, update} = req.body + + if (!name || !update || isNaN(Number(update))) { + return res.status(400).json({ ошибка: "Неверные параметры" }); + } + + const record = await keyTable.getBy("name", name) + + if(record) + return res.status(409).json({ошибка: "Запись существует"}) + + await keyTable.add({name, updateInterval: update}) + updateKey(name) + + autoUpdate(name, update) + + return res.json({статус: "Запись добавлена"}) +}) + +app.get("/get-key", async (req, res) => { + const {name, kid = null} = req.query + + if(!(await keyTable.getBy("name", name))) + return res.status(401).json({ошибка: "Запись не найдена"}) + + if(kid) { + const key = await rsaTable.getBy("kid", kid) + if(key) + return res.json({ + publicKey: decrypt(key.publicKey, KEY), + privateKey: decrypt(key.privateKey, KEY) + }) + return res.status(401).json({ошибка: "Запись не найдена по kid"}) + } + + const key = (await rsaTable.getAllBy("name", name)).at(-1) + + return res.json({ + publicKey: decrypt(key.publicKey, KEY), + privateKey: decrypt(key.privateKey, KEY) + }) +}) + +async function recaveryCron () { + console.log("Восстановление расписаний") + const keys = await keyTable.getAll() + keys.forEach(key => { + autoUpdate(key.name, key.updateInterval) + console.log("Запись \"" + key.name + "\" возобновлена") + }); +} + +const server = app.listen(PORT, async error => { + rsaTable = new AsyncTableWrapper('rsa_keys') + keyTable = new AsyncTableWrapper('keys') + console.log("Сервер стартовал") + + recaveryCron() + + if (error) console.log("Error: " + error) +}) + + + +const shutdown = async () => { + console.log('Завершение работы...') + + await AsyncTableWrapper.terminate() + .then(() => { + console.log("Закрытие соединения с бд") + }) + + server.close(() => { + console.log('Cервер остановлен') + process.exit(0) + }) + + setTimeout(() => { + console.error('Принудительное завершение...') + process.exit(1) + }, 2000) +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) +process.on('uncaughtException', err => { + console.error('Непойманное исключение:', err) + shutdown() +}) \ No newline at end of file