Files
OmniRoute/src/lib/db/encryption.ts

391 lines
14 KiB
TypeScript

/**
* Field-Level Encryption — AES-256-GCM
*
* Encrypts/decrypts sensitive fields (API keys, tokens) stored in SQLite.
* Format: `enc:v1:<iv_hex>:<ciphertext_hex>:<authTag_hex>`
*
* If STORAGE_ENCRYPTION_KEY is not set, operates in passthrough mode
* (stores plaintext for development convenience).
*
* KEY DERIVATION CHANGE (v3.7.9):
* The PRIMARY key is now derived with a static salt ("omniroute-field-encryption-v1").
* The LEGACY key used a dynamic salt (sha256 hash of the key). Auto-migration
* re-encrypts any legacy-encrypted tokens on decrypt.
*
* Why the change?
* The dynamic salt `createHash("sha256").update(secret).digest().slice(0, 16)` produced
* a different derived key than the static salt `"omniroute-field-encryption-v1"`. When the
* health-check/token-refresh path used one derivation and the main API used another,
* tokens encrypted by one path became undecryptable by the other, causing:
* - Persistent decrypt failures
* - Re-encryption loops (health-check undoing fixes)
* - CPU spikes (50%) from error cascades
*
* This fix makes the static salt the primary derivation and auto-migrates
* legacy-encrypted tokens back to static-salt encryption.
*/
import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from "crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const KEY_LENGTH = 32;
/**
* GCM authentication tag length, in bytes. Pinned to the full 16-byte tag
* produced by `cipher.getAuthTag()`. Passing `authTagLength` to
* `createDecipheriv` rejects truncated authentication tags up front, closing
* the GCM tag-truncation forgery vector (Semgrep gcm-no-tag-length).
*/
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;
/** Connection object with potentially encrypted credential fields. */
export interface ConnectionFields {
apiKey?: string | null;
accessToken?: string | null;
refreshToken?: string | null;
idToken?: string | null;
[key: string]: unknown;
}
/**
* #9927 — dedupe tracker for credential-decrypt-failure messages. The health
* sweep / refresh / request routing re-decrypt the same corrupt row every
* cycle; we log the enriched, actionable message ONCE per
* (provider + connection + failing-ciphertext) state so it does not spam
* every sweep, while still re-logging if the row state actually changes
* (e.g. a different field starts failing) instead of permanently suppressing.
*/
const loggedDecryptFailures = new Set<string>();
function decryptFailureSignature(
connectionId: string,
provider: string,
failed: Array<{ field: string; value: unknown }>
): string {
const parts = failed
.map((f) => `${f.field}:${typeof f.value === "string" ? f.value : ""}`)
.sort()
.join("|");
return `${provider}::${connectionId}::${parts}`;
}
const RECOVERY_HINT =
"Re-authenticate this account, or verify STORAGE_ENCRYPTION_KEY matches the key used to store it.";
/**
* Derive the PRIMARY encryption key using the static salt.
* This is the canonical key derivation that all new encryptions use.
* Returns null if no encryption key is configured.
*/
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;
}
/**
* Derive the LEGACY key using the old dynamic salt method.
* Used exclusively for fallback decryption of tokens encrypted by older versions.
*
* The old dynamic salt was: createHash("sha256").update(secret).digest().slice(0, 16)
* This produced a different derived key than the static salt, causing incompatibility.
*/
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;
}
/** Check if encryption is enabled. */
export function isEncryptionEnabled(): boolean {
return !!process.env.STORAGE_ENCRYPTION_KEY;
}
/**
* True when `value` is a stored ciphertext (carries the `enc:v1:` prefix).
* Lets callers tell "credential present but undecryptable" (stale/changed
* STORAGE_ENCRYPTION_KEY) apart from "credential genuinely empty" — decrypt()
* collapses both to null otherwise. See #6148.
*/
export function looksEncrypted(value: unknown): boolean {
return typeof value === "string" && value.startsWith(PREFIX);
}
/**
* Encrypt a plaintext string using the STATIC salt key.
* If encryption is not configured, returns plaintext unchanged.
*/
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; // passthrough mode
}
// Already encrypted — don't double-encrypt
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; // fallback to plaintext rather than crashing
}
}
/**
* Decrypt a ciphertext string. Attempts static-salt key first (primary),
* then falls back to legacy dynamic-salt key for backward compatibility.
*
* When a token is decrypted using the legacy key, it is flagged for
* auto-migration: the next encrypt() call will re-encrypt it with the
* static-salt key, gradually migrating the database.
*/
export function decrypt(
ciphertext: string | null | undefined,
opts?: { quiet?: boolean }
): string | null | undefined {
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
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 {
// PRIMARY: Try static-salt key first (canonical derivation)
const decrypted = tryDecryptWithKey(staticKey);
if (decrypted !== null) {
return decrypted;
}
// #9927 — the low-level generic log is suppressed when called through the
// connection-decryption path (quiet:true); decryptConnectionFields emits a
// single enriched message naming the credential + recovery path instead.
if (!opts?.quiet) {
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);
if (!opts?.quiet) {
console.error("[Encryption] Decryption failed:", message);
}
return null;
}
}
/**
* Encrypt sensitive fields in a connection object (mutates in-place).
* After decryption that required legacy key, re-encrypt with static key
* to migrate tokens automatically.
*/
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;
}
/**
* Decrypt sensitive fields in a connection row (returns new object).
* Note: If any field was decrypted using the legacy key, the migration
* flag is set. The calling code should check isMigrationNeeded() and
* trigger a re-encrypt (write-back) to migrate those tokens to the static key.
*/
export function decryptConnectionFields<T extends ConnectionFields | null | undefined>(row: T): T {
if (!row) return row;
if (!isEncryptionEnabled()) return row;
// quiet:true — the low-level generic decrypt() log is suppressed here so a
// single failure emits ONE enriched message (below) naming the credential
// and recovery path (#9927) instead of one generic line per field per cycle.
const apiKey = decrypt(row.apiKey, { quiet: true });
const accessToken = decrypt(row.accessToken, { quiet: true });
const refreshToken = decrypt(row.refreshToken, { quiet: true });
const idToken = decrypt(row.idToken, { quiet: true });
// #6148 — a stored credential that is still encrypted (`enc:v1:…`) but
// decrypts to null means the STORAGE_ENCRYPTION_KEY changed or was unset.
// Flag it so callers surface a clear error instead of coercing the null to
// "" and firing an empty-Bearer request that upstream rejects as 401.
const credentialDecryptFailed =
(looksEncrypted(row.apiKey) && apiKey === null) ||
(looksEncrypted(row.accessToken) && accessToken === null) ||
(looksEncrypted(row.refreshToken) && refreshToken === null) ||
(looksEncrypted(row.idToken) && idToken === null);
if (credentialDecryptFailed) {
const failed: Array<{ field: string; value: unknown }> = [];
if (looksEncrypted(row.apiKey) && apiKey === null) failed.push({ field: "apiKey", value: row.apiKey });
if (looksEncrypted(row.accessToken) && accessToken === null)
failed.push({ field: "accessToken", value: row.accessToken });
if (looksEncrypted(row.refreshToken) && refreshToken === null)
failed.push({ field: "refreshToken", value: row.refreshToken });
if (looksEncrypted(row.idToken) && idToken === null) failed.push({ field: "idToken", value: row.idToken });
const connectionId = typeof row.id === "string" ? row.id : "";
const provider = typeof row.provider === "string" ? row.provider : "unknown";
const fields = failed.map((f) => f.field).join(", ");
// Dedupe per credential/row state: the sweep re-decrypts the same corrupt
// row every cycle — log ONCE unless the failing state actually changes.
const signature = decryptFailureSignature(connectionId, provider, failed);
if (!loggedDecryptFailures.has(signature)) {
loggedDecryptFailures.add(signature);
console.error(
`[Encryption] Failed to decrypt credential(s) [${fields}] for provider ` +
`"${provider}" (connection ${connectionId || "unknown"}). ${RECOVERY_HINT}`
);
}
}
return {
...row,
apiKey,
accessToken,
refreshToken,
idToken,
...(credentialDecryptFailed ? { credentialDecryptFailed: true } : {}),
};
}
/**
* Specifically tests a ciphertext against the legacy key. If it succeeds, it
* re-encrypts the decrypted value with the canonical static key.
* Used exclusively by the startup migration script.
*/
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;
}
};
// 1. If it already decrypts with the static key, no migration needed.
if (tryDecryptWithKey(staticKey) !== null) {
return { updated: false, value: ciphertext };
}
// 2. If it decrypts with the legacy key, it needs migration!
if (legacyKey) {
const legacyDecrypted = tryDecryptWithKey(legacyKey);
if (legacyDecrypted !== null) {
// Re-encrypt using the canonical static key and return updated
return { updated: true, value: encrypt(legacyDecrypted) };
}
}
// 3. Un-decryptable or corrupted, leave it alone
return { updated: false, value: ciphertext };
}