Files
OmniRoute/src/lib/db/encryption.ts
Diego Rodrigues de Sa e Souza 78f09c8d9f Release v3.8.41 (#5327)
Release v3.8.41 — 52 commits since v3.8.40 (19 CHANGELOG bullets, 11 contributors).

All gating CI green: Unit×8, Coverage×8, Vitest, Package Artifact, Quality Ratchet, CodeQL, Lint, Docs Sync (Strict), Node 24/26 compat, E2E×9, Integration, Electron smoke.

Advisory checks overridden (main unprotected): PR Test Policy = test-masking heuristic on the cumulative 52-commit assert delta (legitimate dead-code-sweep removals + consolidations, reviewed per-PR); SonarCloud/SonarQube = new-code maintainability/coverage quality gate (CodeQL/Semgrep/Security/npm-audit/Dependabot all clean — not a security finding).
2026-06-29 16:51:03 -03:00

302 lines
10 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;
}
/**
* 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;
}
/**
* 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): 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;
}
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;
}
}
/**
* 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;
return {
...row,
apiKey: decrypt(row.apiKey),
accessToken: decrypt(row.accessToken),
refreshToken: decrypt(row.refreshToken),
idToken: decrypt(row.idToken),
};
}
/**
* 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 };
}