mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
391 lines
14 KiB
TypeScript
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 };
|
|
}
|