feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user