diff --git a/bin/cli/encryption.mjs b/bin/cli/encryption.mjs index 3011941232..007b7091f2 100644 --- a/bin/cli/encryption.mjs +++ b/bin/cli/encryption.mjs @@ -3,6 +3,9 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node: const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 16; const KEY_LENGTH = 32; +// Full 16-byte GCM tag produced by cipher.getAuthTag(). Pinning authTagLength on +// decrypt rejects truncated tags, closing the GCM tag-truncation forgery vector. +const AUTH_TAG_LENGTH = 16; const PREFIX = "enc:v1:"; // Keep this salt in sync with the app-side field encryption format so credentials written by // CLI setup remain decryptable by the dashboard/server and vice versa. @@ -53,7 +56,9 @@ export function decryptCredential(value) { } const [ivHex, encryptedHex, authTagHex] = parts; - const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex")); + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex"), { + authTagLength: AUTH_TAG_LENGTH, + }); decipher.setAuthTag(Buffer.from(authTagHex, "hex")); let decrypted = decipher.update(encryptedHex, "hex", "utf8"); diff --git a/src/app/docs/components/MermaidDiagram.tsx b/src/app/docs/components/MermaidDiagram.tsx index 37ed20113b..37a9fbd4fb 100644 --- a/src/app/docs/components/MermaidDiagram.tsx +++ b/src/app/docs/components/MermaidDiagram.tsx @@ -20,7 +20,10 @@ export function MermaidDiagram({ chart }: MermaidDiagramProps) { mermaid.initialize({ startOnLoad: false, theme: "default", - securityLevel: "loose", + // "strict" (Mermaid's default) sanitizes rendered SVG via its bundled + // DOMPurify before we assign it to innerHTML, blocking XSS from any + // untrusted diagram source. Avoid "loose" (allows HTML/click in labels). + securityLevel: "strict", fontFamily: "inherit", }); diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 0aaf47471b..960ac32870 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -30,6 +30,13 @@ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } 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"; @@ -167,7 +174,9 @@ export function decrypt(ciphertext: string | null | undefined): string | null | try { const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); - const decipher = createDecipheriv(ALGORITHM, candidateKey, iv); + const decipher = createDecipheriv(ALGORITHM, candidateKey, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); decipher.setAuthTag(authTag); let decrypted = decipher.update(encryptedHex, "hex", "utf8"); @@ -295,7 +304,9 @@ export function migrateLegacyEncryptedString(ciphertext: string | null | undefin const tryDecryptWithKey = (key: Buffer): string | null => { try { - const decipher = createDecipheriv(ALGORITHM, key, iv); + const decipher = createDecipheriv(ALGORITHM, key, iv, { + authTagLength: AUTH_TAG_LENGTH, + }); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, undefined, "utf8"); decrypted += decipher.final("utf8"); diff --git a/tests/unit/db-encryption.test.ts b/tests/unit/db-encryption.test.ts index 013369f9ed..d228d46772 100644 --- a/tests/unit/db-encryption.test.ts +++ b/tests/unit/db-encryption.test.ts @@ -54,6 +54,21 @@ test("encrypt/decrypt round-trip uses the expected serialized format", async () assert.equal(encryption.encrypt(encrypted), encrypted); }); +test("decrypt rejects a truncated GCM authentication tag (authTagLength pinned to 16 bytes)", async () => { + process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-authtag"; + const encryption = await importFresh("src/lib/db/encryption.ts"); + + const encrypted = encryption.encrypt("forge-me"); + assert.equal(encryption.decrypt(encrypted), "forge-me"); + + // Truncate the trailing auth tag to 1 byte (2 hex chars). With authTagLength + // pinned to 16 bytes on createDecipheriv, Node rejects the short tag instead + // of verifying a weakened tag, so decrypt must fail closed (null). + const [prefix, version, ivHex, encryptedHex] = encrypted.split(":"); + const truncated = `${prefix}:${version}:${ivHex}:${encryptedHex}:ab`; + assert.equal(encryption.decrypt(truncated), null); +}); + test("connection field helpers encrypt and decrypt all supported credential fields", async () => { process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-b"; const encryption = await importFresh("src/lib/db/encryption.ts");