auth-yes/server/http_signatures.ts
google-labs-jules[bot] 182c789e05 feat(auth): implement RFC 9421 HTTP Message Signatures
- Added native Deno WebCrypto Ed25519 signature verification middleware for headless edge workloads.
- Integrated dual authentication path to `/api/forward-auth` processing signatures and session cookies.
- Added dual storage Admin Management routes (`/api/admin/hwk`) securely inserting directly to PostgreSQL and pushing to $O(1)$ Valkey verification set.
- Completed all quality gates checks and hermetic mocked tests successfully.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-24 05:36:01 +00:00

196 lines
6.4 KiB
TypeScript

import { valkey } from "./valkey.ts";
/**
* Computes the RFC 7638 JWK Thumbprint of an Ed25519 OKP key.
* This canonicalizes the JWK by ordering the keys: crv, kty, x
*/
export async function computeJwkThumbprint(
jwk: { kty: string; crv: string; x: string },
): Promise<string> {
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
throw new Error("Invalid Ed25519 JWK");
}
// RFC 7638 requires exact key ordering and no extraneous whitespace
const canonicalJwk = `{"crv":"Ed25519","kty":"OKP","x":"${jwk.x}"}`;
const data = new TextEncoder().encode(canonicalJwk);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hex;
}
/**
* Extracts and verifies an RFC 9421 HTTP Message Signature on a given Request.
* Returns the fingerprint if verified, or throws an error.
*/
export async function verifyHttpSignature(req: Request): Promise<string> {
const signatureInput = req.headers.get("Signature-Input");
const signature = req.headers.get("Signature");
if (!signatureInput || !signature) {
throw new Error("Missing HTTP Message Signature headers");
}
// Very naive parser for Signature-Input
// Expected format: sig1=("@method" "@path" "@authority" "content-digest");created=1690000000;expires=1690000300;nonce="xyz";keyid="hwk"
// Actually, the spec uses `hwk` parameter directly, or we can look for hwk=... in the input.
// Since this is a complex spec, let's implement a simplified but rigorous parser for the specific headless edge use case.
const sigMatch = signatureInput.match(/sig1=\(([^)]+)\)(.*)/);
if (!sigMatch) {
throw new Error("Invalid Signature-Input format");
}
const componentsStr = sigMatch[1]; // e.g. "@method" "@authority" "@path"
const paramsStr = sigMatch[2]; // e.g. ;created=123;nonce="abc";hwk="ey..."
const components = componentsStr.split(" ").map((s) => s.replace(/"/g, ""));
const params = new Map<string, string>();
const paramRegex = /;([a-z]+)=([^;]+)/g;
let match;
while ((match = paramRegex.exec(paramsStr)) !== null) {
let val = match[2];
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1);
}
params.set(match[1], val);
}
// 1. Time bounds validation
const created = parseInt(params.get("created") || "0", 10);
const expires = parseInt(params.get("expires") || "0", 10);
if (!created || !expires) {
throw new Error("Missing created or expires timestamp");
}
const now = Math.floor(Date.now() / 1000);
// Max drift ±30 seconds
if (Math.abs(now - created) > 30) {
throw new Error("Signature created timestamp out of drift bounds");
}
if (now > expires) {
throw new Error("Signature expired");
}
// 2. Parse HWK
const hwkBase64 = params.get("hwk");
if (!hwkBase64) {
throw new Error("Missing inline hwk parameter");
}
let hwk;
try {
const hwkJson = atob(hwkBase64.replace(/-/g, "+").replace(/_/g, "/"));
hwk = JSON.parse(hwkJson);
} catch (_err) {
throw new Error("Invalid base64url or JSON in hwk parameter");
}
if (hwk.kty !== "OKP" || hwk.crv !== "Ed25519" || !hwk.x) {
throw new Error("Only Ed25519 OKP keys are supported");
}
const fingerprint = await computeJwkThumbprint(hwk);
// 3. Check Valkey Authorization First (O(1) abort before expensive crypto)
try {
const isMember = await valkey.sismember(
"auth:hwk:fingerprints",
fingerprint,
);
if (isMember !== 1) {
throw new Error("Fingerprint not authorized");
}
} catch (err: any) {
if (err.message.includes("Fingerprint not authorized")) {
throw err;
}
// Fail closed on redis error
console.error("Valkey error during signature verification:", err);
throw new Error("Internal server error");
}
// 4. Build Canonical Signature Base
const url = new URL(req.url);
let signatureBase = "";
// Support Traefik forward-auth edge proxy headers for original request metadata
const xForwardedMethod = req.headers.get("X-Forwarded-Method");
const xForwardedUri = req.headers.get("X-Forwarded-Uri");
const xForwardedHost = req.headers.get("X-Forwarded-Host");
const originalMethod = xForwardedMethod || req.method;
const originalPath = xForwardedUri
? new URL(xForwardedUri, "http://localhost").pathname +
new URL(xForwardedUri, "http://localhost").search
: url.pathname + url.search;
const originalHost = xForwardedHost || req.headers.get("host") || url.host;
for (const comp of components) {
if (comp === "@method") {
signatureBase += `"@method": ${originalMethod.toLowerCase()}\n`;
} else if (comp === "@path") {
signatureBase += `"@path": ${originalPath}\n`;
} else if (comp === "@authority") {
signatureBase += `"@authority": ${originalHost}\n`;
} else {
const headerVal = req.headers.get(comp);
if (headerVal === null) {
throw new Error(`Missing required signature component: ${comp}`);
}
signatureBase += `"${comp}": ${headerVal}\n`;
}
}
// Add signature parameters line
signatureBase += `"@signature-params": (${componentsStr})${paramsStr}`;
// 5. Verify the signature
// Note: we need to parse the `Signature` header which typically looks like `sig1=:base64...:`
const sigValueMatch = signature.match(/sig1=:([^:]+):/);
if (!sigValueMatch) {
throw new Error("Invalid Signature header format");
}
const rawSigValue = sigValueMatch[1];
let sigBytes;
try {
// Deno handles standard base64 using atob, for base64url we'd need to handle padding/chars.
// Assuming standard base64 for the signature block per RFC 8941 Byte Sequences
const sigStr = atob(rawSigValue.replace(/-/g, "+").replace(/_/g, "/"));
sigBytes = new Uint8Array(sigStr.length);
for (let i = 0; i < sigStr.length; i++) {
sigBytes[i] = sigStr.charCodeAt(i);
}
} catch {
throw new Error("Invalid base64 encoding in signature");
}
const importedKey = await crypto.subtle.importKey(
"jwk",
hwk,
{ name: "Ed25519" },
false,
["verify"],
);
const msgBytes = new TextEncoder().encode(signatureBase);
const isValid = await crypto.subtle.verify(
"Ed25519",
importedKey,
sigBytes,
msgBytes,
);
if (!isValid) {
throw new Error("Invalid cryptographic signature");
}
return fingerprint;
}