-
Notifications
You must be signed in to change notification settings - Fork 7
feat: extract crypto helpers and add comprehensive AES-256-GCM test s… #79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| }); | ||
| }); |
| 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, | ||
| }, | ||
| }; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail closed when 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject ciphertexts that contain extra
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| 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") | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Replace
requireusage to unblock lint CI.Line 1 violates the enforced ESLint rule and is currently breaking the pipeline. Switch to a
ts-jestpreset config that does not requirerequire().Suggested fix
📝 Committable suggestion
🧰 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): Arequire()style import is forbidden.🪛 GitHub Check: Lint and Build
[failure] 1-1:
A
require()style import is forbidden🤖 Prompt for AI Agents