| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from "crypto";
|
|
|
| const ALGORITHM = "aes-256-gcm";
|
| const IV_LENGTH = 16;
|
| const KEY_LENGTH = 32;
|
| |
| |
| |
| |
| |
|
|
| const AUTH_TAG_LENGTH = 16;
|
| const PREFIX = "enc:v1:";
|
| const STATIC_SALT = "omniroute-field-encryption-v1";
|
|
|
| let _staticKey: Buffer | null = null;
|
| let _legacyDynamicKey: Buffer | null = null;
|
|
|
| export interface ConnectionFields {
|
| apiKey?: string | null;
|
| accessToken?: string | null;
|
| refreshToken?: string | null;
|
| idToken?: string | null;
|
| [key: string]: unknown;
|
| }
|
|
|
| |
| |
| |
| |
|
|
| function getStaticKey(): Buffer | null {
|
| if (_staticKey !== null) return _staticKey;
|
|
|
| const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
| if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null;
|
|
|
| try {
|
| _staticKey = scryptSync(secret, STATIC_SALT, KEY_LENGTH);
|
| } catch (err: unknown) {
|
| const message = err instanceof Error ? err.message : String(err);
|
| console.error(
|
| `[Encryption] Failed to derive key from STORAGE_ENCRYPTION_KEY: ${message}. ` +
|
| `Generate a valid key with: openssl rand -base64 32`
|
| );
|
| return null;
|
| }
|
| return _staticKey;
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| function getLegacyDynamicKey(): Buffer | null {
|
| if (_legacyDynamicKey !== null) return _legacyDynamicKey;
|
|
|
| const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
| if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null;
|
|
|
| const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16);
|
| try {
|
| _legacyDynamicKey = scryptSync(secret, dynamicSalt, KEY_LENGTH);
|
| } catch {
|
| return null;
|
| }
|
| return _legacyDynamicKey;
|
| }
|
|
|
|
|
| export function isEncryptionEnabled(): boolean {
|
| return !!process.env.STORAGE_ENCRYPTION_KEY;
|
| }
|
|
|
| |
| |
| |
|
|
| export function encrypt(plaintext: string | null | undefined): string | null | undefined {
|
| if (!plaintext || typeof plaintext !== "string") return plaintext;
|
|
|
| const key = getStaticKey();
|
| if (!key) {
|
| console.warn(
|
| "[Encryption] STORAGE_ENCRYPTION_KEY not set. Storing plaintext (passthrough mode)."
|
| );
|
| return plaintext;
|
| }
|
|
|
|
|
| if (plaintext.startsWith(PREFIX)) return plaintext;
|
|
|
| try {
|
| const iv = randomBytes(IV_LENGTH);
|
| const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
|
| let encrypted = cipher.update(plaintext, "utf8", "hex");
|
| encrypted += cipher.final("hex");
|
| const authTag = cipher.getAuthTag().toString("hex");
|
|
|
| return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`;
|
| } catch (err: unknown) {
|
| const message = err instanceof Error ? err.message : String(err);
|
| console.error(
|
| `[Encryption] Encryption failed: ${message}. ` +
|
| `Check your STORAGE_ENCRYPTION_KEY — generate one with: openssl rand -base64 32`
|
| );
|
| return plaintext;
|
| }
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| export function decrypt(ciphertext: string | null | undefined): string | null | undefined {
|
| if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
|
|
|
|
|
| if (!ciphertext.startsWith(PREFIX)) return ciphertext;
|
|
|
| const staticKey = getStaticKey();
|
| if (!staticKey) {
|
| console.warn(
|
| "[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt."
|
| );
|
| return null;
|
| }
|
|
|
| const body = ciphertext.slice(PREFIX.length);
|
| const parts = body.split(":");
|
| if (parts.length !== 3) {
|
| console.error("[Encryption] Malformed encrypted value");
|
| return null;
|
| }
|
|
|
| const [ivHex, encryptedHex, authTagHex] = parts;
|
|
|
| const tryDecryptWithKey = (candidateKey: Buffer): string | null => {
|
| try {
|
| const iv = Buffer.from(ivHex, "hex");
|
| const authTag = Buffer.from(authTagHex, "hex");
|
| const decipher = createDecipheriv(ALGORITHM, candidateKey, iv, {
|
| authTagLength: AUTH_TAG_LENGTH,
|
| });
|
| decipher.setAuthTag(authTag);
|
|
|
| let decrypted = decipher.update(encryptedHex, "hex", "utf8");
|
| decrypted += decipher.final("utf8");
|
| return decrypted;
|
| } catch {
|
| return null;
|
| }
|
| };
|
|
|
| try {
|
|
|
| const decrypted = tryDecryptWithKey(staticKey);
|
| if (decrypted !== null) {
|
| return decrypted;
|
| }
|
|
|
| console.error(
|
| `[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
|
| `Auth tag validation likely failed.`
|
| );
|
| return null;
|
| } catch (err: unknown) {
|
| const message = err instanceof Error ? err.message : String(err);
|
| console.error("[Encryption] Decryption failed:", message);
|
| return null;
|
| }
|
| }
|
|
|
| |
| |
| |
| |
|
|
| export function encryptConnectionFields<T extends ConnectionFields | null | undefined>(conn: T): T {
|
| if (!isEncryptionEnabled()) return conn;
|
| if (!conn) return conn;
|
|
|
| if (conn.apiKey) conn.apiKey = encrypt(conn.apiKey);
|
| if (conn.accessToken) conn.accessToken = encrypt(conn.accessToken);
|
| if (conn.refreshToken) conn.refreshToken = encrypt(conn.refreshToken);
|
| if (conn.idToken) conn.idToken = encrypt(conn.idToken);
|
| return conn;
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| export function decryptConnectionFields<T extends ConnectionFields | null | undefined>(row: T): T {
|
| if (!row) return row;
|
| if (!isEncryptionEnabled()) return row;
|
|
|
| return {
|
| ...row,
|
| apiKey: decrypt(row.apiKey),
|
| accessToken: decrypt(row.accessToken),
|
| refreshToken: decrypt(row.refreshToken),
|
| idToken: decrypt(row.idToken),
|
| };
|
| }
|
|
|
| |
| |
| |
|
|
| export function validateEncryptionConfig(): { valid: boolean; error?: string } {
|
| const secret = process.env.STORAGE_ENCRYPTION_KEY;
|
|
|
|
|
| if (!secret) return { valid: true };
|
|
|
| if (typeof secret !== "string" || secret.trim().length === 0) {
|
| return {
|
| valid: false,
|
| error:
|
| "STORAGE_ENCRYPTION_KEY is set but empty. " +
|
| "Either remove it (passthrough mode) or set a valid key: openssl rand -base64 32",
|
| };
|
| }
|
|
|
|
|
| try {
|
| scryptSync(secret, STATIC_SALT, KEY_LENGTH);
|
| return { valid: true };
|
| } catch (err: unknown) {
|
| const message = err instanceof Error ? err.message : String(err);
|
| return {
|
| valid: false,
|
| error:
|
| `STORAGE_ENCRYPTION_KEY is invalid (${message}). ` +
|
| `Generate a valid key with: openssl rand -base64 32`,
|
| };
|
| }
|
| }
|
|
|
| |
| |
| |
| |
|
|
| export function migrateLegacyEncryptedString(ciphertext: string | null | undefined): {
|
| updated: boolean;
|
| value: string | null | undefined;
|
| } {
|
| if (!isEncryptionEnabled()) return { updated: false, value: ciphertext };
|
| if (!ciphertext || ciphertext.trim().length === 0) return { updated: false, value: ciphertext };
|
| if (!ciphertext.startsWith(PREFIX)) return { updated: false, value: ciphertext };
|
|
|
| const staticKey = getStaticKey();
|
| const legacyKey = getLegacyDynamicKey();
|
|
|
| if (!staticKey) return { updated: false, value: null };
|
|
|
| const rawPayload = ciphertext.slice(PREFIX.length);
|
| const parts = rawPayload.split(":");
|
| if (parts.length !== 3) return { updated: false, value: ciphertext };
|
|
|
| const [ivHex, encryptedHex, authTagHex] = parts;
|
| const iv = Buffer.from(ivHex, "hex");
|
| const authTag = Buffer.from(authTagHex, "hex");
|
| const encrypted = Buffer.from(encryptedHex, "hex");
|
|
|
| const tryDecryptWithKey = (key: Buffer): string | null => {
|
| try {
|
| const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
| authTagLength: AUTH_TAG_LENGTH,
|
| });
|
| decipher.setAuthTag(authTag);
|
| let decrypted = decipher.update(encrypted, undefined, "utf8");
|
| decrypted += decipher.final("utf8");
|
| return decrypted;
|
| } catch {
|
| return null;
|
| }
|
| };
|
|
|
|
|
| if (tryDecryptWithKey(staticKey) !== null) {
|
| return { updated: false, value: ciphertext };
|
| }
|
|
|
|
|
| if (legacyKey) {
|
| const legacyDecrypted = tryDecryptWithKey(legacyKey);
|
| if (legacyDecrypted !== null) {
|
|
|
| return { updated: true, value: encrypt(legacyDecrypted) };
|
| }
|
| }
|
|
|
|
|
| return { updated: false, value: ciphertext };
|
| }
|
|
|