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.
This commit is contained in:
parent
e61bcb544d
commit
59f21cd062
2 changed files with 389 additions and 0 deletions
262
src/auth.test.ts
Normal file
262
src/auth.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
127
src/auth.ts
127
src/auth.ts
|
|
@ -1 +1,128 @@
|
||||||
// Device authentication and HMAC signing
|
// 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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue