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