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:@std/assert@*": "1.0.19",
|
||||
"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.6": "1.0.19",
|
||||
"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
|
||||
await sql`
|
||||
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 {
|
||||
computeJwkThumbprint,
|
||||
verifyHttpSignature,
|
||||
} from "./http_signatures.ts";
|
||||
|
||||
app.get("/api/forward-auth", async (c) => {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!auth) {
|
||||
return c.text("Unauthorized", 401);
|
||||
@ -1457,6 +1486,86 @@ app.delete("/api/admin/aaguid/:id", async (c) => {
|
||||
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)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user