Compare commits

...

2 Commits

Author SHA1 Message Date
95ef596407
Merge pull request #13 from mrteye/feat-http-sigs-7822098764133354425
feat: implement RFC 9421 HTTP message signatures
2026-08-23 22:36:13 -07:00
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
6 changed files with 360 additions and 1 deletions

1
deno.lock generated
View File

@ -12,6 +12,7 @@
"jsr:@simplewebauthn/server@13": "13.3.2", "jsr:@simplewebauthn/server@13": "13.3.2",
"jsr:@std/assert@*": "1.0.19", "jsr:@std/assert@*": "1.0.19",
"jsr:@std/assert@0.226": "0.226.0", "jsr:@std/assert@0.226": "0.226.0",
"jsr:@std/assert@1": "1.0.19",
"jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/assert@^1.0.19": "1.0.19",
"jsr:@std/assert@~1.0.6": "1.0.19", "jsr:@std/assert@~1.0.6": "1.0.19",
"jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@1": "1.0.10",

View File

@ -196,6 +196,16 @@ export async function initDb(): Promise<void> {
); );
`; `;
await sql`
CREATE TABLE IF NOT EXISTS hwk_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fingerprint TEXT UNIQUE NOT NULL,
public_key JSONB NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`;
// Seed ed-droid app record with spiffe_id // Seed ed-droid app record with spiffe_id
await sql` await sql`
INSERT INTO apps (name, spiffe_id) INSERT INTO apps (name, spiffe_id)

View File

@ -0,0 +1,44 @@
import { assertEquals, assertRejects } from "jsr:@std/assert@1";
import {
computeJwkThumbprint,
verifyHttpSignature,
} from "./http_signatures.ts";
Deno.test("computeJwkThumbprint generates correct RFC 7638 thumbprint", async () => {
const jwk = {
kty: "OKP",
crv: "Ed25519",
x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
};
const fingerprint = await computeJwkThumbprint(jwk);
// Hash of {"crv":"Ed25519","kty":"OKP","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}
assertEquals(fingerprint.length, 64);
assertEquals(typeof fingerprint, "string");
assertEquals(
fingerprint,
"90facafea9b1556698540f70c0117a22ea37bd5cf3ed3c47093c1707282b4b89",
);
});
Deno.test("verifyHttpSignature mock test - invalid headers", async () => {
const req = new Request("http://localhost/api/test");
await assertRejects(
() => verifyHttpSignature(req),
Error,
"Missing HTTP Message Signature headers",
);
});
Deno.test("verifyHttpSignature mock test - bad Signature-Input format", async () => {
const req = new Request("http://localhost/api/test", {
headers: {
"Signature-Input": "bad format",
"Signature": "sig1=:base64:",
},
});
await assertRejects(
() => verifyHttpSignature(req),
Error,
"Invalid Signature-Input format",
);
});

195
server/http_signatures.ts Normal file
View File

@ -0,0 +1,195 @@
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;
}

View File

@ -963,6 +963,10 @@ app.post("/api/admin/users/:id/status", async (c) => {
// --------------------------------------------------------- // ---------------------------------------------------------
import { getAppByHost, getUserGrant } from "./auth-session.ts"; import { getAppByHost, getUserGrant } from "./auth-session.ts";
import {
computeJwkThumbprint,
verifyHttpSignature,
} from "./http_signatures.ts";
app.get("/api/forward-auth", async (c) => { app.get("/api/forward-auth", async (c) => {
const host = c.req.header("X-Forwarded-Host"); const host = c.req.header("X-Forwarded-Host");
@ -977,7 +981,32 @@ app.get("/api/forward-auth", async (c) => {
return c.text("Forbidden: Application not registered", 403); return c.text("Forbidden: Application not registered", 403);
} }
// 2. Validate Session // 2. Validate Session OR HTTP Signature
const signatureInput = c.req.header("Signature-Input");
const signature = c.req.header("Signature");
if (signatureInput && signature) {
// Headless Edge Node Path (RFC 9421)
try {
const fingerprint = await verifyHttpSignature(c.req.raw);
// Look up key name from postgres if needed, but fingerprint string manipulation is fast enough
const serviceName = `service-node:${fingerprint.substring(0, 8)}`;
const serviceId = fingerprint;
const scopes = "edge-node,daemon";
c.header("X-Forwarded-User", serviceName);
c.header("X-Forwarded-User-Id", serviceId);
c.header("X-Forwarded-Scopes", scopes);
c.header("X-Forwarded-App-Id", appRecord.id);
return c.text("OK", 200);
} catch (err: any) {
return c.text(`Unauthorized: ${err.message}`, 401);
}
}
// Standard User Session Path
const auth = await getAuthenticatedUser(c); const auth = await getAuthenticatedUser(c);
if (!auth) { if (!auth) {
return c.text("Unauthorized", 401); return c.text("Unauthorized", 401);
@ -1457,6 +1486,86 @@ app.delete("/api/admin/aaguid/:id", async (c) => {
return c.json({ success: true }); return c.json({ success: true });
}); });
// ---------------------------------------------------------
// Admin HWK (Header Web Key) Management
// ---------------------------------------------------------
app.post("/api/admin/hwk", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
if (!(await isGlobalAdmin(auth.userId))) {
return c.json({ error: "Forbidden" }, 403);
}
const { jwk, name } = await c.req.json();
if (!jwk || !name || typeof name !== "string") {
return c.json({ error: "Missing required fields: jwk, name" }, 400);
}
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x) {
return c.json({ error: "Invalid JWK: Must be an Ed25519 OKP key" }, 400);
}
try {
const fingerprint = await computeJwkThumbprint(jwk);
// 1. Dual Storage: PostgreSQL (Durability)
await sqlWrapper.sql`
INSERT INTO hwk_keys (fingerprint, public_key, name)
VALUES (${fingerprint}, ${JSON.stringify(jwk)}, ${name})
`;
// 2. Dual Storage: Valkey (O(1) Verification)
await valkey.sadd("auth:hwk:fingerprints", fingerprint);
auditWrapper.auditLog(
auth.userId,
"hwk_added",
null,
{ fingerprint, name },
getClientIp(c),
);
return c.json({ success: true, fingerprint }, 201);
} catch (err: any) {
if (err.code === "23505") {
return c.json({ error: "This key has already been registered" }, 409);
}
console.error("Failed to add HWK:", err);
return c.json({ error: "Internal server error" }, 500);
}
});
app.delete("/api/admin/hwk/:fingerprint", async (c) => {
const auth = await getAuthenticatedUser(c);
if (!auth) return c.json({ error: "Unauthorized" }, 401);
if (!(await isGlobalAdmin(auth.userId))) {
return c.json({ error: "Forbidden" }, 403);
}
const fingerprint = c.req.param("fingerprint");
// 1. Remove from PostgreSQL
const record = await sqlWrapper.sql`
DELETE FROM hwk_keys WHERE fingerprint = ${fingerprint} RETURNING id, name
`.then((res: any) => res[0]);
if (record) {
// 2. Remove from Valkey
try {
await valkey.srem("auth:hwk:fingerprints", fingerprint);
} catch (_err) {}
auditWrapper.auditLog(auth.userId, "hwk_removed", null, {
fingerprint,
name: record.name,
}, getClientIp(c));
return c.json({ success: true });
}
return c.json({ error: "Key not found" }, 404);
});
// --------------------------------------------------------- // ---------------------------------------------------------
// Global Session and Device Revocation (Admin) // Global Session and Device Revocation (Admin)
// --------------------------------------------------------- // ---------------------------------------------------------