UNPKG

qala

Version:

Discrete Node.js solution for secure access to encrypted data, using ephemeral tokens.

657 lines (560 loc) 16.9 kB
/** * Qalá v0.8.7 * A discrete secure environment variables and secrets keeper. * * Features: * - Encrypted storage of sensitive data * - JWT-based authentication * - ECC-based secure communication * - Supports standalone, integrated, or env mode * * @license GNU GPLv3 */ const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const express = require('express'); const http = require('http'); const https = require('https'); const jwt = require('jsonwebtoken'); // Constants const TOKEN_LIFETIME = 3600; // 1 hour in seconds const DEFAULT_DATA_PATH = path.join(process.cwd(), './data.json'); const DEFAULT_PORT = 3000; /** * Crypto utility functions */ const Crypto = { // Generate ECDH keypair using secp256k1 curve generateKeyPair() { const ecdh = crypto.createECDH('secp256k1'); ecdh.generateKeys(); return { privateKey: ecdh.getPrivateKey('hex'), publicKey: ecdh.getPublicKey('hex') }; }, // Derive shared secret using ECDH deriveSharedSecret(privateKey, otherPublicKey) { const ecdh = crypto.createECDH('secp256k1'); ecdh.setPrivateKey(Buffer.from(privateKey, 'hex')); return ecdh.computeSecret(Buffer.from(otherPublicKey, 'hex')); }, // Derive encryption key using HKDF deriveKey(sharedSecret) { return crypto.createHash('sha256').update(sharedSecret).digest(); }, // Encrypt data using AES-256-GCM encrypt(data, key) { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); let encrypted = cipher.update(typeof data === 'string' ? data : JSON.stringify(data), 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); return { iv: iv.toString('hex'), encrypted, authTag: authTag.toString('hex') }; }, // Decrypt data using AES-256-GCM decrypt(encryptedData, key) { const iv = Buffer.from(encryptedData.iv, 'hex'); const authTag = Buffer.from(encryptedData.authTag, 'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); try { return JSON.parse(decrypted); } catch (e) { return decrypted; } }, // Generate a random token generateToken() { return crypto.randomBytes(32).toString('hex'); }, // Create JWT token createToken(payload, secret) { return jwt.sign(payload, secret, { expiresIn: TOKEN_LIFETIME }); }, // Verify JWT token verifyToken(token, secret) { try { return jwt.verify(token, secret); } catch (err) { return null; } } }; /** * Qala Server */ class QalaServer { /** * Constructor for Qala Server * @param {Object} options - Configuration options */ constructor(options = {}) { this.options = { mode: options.mode || 'standalone', port: options.port || DEFAULT_PORT, securityLevel: options.securityLevel || 'prod', dataPath: options.dataPath || DEFAULT_DATA_PATH, accessSecret: options.accessSecret || Crypto.generateToken(), server: options.server || null, httpsOptions: options.httpsOptions || null }; this.sessions = {}; this.data = null; this.encryptedDataPath = this.options.dataPath.replace('.json', '.enc.json'); this.keys = Crypto.generateKeyPair(); this.app = null; this.httpServer = null; } /** * Initialize the server */ async init() { try { await this.loadData(); if (this.options.mode === 'standalone') { this.app = express(); this.setupMiddleware(this.app); this.setupRoutes(this.app); this.startServer(); } else { // Integrated mode if (!this.options.server) { throw new Error('Server instance required for integrated mode'); } this.setupMiddleware(this.options.server); this.setupRoutes(this.options.server); } return true; } catch (error) { console.error('Qala initialization failed:', error); throw error; } } /** * Load data from file */ async loadData() { try { // Try to load the original data file const dataExists = await this.fileExists(this.options.dataPath); if (dataExists) { const content = await fs.readFile(this.options.dataPath, 'utf8'); this.data = JSON.parse(content); // Encrypt the data const encryptionKey = Crypto.generateToken(); const encryptedData = Crypto.encrypt(this.data, Buffer.from(encryptionKey, 'hex')); // Store the encryption key with the encrypted data const savedData = { key: encryptionKey, data: encryptedData }; await fs.writeFile(this.encryptedDataPath, JSON.stringify(savedData), 'utf8'); // Delete original file in production mode if (this.options.securityLevel !== 'dev') { await fs.unlink(this.options.dataPath); } } else { // Load from encrypted file const encryptedContent = await fs.readFile(this.encryptedDataPath, 'utf8'); const savedData = JSON.parse(encryptedContent); this.data = Crypto.decrypt(savedData.data, Buffer.from(savedData.key, 'hex')); } } catch (error) { console.error('Error loading data:', error); // Initialize with empty data if file doesn't exist this.data = {}; } } /** * Check if file exists * @param {string} filePath - Path to check */ async fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } /** * Setup middleware * @param {Object} app - Express app */ setupMiddleware(app) { app.use(express.json()); } /** * Setup routes * @param {Object} app - Express app */ setupRoutes(app) { // Handshake route app.post('/meet', (req, res) => { const { accessSecret, publicKey } = req.body; if (accessSecret !== this.options.accessSecret) { return res.status(401).json({ error: 'Unauthorized' }); } const clientIp = req.ip || '0.0.0.0'; const sessionId = Crypto.generateToken(); const token = Crypto.createToken({ sessionId, ip: clientIp }, this.options.accessSecret); this.sessions[sessionId] = { publicKey, ip: clientIp, createdAt: Date.now() }; return res.json({ token, publicKey: this.keys.publicKey }); }); // Data route app.post('/data', (req, res) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Token required' }); } const token = authHeader.slice(7); const decoded = Crypto.verifyToken(token, this.options.accessSecret); if (!decoded) { return res.status(401).json({ error: 'Invalid token' }); } const session = this.sessions[decoded.sessionId]; if (!session) { return res.status(401).json({ error: 'Invalid session' }); } const clientIp = req.ip || '0.0.0.0'; if (decoded.ip !== clientIp) { return res.status(401).json({ error: 'IP mismatch' }); } const sharedSecret = Crypto.deriveSharedSecret(this.keys.privateKey, session.publicKey); const encryptionKey = Crypto.deriveKey(sharedSecret); try { const encryptedRequest = req.body; const { key } = Crypto.decrypt(encryptedRequest, encryptionKey); if (!key || !this.data[key]) { const response = { error: 'Key not found' }; const encryptedResponse = Crypto.encrypt(response, encryptionKey); return res.status(404).json(encryptedResponse); } const response = { value: this.data[key] }; const encryptedResponse = Crypto.encrypt(response, encryptionKey); return res.json(encryptedResponse); } catch (error) { console.error('Error processing data request:', error); const response = { error: 'Invalid request format' }; const encryptedResponse = Crypto.encrypt(response, encryptionKey); return res.status(400).json(encryptedResponse); } }); // Renew token route app.post('/renew', (req, res) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Token required' }); } const token = authHeader.slice(7); const decoded = Crypto.verifyToken(token, this.options.accessSecret); if (!decoded) { return res.status(401).json({ error: 'Invalid token' }); } const session = this.sessions[decoded.sessionId]; if (!session) { return res.status(401).json({ error: 'Invalid session' }); } const clientIp = req.ip || '0.0.0.0'; if (decoded.ip !== clientIp) { return res.status(401).json({ error: 'IP mismatch' }); } // Create a new token const newToken = Crypto.createToken({ sessionId: decoded.sessionId, ip: clientIp }, this.options.accessSecret); return res.json({ token: newToken }); }); } /** * Start server (standalone mode) */ startServer() { const { port, httpsOptions } = this.options; if (httpsOptions) { const key = fs.readFileSync(httpsOptions.key); const cert = fs.readFileSync(httpsOptions.cert); this.httpServer = https.createServer({ key, cert }, this.app); } else { this.httpServer = http.createServer(this.app); } this.httpServer.listen(port, () => { console.log(`Qala server running on port ${port}`); }); } /** * Stop server */ stop() { if (this.httpServer) { this.httpServer.close(); } } } /** * Qala Client */ class QalaClient { /** * Constructor for Qala Client * @param {Object} options - Configuration options */ constructor(options = {}) { this.options = { serverUrl: options.serverUrl || 'http://localhost:' + DEFAULT_PORT, accessSecret: options.accessSecret }; this.token = null; this.keys = Crypto.generateKeyPair(); this.serverPublicKey = null; this.sharedSecret = null; this.encryptionKey = null; this.cache = {}; } /** * Initialize connection with server */ async connect() { try { const response = await this.request('/meet', { accessSecret: this.options.accessSecret, publicKey: this.keys.publicKey }); this.token = response.token; this.serverPublicKey = response.publicKey; this.sharedSecret = Crypto.deriveSharedSecret(this.keys.privateKey, this.serverPublicKey); this.encryptionKey = Crypto.deriveKey(this.sharedSecret); return true; } catch (error) { console.error('Connection failed:', error); throw error; } } /** * Get value from server * @param {string} key - Key to retrieve */ async get(key) { if (!this.token || !this.encryptionKey) { throw new Error('Not connected. Call connect() first'); } // Check cache first if (this.cache[key]) { return this.cache[key]; } try { const encryptedRequest = Crypto.encrypt({ key }, this.encryptionKey); const encryptedResponse = await this.request('/data', encryptedRequest, true); const response = Crypto.decrypt(encryptedResponse, this.encryptionKey); if (response.error) { throw new Error(response.error); } // Cache the result this.cache[key] = response.value; return response.value; } catch (error) { console.error('Get operation failed:', error); throw error; } } /** * Renew token */ async renewToken() { if (!this.token) { throw new Error('Not connected. Call connect() first'); } try { const response = await this.request('/renew', {}, true); this.token = response.token; return true; } catch (error) { console.error('Token renewal failed:', error); throw error; } } /** * Make HTTP request to server * @param {string} endpoint - API endpoint * @param {Object} data - Request data * @param {boolean} authenticated - Whether to include auth token */ async request(endpoint, data, authenticated = false) { const url = `${this.options.serverUrl}${endpoint}`; const headers = { 'Content-Type': 'application/json' }; if (authenticated && this.token) { headers['Authorization'] = `Bearer ${this.token}`; } try { const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Request failed'); } return await response.json(); } catch (error) { console.error(`Request to ${endpoint} failed:`, error); throw error; } } } /** * Qala Environment Mode * Transparently syncs with server and provides values through process.env */ class QalaEnv { constructor() { this.server = null; this.client = null; this.accessSecret = Crypto.generateToken(); this.initialized = false; this.dataPath = DEFAULT_DATA_PATH; this.port = DEFAULT_PORT; this.isInitializing = false; this.envProxy = null; } /** * Initialize the environment mode */ async init() { if (this.initialized || this.isInitializing) { return; } this.isInitializing = true; try { // Start server this.server = new QalaServer({ mode: 'standalone', port: this.port, dataPath: this.dataPath, accessSecret: this.accessSecret }); await this.server.init(); // Initialize client this.client = new QalaClient({ serverUrl: `http://localhost:${this.port}`, accessSecret: this.accessSecret }); await this.client.connect(); // Setup environment proxy this.setupEnvProxy(); this.initialized = true; this.isInitializing = false; return true; } catch (error) { this.isInitializing = false; console.error('Failed to initialize Qala ENV mode:', error); throw error; } } /** * Setup proxy for process.env */ setupEnvProxy() { const originalEnv = process.env; const client = this.client; // Create proxy for process.env this.envProxy = new Proxy(originalEnv, { get: function(target, prop) { // If it's a built-in property or method, return it if (prop in target) { return target[prop]; } // Otherwise try to get from Qala try { // Get the value asynchronously, but return promise result synchronously const value = client.cache[prop]; if (value !== undefined) { return value; } // Start async fetch but don't block client.get(prop).then(value => { // Value will be cached by client.get }).catch(err => { // Silently fail }); return undefined; } catch (error) { return undefined; } } }); // Replace process.env with our proxy process.env = this.envProxy; } /** * Set default data path * @param {string} path - Path to data file */ setDataPath(path) { if (!this.initialized) { this.dataPath = path; } return this; } /** * Set port for server * @param {number} port - Port number */ setPort(port) { if (!this.initialized) { this.port = port; } return this; } } // Singleton instance for ENV mode const qalaEnvInstance = new QalaEnv(); /** * Main Qala class - Factory for client and server */ class Qala { /** * Create a Qala server * @param {Object} options - Server options */ static guard(options = {}) { return new QalaServer(options); } /** * Create a Qala client * @param {Object} options - Client options */ static engage(options = {}) { return new QalaClient(options); } /** * Initialize Qala in ENV mode * @param {Object} options - Optional configuration * @return {Promise} Resolves when initialization is complete */ static async init(options = {}) { if (options.dataPath) { qalaEnvInstance.setDataPath(options.dataPath); } if (options.port) { qalaEnvInstance.setPort(options.port); } return qalaEnvInstance.init(); } } module.exports = Qala;