@vqp/data-encrypted
Version:
Encrypted data adapter for VQP
314 lines • 12.9 kB
JavaScript
/**
* 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