Compare commits
2 Commits
de2dcc85f7
...
95ef596407
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ef596407 | |||
|
|
182c789e05 |
1
deno.lock
generated
1
deno.lock
generated
@ -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",
|
||||||
|
|||||||
10
server/db.ts
10
server/db.ts
@ -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)
|
||||||
|
|||||||
44
server/http_signatures.test.ts
Normal file
44
server/http_signatures.test.ts
Normal 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
195
server/http_signatures.ts
Normal 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;
|
||||||
|
}
|
||||||
111
server/main.ts
111
server/main.ts
@ -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)
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user