Compare commits
No commits in common. "95ef5964071c277f66632680babc63778de05b2a" and "de2dcc85f7ab4dab2d0783dcce12dfd677f1f436" have entirely different histories.
95ef596407
...
de2dcc85f7
1
deno.lock
generated
1
deno.lock
generated
@ -12,7 +12,6 @@
|
|||||||
"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,16 +196,6 @@ 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)
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
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,10 +963,6 @@ 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");
|
||||||
@ -981,32 +977,7 @@ 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 OR HTTP Signature
|
// 2. Validate Session
|
||||||
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);
|
||||||
@ -1486,86 +1457,6 @@ 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