Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions __tests__/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { encrypt, decrypt } from "../lib/crypto";

describe("Crypto Utils", () => {
// Mock NEXTAUTH_SECRET to ensure tests don't fail based on local env
const OLD_ENV = process.env;

beforeAll(() => {
process.env = { ...OLD_ENV, NEXTAUTH_SECRET: "test-secret-key-32-chars-long-abc" };
});

afterAll(() => {
process.env = OLD_ENV;
});

describe("Round-Trip Data Integrity", () => {
it("should successfully encrypt and decrypt a standard string", () => {
const plaintext = "hello world!";
const ciphertext = encrypt(plaintext);
const decrypted = decrypt(ciphertext);
expect(decrypted).toBe(plaintext);
});

it("should handle empty strings", () => {
const plaintext = "";
const ciphertext = encrypt(plaintext);
const decrypted = decrypt(ciphertext);
expect(decrypted).toBe(plaintext);
});

it("should handle long strings", () => {
const plaintext = "a".repeat(10000);
const ciphertext = encrypt(plaintext);
const decrypted = decrypt(ciphertext);
expect(decrypted).toBe(plaintext);
});

it("should handle special characters and unicode", () => {
const plaintext = "hello 🌍🚀 ~!@#$%^&*()_+";
const ciphertext = encrypt(plaintext);
const decrypted = decrypt(ciphertext);
expect(decrypted).toBe(plaintext);
});
});

describe("Format and Delimiter Validation", () => {
it("should return a string separated by two colons into 3 parts", () => {
const ciphertext = encrypt("test");
const parts = ciphertext.split(":");
expect(parts).toHaveLength(3);

const [iv, tag, enc] = parts;
expect(iv.length).toBeGreaterThan(0);
expect(tag.length).toBeGreaterThan(0);
expect(enc.length).toBeGreaterThan(0);

// Should be valid hex
expect(/^[0-9a-f]+$/i.test(iv)).toBe(true);
expect(/^[0-9a-f]+$/i.test(tag)).toBe(true);
expect(/^[0-9a-f]+$/i.test(enc)).toBe(true);
});
});

describe("Initialization Vector (IV) Uniqueness", () => {
it("should generate a unique ciphertext for the same plaintext", () => {
const plaintext = "identical plaintext";
const first = encrypt(plaintext);
const second = encrypt(plaintext);
expect(first).not.toBe(second);

const firstParts = first.split(":");
const secondParts = second.split(":");
// IVs should be different
expect(firstParts[0]).not.toBe(secondParts[0]);
});
});

describe("Robust Error & Tamper Handling", () => {
it("should throw an error if the input string is malformed", () => {
expect(() => decrypt("not-a-valid-ciphertext")).toThrow();
expect(() => decrypt("one:two")).toThrow();
});

it("should throw an error if the ciphertext is modified", () => {
const original = encrypt("top secret");
const parts = original.split(":");
// Tamper with the encrypted data
parts[2] = parts[2].substring(1) + "0";
const tampered = parts.join(":");
expect(() => decrypt(tampered)).toThrow();
});

it("should throw an error if the authentication tag is altered", () => {
const original = encrypt("top secret");
const parts = original.split(":");
// Tamper with the auth tag
parts[1] = "00000000000000000000000000000000";
const tampered = parts.join(":");
expect(() => decrypt(tampered)).toThrow();
});
});
});
12 changes: 12 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { createDefaultPreset } = require("ts-jest");

const tsJestTransformCfg = createDefaultPreset().transform;

/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
Comment on lines +2 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace require usage to unblock lint CI.

Line 1 violates the enforced ESLint rule and is currently breaking the pipeline. Switch to a ts-jest preset config that does not require require().

