mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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).
302 lines
10 KiB
TypeScript
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 };
|
|
}
|