import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; import { homedir } from "node:os"; const ALGORITHM = "aes-256-gcm"; const KEY_LENGTH = 32; // 256 bits const IV_LENGTH = 12; // 96 bits — recommended for GCM const AUTH_TAG_LENGTH = 16; // 128 bits /** * Resolve the config directory from brand.json (same pattern as anthropic-key, email). * Falls back to ".maxy" if brand.json is unreadable. */ function resolveConfigDir(): string { try { const platformRoot = process.env.PLATFORM_ROOT; if (platformRoot) { const brandPath = resolve(platformRoot, "config/brand.json"); const brand = JSON.parse(readFileSync(brandPath, "utf-8")); if (brand.configDir) return brand.configDir; } } catch { // Fall back to default } return ".maxy"; } function keyFilePath(): string { return resolve(homedir(), resolveConfigDir(), ".loop-encryption-key"); } /** * Load or generate the encryption key. Generated on first use, * stored at ~/.{configDir}/.loop-encryption-key with mode 0o600. */ function loadOrGenerateKey(): Buffer { const path = keyFilePath(); if (existsSync(path)) { const hex = readFileSync(path, "utf-8").trim(); const buf = Buffer.from(hex, "hex"); if (buf.length !== KEY_LENGTH) { throw new Error( `Encryption key at ${path} is ${buf.length} bytes, expected ${KEY_LENGTH}. ` + `Delete the file and re-register all Loop team keys.` ); } return buf; } // First use — generate a new key const key = randomBytes(KEY_LENGTH); writeFileSync(path, key.toString("hex"), { mode: 0o600 }); console.error(`[loop] encryption key generated at ${path}`); return key; } /** * Encrypt a plaintext string. Returns "iv:ciphertext:authTag" (hex-encoded). */ export function encrypt(plaintext: string): string { const key = loadOrGenerateKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); let encrypted = cipher.update(plaintext, "utf-8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag().toString("hex"); return `${iv.toString("hex")}:${encrypted}:${authTag}`; } /** * Decrypt a value produced by encrypt(). Expects "iv:ciphertext:authTag" (hex-encoded). * Throws on tampered/corrupt data or wrong key. */ export function decrypt(encryptedValue: string): string { const parts = encryptedValue.split(":"); if (parts.length !== 3) { throw new Error("Malformed encrypted value — expected iv:ciphertext:authTag"); } const [ivHex, ciphertextHex, authTagHex] = parts; const path = keyFilePath(); if (!existsSync(path)) { throw new Error( `Encryption key not found at ${path}. ` + `All stored Loop API keys are unrecoverable. Re-register keys.` ); } const key = loadOrGenerateKey(); const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); decipher.setAuthTag(authTag); let decrypted = decipher.update(ciphertextHex, "hex", "utf-8"); decrypted += decipher.final("utf-8"); return decrypted; }