diff --git a/src/auth.test.ts b/src/auth.test.ts new file mode 100644 index 0000000..1ab33b0 --- /dev/null +++ b/src/auth.test.ts @@ -0,0 +1,262 @@ +import { expect, test } from "bun:test"; +import { + createAuthHeader, + generateSecret, + signMessage, + verifyAuthHeader, + verifySignature, +} from "./auth"; + +// Secret generation tests + +test("generateSecret creates a 32-byte base64 secret", () => { + const secret = generateSecret(); + + // Base64 encoding of 32 bytes should be 44 characters (with padding) + expect(secret.length).toBe(44); + + // Should be valid base64 + const decoded = Buffer.from(secret, "base64"); + expect(decoded.length).toBe(32); +}); + +test("generateSecret creates unique secrets", () => { + const secret1 = generateSecret(); + const secret2 = generateSecret(); + const secret3 = generateSecret(); + + expect(secret1).not.toBe(secret2); + expect(secret2).not.toBe(secret3); + expect(secret1).not.toBe(secret3); +}); + +// Sign/verify tests + +test("signMessage creates a signature", async () => { + const secret = generateSecret(); + const message = "test message"; + + const signature = await signMessage(secret, message); + + expect(signature).toBeTruthy(); + expect(signature.length).toBeGreaterThan(0); + // HMAC-SHA256 produces 32 bytes, base64 encoded is 44 chars + expect(signature.length).toBe(44); +}); + +test("verifySignature succeeds with correct secret and message", async () => { + const secret = generateSecret(); + const message = "test message"; + + const signature = await signMessage(secret, message); + const valid = await verifySignature(secret, message, signature); + + expect(valid).toBe(true); +}); + +test("verifySignature fails with wrong secret", async () => { + const secret1 = generateSecret(); + const secret2 = generateSecret(); + const message = "test message"; + + const signature = await signMessage(secret1, message); + const valid = await verifySignature(secret2, message, signature); + + expect(valid).toBe(false); +}); + +test("verifySignature fails with wrong message", async () => { + const secret = generateSecret(); + const message1 = "test message"; + const message2 = "different message"; + + const signature = await signMessage(secret, message1); + const valid = await verifySignature(secret, message2, signature); + + expect(valid).toBe(false); +}); + +test("verifySignature fails with tampered signature", async () => { + const secret = generateSecret(); + const message = "test message"; + + const signature = await signMessage(secret, message); + // Tamper with the signature + const tamperedSignature = `${signature.slice(0, -1)}X`; + const valid = await verifySignature(secret, message, tamperedSignature); + + expect(valid).toBe(false); +}); + +test("verifySignature fails with invalid base64 signature", async () => { + const secret = generateSecret(); + const message = "test message"; + + const valid = await verifySignature(secret, message, "not-valid-base64!!!"); + + expect(valid).toBe(false); +}); + +// Auth header tests + +test("createAuthHeader creates header with timestamp and signature", async () => { + const secret = generateSecret(); + const timestamp = Date.now(); + + const header = await createAuthHeader(secret, timestamp); + + const parts = header.split(":"); + expect(parts.length).toBe(2); + expect(parts[0]).toBe(timestamp.toString()); + expect(parts[1]?.length).toBe(44); // HMAC-SHA256 base64 length +}); + +test("verifyAuthHeader succeeds with valid header", async () => { + const secret = generateSecret(); + const timestamp = Date.now(); + + const header = await createAuthHeader(secret, timestamp); + const valid = await verifyAuthHeader(secret, header); + + expect(valid).toBe(true); +}); + +test("verifyAuthHeader fails with wrong secret", async () => { + const secret1 = generateSecret(); + const secret2 = generateSecret(); + const timestamp = Date.now(); + + const header = await createAuthHeader(secret1, timestamp); + const valid = await verifyAuthHeader(secret2, header); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with tampered timestamp", async () => { + const secret = generateSecret(); + const timestamp = Date.now(); + + const header = await createAuthHeader(secret, timestamp); + // Tamper with timestamp + const parts = header.split(":"); + const tamperedHeader = `${Number.parseInt(parts[0] || "0", 10) + 1000}:${parts[1]}`; + const valid = await verifyAuthHeader(secret, tamperedHeader); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with tampered signature", async () => { + const secret = generateSecret(); + const timestamp = Date.now(); + + const header = await createAuthHeader(secret, timestamp); + // Tamper with signature + const parts = header.split(":"); + const tamperedHeader = `${parts[0]}:${parts[1]?.slice(0, -1)}X`; + const valid = await verifyAuthHeader(secret, tamperedHeader); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with expired timestamp", async () => { + const secret = generateSecret(); + // Timestamp from 10 minutes ago + const timestamp = Date.now() - 10 * 60 * 1000; + + const header = await createAuthHeader(secret, timestamp); + // Default maxAge is 5 minutes, so this should fail + const valid = await verifyAuthHeader(secret, header); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader succeeds with timestamp within maxAge", async () => { + const secret = generateSecret(); + // Timestamp from 3 minutes ago + const timestamp = Date.now() - 3 * 60 * 1000; + + const header = await createAuthHeader(secret, timestamp); + // Default maxAge is 5 minutes, so this should succeed + const valid = await verifyAuthHeader(secret, header); + + expect(valid).toBe(true); +}); + +test("verifyAuthHeader respects custom maxAge", async () => { + const secret = generateSecret(); + // Timestamp from 2 minutes ago + const timestamp = Date.now() - 2 * 60 * 1000; + + const header = await createAuthHeader(secret, timestamp); + + // Should fail with 1 minute maxAge + const valid1 = await verifyAuthHeader(secret, header, 1 * 60 * 1000); + expect(valid1).toBe(false); + + // Should succeed with 3 minute maxAge + const valid2 = await verifyAuthHeader(secret, header, 3 * 60 * 1000); + expect(valid2).toBe(true); +}); + +test("verifyAuthHeader rejects future timestamps beyond clock skew", async () => { + const secret = generateSecret(); + // Timestamp from 2 minutes in the future + const timestamp = Date.now() + 2 * 60 * 1000; + + const header = await createAuthHeader(secret, timestamp); + const valid = await verifyAuthHeader(secret, header); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader allows small future timestamps (clock skew)", async () => { + const secret = generateSecret(); + // Timestamp from 30 seconds in the future (within 1 minute tolerance) + const timestamp = Date.now() + 30 * 1000; + + const header = await createAuthHeader(secret, timestamp); + const valid = await verifyAuthHeader(secret, header); + + expect(valid).toBe(true); +}); + +test("verifyAuthHeader fails with malformed header (no colon)", async () => { + const secret = generateSecret(); + const valid = await verifyAuthHeader(secret, "malformed-header"); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with malformed header (extra colons)", async () => { + const secret = generateSecret(); + const timestamp = Date.now(); + + const header = await createAuthHeader(secret, timestamp); + const malformed = `${header}:extra`; + const valid = await verifyAuthHeader(secret, malformed); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with empty timestamp", async () => { + const secret = generateSecret(); + const valid = await verifyAuthHeader(secret, ":signature"); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with empty signature", async () => { + const secret = generateSecret(); + const timestamp = Date.now(); + const valid = await verifyAuthHeader(secret, `${timestamp}:`); + + expect(valid).toBe(false); +}); + +test("verifyAuthHeader fails with non-numeric timestamp", async () => { + const secret = generateSecret(); + const signature = await signMessage(secret, "not-a-number"); + const valid = await verifyAuthHeader(secret, `not-a-number:${signature}`); + + expect(valid).toBe(false); +}); diff --git a/src/auth.ts b/src/auth.ts index 8aa6f79..34bf476 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1 +1,128 @@ // Device authentication and HMAC signing + +/** + * Generates a random device secret (32 bytes, base64 encoded) + */ +export function generateSecret(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Buffer.from(bytes).toString("base64"); +} + +/** + * Creates an HMAC-SHA256 signature for a message + */ +export async function signMessage( + secret: string, + message: string, +): Promise { + const key = await crypto.subtle.importKey( + "raw", + Buffer.from(secret, "base64"), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + const signature = await crypto.subtle.sign( + "HMAC", + key, + Buffer.from(message, "utf-8"), + ); + + return Buffer.from(signature).toString("base64"); +} + +/** + * Verifies an HMAC-SHA256 signature using constant-time comparison + */ +export async function verifySignature( + secret: string, + message: string, + signature: string, +): Promise { + const expected = await signMessage(secret, message); + + // Constant-time comparison to prevent timing attacks + if (expected.length !== signature.length) { + return false; + } + + const expectedBytes = Buffer.from(expected, "base64"); + const signatureBytes = Buffer.from(signature, "base64"); + + if (expectedBytes.length !== signatureBytes.length) { + return false; + } + + let result = 0; + for (let i = 0; i < expectedBytes.length; i++) { + // biome-ignore lint/style/noNonNullAssertion: loop bounds ensure this exists + result |= expectedBytes[i]! ^ signatureBytes[i]!; + } + + return result === 0; +} + +/** + * Creates an auth header: "timestamp:signature" + * The signature is HMAC-SHA256 of the timestamp string + */ +export async function createAuthHeader( + secret: string, + timestamp: number, +): Promise { + const timestampStr = timestamp.toString(); + const signature = await signMessage(secret, timestampStr); + return `${timestampStr}:${signature}`; +} + +/** + * Verifies an auth header and checks timestamp freshness + * @param secret - Device secret + * @param header - Auth header in format "timestamp:signature" + * @param maxAge - Maximum age in milliseconds (default: 5 minutes) + * @returns true if valid and fresh, false otherwise + */ +export async function verifyAuthHeader( + secret: string, + header: string, + maxAge = 5 * 60 * 1000, // 5 minutes default +): Promise { + const parts = header.split(":"); + if (parts.length !== 2) { + return false; + } + + const [timestampStr, signature] = parts; + if (!timestampStr || !signature) { + return false; + } + + // Verify the signature + const valid = await verifySignature(secret, timestampStr, signature); + if (!valid) { + return false; + } + + // Check timestamp freshness + const timestamp = Number.parseInt(timestampStr, 10); + if (Number.isNaN(timestamp)) { + return false; + } + + const now = Date.now(); + const age = now - timestamp; + + // Reject timestamps from the future (with 1 minute tolerance for clock skew) + if (age < -60_000) { + return false; + } + + // Reject timestamps older than maxAge + if (age > maxAge) { + return false; + } + + return true; +}