- 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>
196 lines
6.4 KiB
TypeScript
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;
|
|
}
|