Skip to content
2 changes: 1 addition & 1 deletion e2e/landing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
test("landing page renders GitHub sign-in entrypoint", async ({ page }) => {
await page.goto("/");

await expect(page.getByRole("heading", { name: "DevTrack" })).toBeVisible();
await expect(page.getByRole("heading", { name: "DevTrack", exact: true })).toBeVisible();
await expect(
page.getByRole("link", { name: "Sign in with GitHub" }),
).toHaveAttribute("href", /\/api\/auth\/signin\/github\?callbackUrl=\/dashboard/);
Expand Down
35 changes: 24 additions & 11 deletions src/lib/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const KEY_ERROR_MESSAGE =
"ENCRYPTION_KEY env var must be a 32-byte hex string";
const IV_ERROR_MESSAGE =
"Encrypted token IV must be a 12-byte hex string";
const PAYLOAD_ERROR_MESSAGE =
"Encrypted token payload must include at least a 16-byte auth tag";

function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY;
Expand All @@ -26,6 +30,24 @@ function getEncryptionKey(): Buffer {
return keyBuffer;
}

function assertFixedHex(value: string, expectedChars: number, message: string) {
if (!new RegExp(`^[0-9a-fA-F]{${expectedChars}}$`).test(value)) {
throw new Error(message);
}
}

function validateEncryptedTokenPayload(encrypted: string, iv: string) {
assertFixedHex(iv, IV_LENGTH * 2, IV_ERROR_MESSAGE);

if (
encrypted.length < AUTH_TAG_LENGTH * 2 ||
encrypted.length % 2 !== 0 ||
!/^[0-9a-fA-F]+$/.test(encrypted)
) {
throw new Error(PAYLOAD_ERROR_MESSAGE);
}
}

export function encryptToken(plaintext: string): {
encrypted: string;
iv: string;
Expand All @@ -46,22 +68,13 @@ export function encryptToken(plaintext: string): {
};
}


export function decryptToken(
encrypted: string,
iv: string
): string | null {
try {
const key = getEncryptionKey();

if (!/^[0-9a-fA-F]*$/.test(encrypted) || encrypted.length % 2 !== 0) {
throw new Error("Invalid encrypted token format");
}

if (!/^[0-9a-fA-F]*$/.test(iv) || iv.length % 2 !== 0) {
throw new Error("Invalid IV format");
}

validateEncryptedTokenPayload(encrypted, iv);
const encryptedBuffer = Buffer.from(encrypted, "hex");
const ivBuffer = Buffer.from(iv, "hex");

Expand Down Expand Up @@ -94,4 +107,4 @@ export function decryptToken(
console.error("Token decryption failed:", error);
return null;
}
}
}
61 changes: 61 additions & 0 deletions test/crypto.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const ts = require("typescript");

function loadCryptoModule() {
const sourcePath = path.join(__dirname, "..", "src", "lib", "crypto.ts");
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "devtrack-crypto-"));
const outPath = path.join(outDir, "crypto.cjs");
const source = fs.readFileSync(sourcePath, "utf8");
const output = ts.transpileModule(source, {
compilerOptions: {
esModuleInterop: true,
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
}).outputText;

fs.writeFileSync(outPath, output);
return require(outPath);
}

test("decryptToken rejects malformed IV before decipher creation", () => {
const { decryptToken } = loadCryptoModule();
process.env.ENCRYPTION_KEY = "a".repeat(64);
const originalError = console.error;
console.error = () => {};

try {
assert.equal(decryptToken("0".repeat(32), "abcd"), null);
} finally {
console.error = originalError;
}
});

test("decryptToken rejects payloads shorter than the auth tag", () => {
const { decryptToken } = loadCryptoModule();
process.env.ENCRYPTION_KEY = "b".repeat(64);
const originalError = console.error;
console.error = () => {};

try {
assert.equal(decryptToken("0".repeat(30), "1".repeat(24)), null);
} finally {
console.error = originalError;
}
});

test("decryptToken still decrypts valid encrypted tokens", () => {
const { decryptToken, encryptToken } = loadCryptoModule();
process.env.ENCRYPTION_KEY = "c".repeat(64);

const encrypted = encryptToken("github-token-123");

assert.equal(
decryptToken(encrypted.encrypted, encrypted.iv),
"github-token-123"
);
});
Loading