Suggested fix
-const { createDefaultPreset } = require("ts-jest");
-
-const tsJestTransformCfg = createDefaultPreset().transform;
-
 /** `@type` {import("jest").Config} **/
 module.exports = {
+  preset: "ts-jest",
   testEnvironment: "node",
-  transform: {
-    ...tsJestTransformCfg,
-  },
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { createDefaultPreset } = require("ts-jest");
const tsJestTransformCfg = createDefaultPreset().transform;
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: "node",
transform: {
...tsJestTransformCfg,
},
/** `@type` {import("jest").Config} **/
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
🧰 Tools
🪛 ESLint

[error] 1-1: A require() style import is forbidden.

(@typescript-eslint/no-require-imports)

🪛 GitHub Actions: CI / 0_Lint and Build.txt

[error] 1-1: ESLint error: A require() style import is forbidden. (@typescript-eslint/no-require-imports)

🪛 GitHub Actions: CI / Lint and Build

[error] 1-1: ESLint (@typescript-eslint/no-require-imports): A require() style import is forbidden.

🪛 GitHub Check: Lint and Build

[failure] 1-1:
A require() style import is forbidden

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jest.config.js` around lines 1 - 10, Replace the CommonJS require by using ES
module imports/exports: import createDefaultPreset from "ts-jest" (or import {
createDefaultPreset } from "ts-jest" depending on the package export) and
replace module.exports with export default, then keep the existing usage of
createDefaultPreset() and tsJestTransformCfg (the transform assignment)
unchanged so the config still sets testEnvironment and transform; update the
file to use ESM syntax so createDefaultPreset, tsJestTransformCfg and the
exported Jest config no longer use require().

};
29 changes: 29 additions & 0 deletions lib/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";

export function getKey(): Buffer {
const raw = process.env.NEXTAUTH_SECRET ?? "default-dev-secret-32-chars-long";
return createHash("sha256").update(raw).digest();
Comment on lines +3 to +5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail closed when NEXTAUTH_SECRET is missing.

Falling back to a hard-coded secret makes every misconfigured deployment encrypt env vars with the same known key, so DB-backed secrets are effectively recoverable. Require an explicit secret here and let tests set one deliberately.

Suggested fix
 export function getKey(): Buffer {
-  const raw = process.env.NEXTAUTH_SECRET ?? "default-dev-secret-32-chars-long";
+  const raw = process.env.NEXTAUTH_SECRET;
+  if (!raw) {
+    throw new Error("NEXTAUTH_SECRET must be set");
+  }
   return createHash("sha256").update(raw).digest();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function getKey(): Buffer {
const raw = process.env.NEXTAUTH_SECRET ?? "default-dev-secret-32-chars-long";
return createHash("sha256").update(raw).digest();
export function getKey(): Buffer {
const raw = process.env.NEXTAUTH_SECRET;
if (!raw) {
throw new Error("NEXTAUTH_SECRET must be set");
}
return createHash("sha256").update(raw).digest();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crypto.ts` around lines 3 - 5, Replace the insecure fallback in getKey so
the app fails closed: in the getKey function check process.env.NEXTAUTH_SECRET
(referencing getKey and the createHash call) and if it's undefined/empty throw a
clear error (e.g., "NEXTAUTH_SECRET must be set") instead of using
"default-dev-secret-32-chars-long"; when present, continue to derive the Buffer
via createHash("sha256").update(raw).digest(); ensure tests set NEXTAUTH_SECRET
explicitly.

}

export function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
}

export function decrypt(ciphertext: string): string {
const [ivHex, tagHex, dataHex] = ciphertext.split(":");
if (!ivHex || !tagHex || dataHex === undefined) {
throw new Error("Invalid ciphertext format");
Comment on lines +18 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject ciphertexts that contain extra : segments.

split(":") + destructuring ignores any fourth segment, so a tampered payload like ${validCiphertext}:junk still decrypts successfully. Enforce exactly three parts before creating the decipher.

Suggested fix
 export function decrypt(ciphertext: string): string {
-  const [ivHex, tagHex, dataHex] = ciphertext.split(":");
-  if (!ivHex || !tagHex || dataHex === undefined) {
+  const parts = ciphertext.split(":");
+  if (parts.length !== 3) {
+    throw new Error("Invalid ciphertext format");
+  }
+  const [ivHex, tagHex, dataHex] = parts;
+  if (!ivHex || !tagHex || dataHex === undefined) {
     throw new Error("Invalid ciphertext format");
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [ivHex, tagHex, dataHex] = ciphertext.split(":");
if (!ivHex || !tagHex || dataHex === undefined) {
throw new Error("Invalid ciphertext format");
const parts = ciphertext.split(":");
if (parts.length !== 3) {
throw new Error("Invalid ciphertext format");
}
const [ivHex, tagHex, dataHex] = parts;
if (!ivHex || !tagHex || dataHex === undefined) {
throw new Error("Invalid ciphertext format");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crypto.ts` around lines 18 - 20, The current destructuring const [ivHex,
tagHex, dataHex] = ciphertext.split(":") allows extra segments to be ignored;
change the logic so you first assign the result to a variable (e.g., const parts
= ciphertext.split(":")) and validate parts.length === 3 before destructuring or
using parts[0..2]; if the length is not exactly 3, throw the existing "Invalid
ciphertext format" error. Update the code path around the ivHex/tagHex/dataHex
validation to use parts to ensure tampered payloads like
`${validCiphertext}:junk` are rejected.

}
const key = getKey();
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
return (
decipher.update(Buffer.from(dataHex, "hex")).toString("utf8") +
decipher.final("utf8")
);
}
29 changes: 1 addition & 28 deletions lib/env-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,9 @@
* - multiple app instances ✓ (shared Neon DB)
*/

import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { encrypt, decrypt } from "./crypto";
import { getDb, ensureTables } from "./db";

// ── DB helpers ─────────────────────────────────────────────────────────────────

function getKey(): Buffer {
const raw = process.env.NEXTAUTH_SECRET ?? "default-dev-secret-32-chars-long";
return createHash("sha256").update(raw).digest();
}

function encrypt(plaintext: string): string {
const key = getKey();
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
}

function decrypt(ciphertext: string): string {
const [ivHex, tagHex, dataHex] = ciphertext.split(":");
const key = getKey();
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
return (
decipher.update(Buffer.from(dataHex, "hex")).toString("utf8") +
decipher.final("utf8")
);
}

// ── Public API ─────────────────────────────────────────────────────────────────

/** Returns plaintext env vars for a project. */
Expand Down
Loading
Loading