Files
nekonest-cloud/db/relay-node-identity.ts
T

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 };
}