Skip to content
Open
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
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