UNPKG

@vqp/data-encrypted

Version:

Encrypted data adapter for VQP

314 lines 12.9 kB
/** * Encrypted Data Adapter - Implements DataAccessPort with transparent encryption * This adapter provides AES-256-GCM encryption for vault data storage */ import { createCipheriv, createDecipheriv, randomBytes, createHash, pbkdf2Sync, } from 'node:crypto'; import { promises as fs } from 'fs'; export class EncryptedDataAdapter { config; static ALGORITHM = 'aes-256-gcm'; static DEFAULT_ITERATIONS = 100000; static DEFAULT_KEY_LENGTH = 32; static VAULT_VERSION = '1.0.0'; dataCache = {}; policiesCache = null; encryptionKey = null; accessCounts = new Map(); constructor(config) { this.config = config; if (!config.encryptionKey) { throw new Error('Encryption key is required for EncryptedDataAdapter'); } this.initializeEncryptionKey(); } async getData(path) { const cacheKey = path.join('.'); // Return cached data if available and caching is enabled if (this.config.cacheEnabled !== false && this.dataCache[cacheKey]) { return this.dataCache[cacheKey]; } const vault = await this.loadVault(); const data = this.extractNestedData(vault, path); // Cache the result if caching is enabled if (this.config.cacheEnabled !== false) { this.dataCache[cacheKey] = data; } return data; } async validateDataAccess(path, requester) { // Check rate limiting first if (!this.checkRateLimit(requester)) { return false; } const policies = await this.loadPolicies(); // If no policies file, use default policy or allow access if (!policies) { return true; // Default to allow in development mode } // Apply default policy if specified if (policies.default_policy === 'deny' && !this.hasExplicitAccess(path, requester, policies)) { return false; } const pathString = path.join('.'); // Check exact path match if (policies.allowed_paths && policies.allowed_paths[pathString]) { const allowedRequesters = policies.allowed_paths[pathString]; return allowedRequesters?.includes('*') || allowedRequesters?.includes(requester) || false; } // Check wildcard matches if (policies.wildcard_paths) { for (const [pattern, allowedRequesters] of Object.entries(policies.wildcard_paths)) { if (this.matchesWildcard(pathString, pattern)) { return allowedRequesters.includes('*') || allowedRequesters.includes(requester); } } } // Default to allow if no explicit deny policy return policies.default_policy !== 'deny'; } async hasData(path) { try { const data = await this.getData(path); return data !== undefined && data !== null; } catch { return false; } } /** * Encrypts and saves vault data */ async saveVault(data) { if (!this.encryptionKey) { throw new Error('Encryption key not initialized'); } const serializedData = JSON.stringify(data); const checksum = createHash('sha256').update(serializedData, 'utf8').digest('hex'); // Generate random IV for this encryption const iv = randomBytes(16); const cipher = createCipheriv(EncryptedDataAdapter.ALGORITHM, this.encryptionKey, iv); let encrypted = cipher.update(serializedData, 'utf8', 'base64'); encrypted += cipher.final('base64'); const authTag = cipher.getAuthTag(); const vaultStructure = { version: EncryptedDataAdapter.VAULT_VERSION, algorithm: EncryptedDataAdapter.ALGORITHM, keyDerivation: { iterations: this.config.keyDerivation?.iterations || EncryptedDataAdapter.DEFAULT_ITERATIONS, salt: this.config.keyDerivation?.salt || '', keyLength: this.config.keyDerivation?.keyLength || EncryptedDataAdapter.DEFAULT_KEY_LENGTH, }, encryptedData: encrypted, iv: iv.toString('base64'), authTag: authTag.toString('base64'), timestamp: new Date().toISOString(), checksum, }; await fs.writeFile(this.config.vaultPath, JSON.stringify(vaultStructure, null, 2), 'utf8'); // Clear cache to force reload this.dataCache = {}; } /** * Clears the data cache */ clearCache() { this.dataCache = {}; this.policiesCache = null; } /** * Updates the encryption key and re-encrypts all data */ async rotateEncryptionKey(newEncryptionKey) { // Load current data with old key const currentData = await this.loadVault(); // Update encryption key this.config.encryptionKey = newEncryptionKey; this.initializeEncryptionKey(); // Re-encrypt with new key await this.saveVault(currentData); } initializeEncryptionKey() { if (!this.config.encryptionKey) { throw new Error('Encryption key is required'); } const keyDerivation = this.config.keyDerivation; const iterations = keyDerivation?.iterations || EncryptedDataAdapter.DEFAULT_ITERATIONS; const keyLength = keyDerivation?.keyLength || EncryptedDataAdapter.DEFAULT_KEY_LENGTH; let salt = keyDerivation?.salt; // Generate salt if not provided if (!salt) { salt = randomBytes(16).toString('hex'); // Update config with generated salt for future use if (!this.config.keyDerivation) { this.config.keyDerivation = { iterations, keyLength, salt, }; } else { this.config.keyDerivation.salt = salt; } } // Derive encryption key using PBKDF2 this.encryptionKey = pbkdf2Sync(this.config.encryptionKey, salt, iterations, keyLength, 'sha256'); } async loadVault() { try { const fileContent = await fs.readFile(this.config.vaultPath, 'utf8'); // Try to parse as encrypted vault first try { const vaultStructure = JSON.parse(fileContent); // Check if it's an encrypted vault by looking for our specific structure if (vaultStructure.version && vaultStructure.encryptedData && vaultStructure.algorithm) { return this.decryptVaultData(vaultStructure); } } catch (parseError) { // If JSON parsing failed, it's likely corrupted throw new Error(`Failed to parse vault file: ${parseError.message}`); } // If not encrypted, treat as plain JSON and auto-encrypt it const plainData = JSON.parse(fileContent); // Auto-encrypt plain JSON vaults for security await this.saveVault(plainData); return plainData; } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Vault file not found: ${this.config.vaultPath}`); } throw new Error(`Failed to load vault: ${error.message}`); } } decryptVaultData(vaultStructure) { if (!this.encryptionKey) { throw new Error('Encryption key not initialized'); } // Verify version compatibility if (vaultStructure.version !== EncryptedDataAdapter.VAULT_VERSION) { throw new Error(`Unsupported vault version: ${vaultStructure.version}`); } try { const iv = Buffer.from(vaultStructure.iv, 'base64'); const authTag = Buffer.from(vaultStructure.authTag, 'base64'); const decipher = createDecipheriv(vaultStructure.algorithm, this.encryptionKey, iv); decipher.setAuthTag(authTag); let decrypted; try { decrypted = decipher.update(vaultStructure.encryptedData, 'base64', 'utf8'); decrypted += decipher.final('utf8'); } catch (decryptError) { // This typically means wrong key or corrupted data if (decryptError.message.includes('bad decrypt')) { throw new Error('Failed to decrypt vault: Invalid encryption key or corrupted data'); } throw new Error(`Failed to decrypt vault: ${decryptError.message}`); } let data; try { data = JSON.parse(decrypted); } catch (jsonError) { throw new Error(`Failed to parse decrypted data: ${jsonError.message}`); } // Verify checksum if provided if (vaultStructure.checksum) { const actualChecksum = createHash('sha256').update(decrypted, 'utf8').digest('hex'); if (actualChecksum !== vaultStructure.checksum) { throw new Error('Data integrity check failed - checksum mismatch'); } } return data; } catch (error) { // Re-throw our custom error messages, or wrap others if (error.message.startsWith('Failed to decrypt vault:') || error.message.startsWith('Data integrity check failed') || error.message.startsWith('Failed to parse decrypted data:')) { throw error; } throw new Error(`Failed to decrypt vault: ${error.message}`); } } async loadPolicies() { if (this.policiesCache) { return this.policiesCache; } if (!this.config.policiesPath) { return null; } try { const content = await fs.readFile(this.config.policiesPath, 'utf8'); this.policiesCache = JSON.parse(content); return this.policiesCache; } catch (error) { if (error.code === 'ENOENT') { // Policies file doesn't exist, return null return null; } throw new Error(`Failed to load policies: ${error.message}`); } } extractNestedData(data, path) { let current = data; for (const segment of path) { if (current === null || current === undefined) { return undefined; } if (typeof current !== 'object') { return undefined; } current = current[segment]; } return current; } hasExplicitAccess(path, requester, policies) { const pathString = path.join('.'); // Check exact path if (policies.allowed_paths && policies.allowed_paths[pathString]) { const allowedRequesters = policies.allowed_paths[pathString]; return allowedRequesters?.includes('*') || allowedRequesters?.includes(requester) || false; } // Check wildcards if (policies.wildcard_paths) { for (const [pattern, allowedRequesters] of Object.entries(policies.wildcard_paths)) { if (this.matchesWildcard(pathString, pattern)) { return allowedRequesters.includes('*') || allowedRequesters.includes(requester); } } } return false; } matchesWildcard(path, pattern) { // Convert glob pattern to regex const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$'); return regex.test(path); } checkRateLimit(requester) { const policies = this.policiesCache; if (!policies || !policies.rate_limits || !policies.rate_limits[requester]) { return true; // No rate limits defined } const limits = policies.rate_limits[requester]; const now = Date.now(); const accessData = this.accessCounts.get(requester) || { count: 0, lastReset: now }; // Reset counter if more than a minute has passed if (now - accessData.lastReset > 60000) { accessData.count = 0; accessData.lastReset = now; } accessData.count++; this.accessCounts.set(requester, accessData); // Check per-minute limit if (limits?.requests_per_minute && accessData.count > limits.requests_per_minute) { return false; } // For hourly limits, we'd need a more sophisticated tracking mechanism // This is a simplified implementation return true; } } //# sourceMappingURL=encrypted-adapter.js.map