clarc/src/auth.test.ts
Jared Miller 59f21cd062
Add HMAC authentication with comprehensive tests
Implements device authentication using HMAC-SHA256 signatures with constant-time comparison for security. Includes timestamp-based auth headers with configurable freshness checking (default 5min) and clock skew tolerance.
2026-01-28 11:14:54 -05:00

262 lines
7.8 KiB
TypeScript

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);
});