codevault
Version:
AI-powered semantic code search via Model Context Protocol
237 lines • 9.09 kB
JavaScript
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import zlib from 'zlib';
const KEY_ENV_VAR = 'CODEVAULT_ENCRYPTION_KEY';
const MAGIC_HEADER = Buffer.from('CVAULTE1', 'utf8');
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const HKDF_INFO = Buffer.from('codevault-chunk-v1', 'utf8');
let cachedNormalizedKey = null;
let cachedKeyBuffer = null;
let cachedKeyError = null;
function decodeKey(raw) {
if (typeof raw !== 'string') {
return null;
}
const trimmed = raw.trim();
if (trimmed.length === 0) {
return null;
}
try {
const base64 = Buffer.from(trimmed, 'base64');
if (base64.length === 32) {
return base64;
}
}
catch (error) {
// Ignore decoding errors
}
try {
const hex = Buffer.from(trimmed, 'hex');
if (hex.length === 32) {
return hex;
}
}
catch (error) {
// Ignore decoding errors
}
return null;
}
export function resetEncryptionCacheForTests() {
cachedNormalizedKey = null;
cachedKeyBuffer = null;
cachedKeyError = null;
}
export function getActiveEncryptionKey() {
const raw = process.env[KEY_ENV_VAR];
const normalized = typeof raw === 'string' ? raw.trim() : '';
// FIX: Always check if env var changed to support runtime updates
if (normalized === cachedNormalizedKey && cachedKeyBuffer) {
return cachedKeyBuffer;
}
// Invalidate cache when environment variable changes
cachedNormalizedKey = normalized;
cachedKeyError = null;
cachedKeyBuffer = null;
if (!normalized) {
return null;
}
const decoded = decodeKey(normalized);
if (!decoded) {
cachedKeyError = new Error(`${KEY_ENV_VAR} must be a 32-byte key encoded as base64 or hex.`);
return null;
}
cachedKeyBuffer = decoded;
return cachedKeyBuffer;
}
export function getEncryptionKeyError() {
return cachedKeyError;
}
const warnedInvalidMode = new Set();
let warnedInvalidKey = false;
function normalizeMode(mode) {
if (typeof mode !== 'string') {
return undefined;
}
return mode.trim().toLowerCase();
}
export function resolveEncryptionPreference({ mode, logger = console } = {}) {
const normalizedMode = normalizeMode(mode);
if (normalizedMode && normalizedMode !== 'on' && normalizedMode !== 'off' && !warnedInvalidMode.has(normalizedMode)) {
if (logger && typeof logger.warn === 'function') {
logger.warn(`Unknown --encrypt mode "${mode}". Expected "on" or "off". Falling back to environment configuration.`);
}
warnedInvalidMode.add(normalizedMode);
}
if (normalizedMode === 'off') {
return { enabled: false, key: null, reason: 'flag_off' };
}
const key = getActiveEncryptionKey();
if (!key) {
const keyError = getEncryptionKeyError();
if (normalizedMode === 'on') {
throw keyError || new Error('CODEVAULT_ENCRYPTION_KEY is not configured but encryption was requested (--encrypt on).');
}
if (keyError && !warnedInvalidKey && logger && typeof logger.warn === 'function') {
logger.warn(`${keyError.message} Encryption disabled.`);
warnedInvalidKey = true;
}
return { enabled: false, key: null, reason: keyError ? 'invalid_key' : 'missing_key' };
}
return { enabled: true, key, reason: 'enabled' };
}
function deriveChunkKey(masterKey, salt) {
return Buffer.from(crypto.hkdfSync('sha256', masterKey, salt, HKDF_INFO, 32));
}
function encryptBuffer(plaintext, masterKey) {
const salt = crypto.randomBytes(SALT_LENGTH);
const iv = crypto.randomBytes(IV_LENGTH);
const derivedKey = deriveChunkKey(masterKey, salt);
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const payload = Buffer.concat([MAGIC_HEADER, salt, iv, encrypted, tag]);
return { payload, salt, iv, tag };
}
function decryptBuffer(payload, masterKey) {
const minimumLength = MAGIC_HEADER.length + SALT_LENGTH + IV_LENGTH + TAG_LENGTH + 1;
if (!payload || payload.length < minimumLength) {
const error = new Error('Encrypted chunk payload is truncated.');
error.code = 'ENCRYPTION_PAYLOAD_INVALID';
throw error;
}
const header = payload.subarray(0, MAGIC_HEADER.length);
if (!header.equals(MAGIC_HEADER)) {
const error = new Error('Encrypted chunk payload has an unknown header.');
error.code = 'ENCRYPTION_FORMAT_UNRECOGNIZED';
throw error;
}
const saltStart = MAGIC_HEADER.length;
const ivStart = saltStart + SALT_LENGTH;
const cipherStart = ivStart + IV_LENGTH;
const cipherEnd = payload.length - TAG_LENGTH;
const salt = payload.subarray(saltStart, saltStart + SALT_LENGTH);
const iv = payload.subarray(ivStart, ivStart + IV_LENGTH);
const ciphertext = payload.subarray(cipherStart, cipherEnd);
const tag = payload.subarray(cipherEnd);
const derivedKey = deriveChunkKey(masterKey, salt);
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv);
decipher.setAuthTag(tag);
try {
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
catch (error) {
const authError = new Error('authentication failed');
authError.code = 'ENCRYPTION_AUTH_FAILED';
authError.cause = error;
throw authError;
}
}
function getChunkPaths(chunkDir, sha) {
const plainPath = path.join(chunkDir, `${sha}.gz`);
const encryptedPath = path.join(chunkDir, `${sha}.gz.enc`);
return { plainPath, encryptedPath };
}
export function writeChunkToDisk({ chunkDir, sha, code, encryption }) {
const { plainPath, encryptedPath } = getChunkPaths(chunkDir, sha);
const buffer = Buffer.isBuffer(code) ? code : Buffer.from(code, 'utf8');
const compressed = zlib.gzipSync(buffer);
if (encryption && encryption.enabled && encryption.key) {
const { payload } = encryptBuffer(compressed, encryption.key);
fs.writeFileSync(encryptedPath, payload);
if (fs.existsSync(plainPath)) {
fs.rmSync(plainPath, { force: true });
}
return { encrypted: true, path: encryptedPath };
}
fs.writeFileSync(plainPath, compressed);
if (fs.existsSync(encryptedPath)) {
fs.rmSync(encryptedPath, { force: true });
}
return { encrypted: false, path: plainPath };
}
export function readChunkFromDisk({ chunkDir, sha, key = getActiveEncryptionKey() }) {
const { plainPath, encryptedPath } = getChunkPaths(chunkDir, sha);
if (fs.existsSync(encryptedPath)) {
if (!key) {
const error = new Error(`Chunk ${sha} is encrypted and no CODEVAULT_ENCRYPTION_KEY is configured.`);
error.code = 'ENCRYPTION_KEY_REQUIRED';
throw error;
}
const payload = fs.readFileSync(encryptedPath);
let decrypted;
try {
decrypted = decryptBuffer(payload, key);
}
catch (error) {
if (error.code === 'ENCRYPTION_AUTH_FAILED') {
const authError = new Error(`Failed to decrypt chunk ${sha}: authentication failed.`);
authError.code = 'ENCRYPTION_AUTH_FAILED';
authError.cause = error;
throw authError;
}
error.message = `Failed to decrypt chunk ${sha}: ${error.message}`;
throw error;
}
try {
const code = zlib.gunzipSync(decrypted).toString('utf8');
return { code, encrypted: true };
}
catch (error) {
const decompressionError = new Error(`Failed to decompress chunk ${sha}: ${error.message}`);
decompressionError.code = 'CHUNK_DECOMPRESSION_FAILED';
decompressionError.cause = error;
throw decompressionError;
}
}
if (fs.existsSync(plainPath)) {
try {
const compressed = fs.readFileSync(plainPath);
const code = zlib.gunzipSync(compressed).toString('utf8');
return { code, encrypted: false };
}
catch (error) {
const readError = new Error(`Failed to read chunk ${sha}: ${error.message}`);
readError.code = 'CHUNK_READ_FAILED';
readError.cause = error;
throw readError;
}
}
return null;
}
export function removeChunkArtifacts(chunkDir, sha) {
const { plainPath, encryptedPath } = getChunkPaths(chunkDir, sha);
if (fs.existsSync(plainPath)) {
fs.rmSync(plainPath, { force: true });
}
if (fs.existsSync(encryptedPath)) {
fs.rmSync(encryptedPath, { force: true });
}
}
export function isChunkEncryptedOnDisk(chunkDir, sha) {
const { encryptedPath } = getChunkPaths(chunkDir, sha);
return fs.existsSync(encryptedPath);
}
//# sourceMappingURL=encrypted-chunks.js.map