UNPKG

@enspirit/emb

Version:

A replacement for our Makefile-for-monorepos

189 lines (188 loc) 6.73 kB
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes, } from 'node:crypto'; import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { homedir, hostname, userInfo } from 'node:os'; import { join } from 'node:path'; const DEFAULT_EXPIRY_BUFFER = 5 * 60 * 1000; // 5 minutes const DEFAULT_CACHE_DIR = join(homedir(), '.emb', 'vault-tokens'); // Encryption constants const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; const _AUTH_TAG_LENGTH = 16; const SALT_LENGTH = 32; const KEY_LENGTH = 32; const PBKDF2_ITERATIONS = 100_000; /** * Derive an encryption key from machine-specific data. * The key is derived from hostname + username + a static pepper, * making the cache file unusable if copied to another machine or user. */ function deriveKey(salt) { const machineId = `${hostname()}:${userInfo().username}:emb-vault-cache`; return pbkdf2Sync(machineId, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256'); } /** * Encrypt data using AES-256-GCM. */ function encrypt(data) { const salt = randomBytes(SALT_LENGTH); const key = deriveKey(salt); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([ cipher.update(data, 'utf8'), cipher.final(), ]); const authTag = cipher.getAuthTag(); return { version: 1, salt: salt.toString('hex'), iv: iv.toString('hex'), authTag: authTag.toString('hex'), encrypted: encrypted.toString('hex'), }; } /** * Decrypt data using AES-256-GCM. * Returns null if decryption fails (wrong machine, corrupted data, etc.) */ function decrypt(file) { try { if (file.version !== 1) { return null; } const salt = Buffer.from(file.salt, 'hex'); const key = deriveKey(salt); const iv = Buffer.from(file.iv, 'hex'); const authTag = Buffer.from(file.authTag, 'hex'); const encrypted = Buffer.from(file.encrypted, 'hex'); const decipher = createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([ decipher.update(encrypted), decipher.final(), ]); return decrypted.toString('utf8'); } catch { // Decryption failed - wrong key (different machine/user) or corrupted data return null; } } /** * Generate a cache key for a Vault address and namespace combination. * Uses a hash to create safe filenames. */ function getCacheKey(vaultAddress, namespace) { const input = namespace ? `${vaultAddress}::${namespace}` : vaultAddress; return createHash('sha256').update(input).digest('hex').slice(0, 16); } /** * Get the path to the cache file for a given Vault address. */ function getCachePath(vaultAddress, namespace, cacheDir = DEFAULT_CACHE_DIR) { const key = getCacheKey(vaultAddress, namespace); return join(cacheDir, `${key}.json`); } /** * Retrieve a cached token if it exists and is still valid. * * @param vaultAddress - The Vault server address * @param namespace - Optional Vault namespace * @param options - Cache options * @returns The cached token or null if not found/expired */ export async function getCachedToken(vaultAddress, namespace, options = {}) { const { expiryBuffer = DEFAULT_EXPIRY_BUFFER, cacheDir } = options; const cachePath = getCachePath(vaultAddress, namespace, cacheDir); try { const content = await readFile(cachePath, 'utf8'); const encryptedFile = JSON.parse(content); // Decrypt the cached data const decrypted = decrypt(encryptedFile); if (!decrypted) { // Decryption failed - likely different machine/user or corrupted await clearCachedToken(vaultAddress, namespace, options); return null; } const cached = JSON.parse(decrypted); // Verify the cached token matches the requested address/namespace if (cached.vaultAddress !== vaultAddress || cached.namespace !== namespace) { return null; } // Check if token is expired or close to expiry const now = Date.now(); if (cached.expiresAt - expiryBuffer <= now) { // Token is expired or about to expire, clear it await clearCachedToken(vaultAddress, namespace, options); return null; } return cached; } catch { // File doesn't exist or is invalid return null; } } /** * Cache a Vault token to disk (encrypted). * * @param vaultAddress - The Vault server address * @param token - The Vault client token * @param ttlSeconds - Token TTL in seconds (from Vault's lease_duration) * @param namespace - Optional Vault namespace * @param options - Cache options */ // eslint-disable-next-line max-params export async function cacheToken(vaultAddress, token, ttlSeconds, namespace, options = {}) { const { cacheDir = DEFAULT_CACHE_DIR } = options; const cachePath = getCachePath(vaultAddress, namespace, cacheDir); const now = Date.now(); const cached = { token, expiresAt: now + ttlSeconds * 1000, createdAt: now, namespace, vaultAddress, }; // Encrypt the token data const encryptedFile = encrypt(JSON.stringify(cached)); // Ensure cache directory exists await mkdir(cacheDir, { recursive: true, mode: 0o700 }); // Write the encrypted cache file with restricted permissions await writeFile(cachePath, JSON.stringify(encryptedFile, null, 2), { mode: 0o600, encoding: 'utf8', }); // Ensure permissions are correct (writeFile mode may not work on all platforms) await chmod(cachePath, 0o600); } /** * Clear a cached token. * * @param vaultAddress - The Vault server address * @param namespace - Optional Vault namespace * @param options - Cache options */ export async function clearCachedToken(vaultAddress, namespace, options = {}) { const { cacheDir } = options; const cachePath = getCachePath(vaultAddress, namespace, cacheDir); try { await rm(cachePath); } catch { // Ignore errors if file doesn't exist } } /** * Check if a cached token exists and is valid (without returning the token). * * @param vaultAddress - The Vault server address * @param namespace - Optional Vault namespace * @param options - Cache options * @returns True if a valid cached token exists */ export async function hasCachedToken(vaultAddress, namespace, options = {}) { const cached = await getCachedToken(vaultAddress, namespace, options); return cached !== null; }