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.
128 lines
3 KiB
TypeScript
128 lines
3 KiB
TypeScript
// 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;
|
|
}
|