163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
const ASSERTION_DOMAIN = "nekonest-cloud/relay-mtls-identity/v1";
|
|
const MAX_ASSERTION_SKEW_SECONDS = 30;
|
|
|
|
export type TrustedRelayMtlsIdentity = {
|
|
nodeId: string;
|
|
spiffeId: string;
|
|
certificateFingerprintSha256: string;
|
|
};
|
|
|
|
export const AUTHENTICATE_RELAY_NODE_IDENTITY_SQL = `
|
|
UPDATE relay_node_credentials
|
|
SET last_used_at = ?1
|
|
WHERE node_id = ?2
|
|
AND mtls_spiffe_id = ?3
|
|
AND certificate_fingerprint_sha256 = ?4
|
|
AND status = 'active'
|
|
AND issued_at <= ?1
|
|
AND revoked_at IS NULL
|
|
AND (expires_at IS NULL OR expires_at > ?1)
|
|
AND EXISTS (
|
|
SELECT 1 FROM relay_nodes
|
|
WHERE id = ?2 AND status IN ('active', 'draining')
|
|
)`;
|
|
|
|
export class RelayMtlsIdentityError extends Error {
|
|
readonly code: "relay_mtls_unavailable" | "relay_mtls_identity_invalid";
|
|
readonly status: 401 | 503;
|
|
|
|
constructor(
|
|
code: "relay_mtls_unavailable" | "relay_mtls_identity_invalid",
|
|
message: string,
|
|
status: 401 | 503,
|
|
) {
|
|
super(message);
|
|
this.code = code;
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
function assertionTranscript(input: {
|
|
method: string;
|
|
pathname: string;
|
|
nodeId: string;
|
|
spiffeId: string;
|
|
certificateFingerprintSha256: string;
|
|
timestampSeconds: number;
|
|
}): Uint8Array {
|
|
return new TextEncoder().encode([
|
|
ASSERTION_DOMAIN,
|
|
input.method.toUpperCase(),
|
|
input.pathname,
|
|
input.nodeId,
|
|
input.spiffeId,
|
|
input.certificateFingerprintSha256,
|
|
String(input.timestampSeconds),
|
|
].join("\0"));
|
|
}
|
|
|
|
function bytesToHex(bytes: Uint8Array): string {
|
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
function equalHex(left: string, right: string): boolean {
|
|
let difference = left.length ^ right.length;
|
|
const length = Math.max(left.length, right.length);
|
|
for (let index = 0; index < length; index += 1) {
|
|
difference |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
|
|
}
|
|
return difference === 0;
|
|
}
|
|
|
|
export async function createTrustedRelayMtlsAssertion(input: {
|
|
assertionSecret: string;
|
|
method: string;
|
|
pathname: string;
|
|
nodeId: string;
|
|
spiffeId: string;
|
|
certificateFingerprintSha256: string;
|
|
timestampSeconds: number;
|
|
}): Promise<string> {
|
|
if (input.assertionSecret.length < 32) throw new TypeError("relay_mtls_assertion_secret_too_short");
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
new TextEncoder().encode(input.assertionSecret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const signature = await crypto.subtle.sign(
|
|
"HMAC",
|
|
key,
|
|
Uint8Array.from(assertionTranscript(input)).buffer,
|
|
);
|
|
return bytesToHex(new Uint8Array(signature));
|
|
}
|
|
|
|
/**
|
|
* Workers cannot inspect a client certificate directly. The production mTLS
|
|
* terminator must strip all x-neko-mtls-* headers from untrusted traffic,
|
|
* verify the certificate, and inject this short-lived HMAC assertion.
|
|
*/
|
|
export async function verifyTrustedRelayMtlsIdentity(input: {
|
|
request: Request;
|
|
assertionSecret: string;
|
|
nowMs?: number;
|
|
}): Promise<TrustedRelayMtlsIdentity> {
|
|
if (input.assertionSecret.length < 32) {
|
|
throw new RelayMtlsIdentityError(
|
|
"relay_mtls_unavailable",
|
|
"Relay mTLS ingress assertion is not configured",
|
|
503,
|
|
);
|
|
}
|
|
const headers = input.request.headers;
|
|
const nodeId = headers.get("x-neko-relay-node-id")?.trim() ?? "";
|
|
const spiffeId = headers.get("x-neko-mtls-spiffe-id")?.trim() ?? "";
|
|
const certificateFingerprintSha256 =
|
|
headers.get("x-neko-mtls-cert-sha256")?.trim().toLowerCase() ?? "";
|
|
const verified = headers.get("x-neko-mtls-verified")?.trim() ?? "";
|
|
const timestampRaw = headers.get("x-neko-mtls-timestamp")?.trim() ?? "";
|
|
const assertion = headers.get("x-neko-mtls-assertion")?.trim().toLowerCase() ?? "";
|
|
const timestampSeconds = Number(timestampRaw);
|
|
if (
|
|
verified !== "SUCCESS" ||
|
|
!/^node_[A-Za-z0-9._:-]{1,96}$/u.test(nodeId) ||
|
|
!/^spiffe:\/\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]{3,240}$/u.test(spiffeId) ||
|
|
!/^[0-9a-f]{64}$/u.test(certificateFingerprintSha256) ||
|
|
!/^[0-9a-f]{64}$/u.test(assertion) ||
|
|
!Number.isSafeInteger(timestampSeconds)
|
|
) {
|
|
throw new RelayMtlsIdentityError(
|
|
"relay_mtls_identity_invalid",
|
|
"Relay mTLS identity is invalid",
|
|
401,
|
|
);
|
|
}
|
|
const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1_000);
|
|
if (Math.abs(nowSeconds - timestampSeconds) > MAX_ASSERTION_SKEW_SECONDS) {
|
|
throw new RelayMtlsIdentityError(
|
|
"relay_mtls_identity_invalid",
|
|
"Relay mTLS assertion has expired",
|
|
401,
|
|
);
|
|
}
|
|
const expected = await createTrustedRelayMtlsAssertion({
|
|
assertionSecret: input.assertionSecret,
|
|
method: input.request.method,
|
|
pathname: new URL(input.request.url).pathname,
|
|
nodeId,
|
|
spiffeId,
|
|
certificateFingerprintSha256,
|
|
timestampSeconds,
|
|
});
|
|
if (!equalHex(assertion, expected)) {
|
|
throw new RelayMtlsIdentityError(
|
|
"relay_mtls_identity_invalid",
|
|
"Relay mTLS assertion is invalid",
|
|
401,
|
|
);
|
|
}
|
|
return { nodeId, spiffeId, certificateFingerprintSha256 };
|
|
}
|