218 lines
8.8 KiB
JavaScript
218 lines
8.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import {
|
|
generateKeyPairSync,
|
|
randomBytes,
|
|
sign,
|
|
} from "node:crypto";
|
|
import { chmod, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
createManagedProvisionerRequestVerifier,
|
|
loadManagedProvisionerPublicKeyFile,
|
|
MANAGED_PROVISIONER_HEADERS,
|
|
managedProvisionerSigningPayload,
|
|
ManagedProvisionerReplayCache,
|
|
sha256RawBody,
|
|
} from "../src/managed-provisioner-auth.mjs";
|
|
|
|
const serviceId = "nodedc-engine";
|
|
const keyId = "engine-edp-managed-provisioner-v1";
|
|
const audience = "nodedc-external-data-plane.managed-provisioning.v1";
|
|
const nowMs = Date.parse("2026-07-16T12:00:00.000Z");
|
|
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
let nonceSequence = 0;
|
|
|
|
const replayCache = new ManagedProvisionerReplayCache({ maxEntries: 100 });
|
|
const verify = createManagedProvisionerRequestVerifier({
|
|
publicKey,
|
|
serviceId,
|
|
keyId,
|
|
audience,
|
|
maxSkewMs: 60_000,
|
|
replayCache,
|
|
now: () => nowMs,
|
|
});
|
|
|
|
const valid = signedRequest();
|
|
assert.deepEqual(verify(valid), { serviceId, keyId });
|
|
assert.throws(() => verify(valid), hasCode("managed_provisioner_request_replayed", 409));
|
|
|
|
const whitespaceTamper = signedRequest();
|
|
whitespaceTamper.rawBody = Buffer.from(`${whitespaceTamper.rawBody.toString("utf8")} `, "utf8");
|
|
assert.throws(() => verify(whitespaceTamper), hasCode("managed_provisioner_body_hash_mismatch", 401));
|
|
|
|
const methodTamper = signedRequest();
|
|
methodTamper.method = "POST";
|
|
assert.throws(() => verify(methodTamper), hasCode("managed_provisioner_signature_invalid", 401));
|
|
|
|
const pathTamper = signedRequest();
|
|
pathTamper.originalUrl = `${pathTamper.originalUrl}?unexpected=true`;
|
|
assert.throws(() => verify(pathTamper), hasCode("managed_provisioner_signature_invalid", 401));
|
|
|
|
const audienceTamper = signedRequest();
|
|
setHeader(audienceTamper, MANAGED_PROVISIONER_HEADERS.audience, "other-audience");
|
|
assert.throws(() => verify(audienceTamper), hasCode("managed_provisioner_identity_mismatch", 401));
|
|
|
|
const serviceTamper = signedRequest();
|
|
setHeader(serviceTamper, MANAGED_PROVISIONER_HEADERS.serviceId, "other-engine");
|
|
assert.throws(() => verify(serviceTamper), hasCode("managed_provisioner_identity_mismatch", 401));
|
|
const keyTamper = signedRequest();
|
|
setHeader(keyTamper, MANAGED_PROVISIONER_HEADERS.keyId, "other-key-v1");
|
|
assert.throws(() => verify(keyTamper), hasCode("managed_provisioner_identity_mismatch", 401));
|
|
|
|
const forbiddenBearer = signedRequest();
|
|
forbiddenBearer.headers.authorization = "Bearer value-that-must-not-appear-in-errors";
|
|
forbiddenBearer.rawHeaders.push("Authorization", forbiddenBearer.headers.authorization);
|
|
assert.throws(
|
|
() => verify(forbiddenBearer),
|
|
(error) => hasCode("managed_provisioner_authorization_header_forbidden", 401)(error)
|
|
&& !error.message.includes("value-that-must-not-appear-in-errors"),
|
|
);
|
|
|
|
const stale = signedRequest({ timestamp: new Date(nowMs - 60_000).toISOString() });
|
|
assert.throws(() => verify(stale), hasCode("managed_provisioner_timestamp_outside_window", 401));
|
|
const future = signedRequest({ timestamp: new Date(nowMs + 60_000).toISOString() });
|
|
assert.throws(() => verify(future), hasCode("managed_provisioner_timestamp_outside_window", 401));
|
|
const invalidTimestamp = signedRequest();
|
|
setHeader(invalidTimestamp, MANAGED_PROVISIONER_HEADERS.timestamp, "2026-07-16T12:00:00Z");
|
|
assert.throws(() => verify(invalidTimestamp), hasCode("managed_provisioner_timestamp_invalid", 401));
|
|
|
|
const duplicateHeader = signedRequest();
|
|
duplicateHeader.rawHeaders.push(MANAGED_PROVISIONER_HEADERS.nonce, header(duplicateHeader, MANAGED_PROVISIONER_HEADERS.nonce));
|
|
assert.throws(() => verify(duplicateHeader), hasCode("managed_provisioner_header_invalid", 401));
|
|
|
|
const uncaptured = signedRequest();
|
|
uncaptured.rawBodyCaptured = false;
|
|
uncaptured.headers["content-length"] = String(uncaptured.rawBody.length);
|
|
assert.throws(() => verify(uncaptured), hasCode("managed_provisioner_raw_body_unavailable", 401));
|
|
|
|
const badSignature = signedRequest();
|
|
setHeader(badSignature, MANAGED_PROVISIONER_HEADERS.signature, randomBytes(64).toString("base64url"));
|
|
assert.throws(() => verify(badSignature), hasCode("managed_provisioner_signature_invalid", 401));
|
|
const paddedSignature = signedRequest();
|
|
setHeader(
|
|
paddedSignature,
|
|
MANAGED_PROVISIONER_HEADERS.signature,
|
|
`${header(paddedSignature, MANAGED_PROVISIONER_HEADERS.signature)}=`,
|
|
);
|
|
assert.throws(() => verify(paddedSignature), hasCode("managed_provisioner_signature_invalid", 401));
|
|
const uppercaseHash = signedRequest();
|
|
setHeader(
|
|
uppercaseHash,
|
|
MANAGED_PROVISIONER_HEADERS.bodySha256,
|
|
header(uppercaseHash, MANAGED_PROVISIONER_HEADERS.bodySha256).toUpperCase(),
|
|
);
|
|
assert.throws(() => verify(uppercaseHash), hasCode("managed_provisioner_body_hash_invalid", 401));
|
|
|
|
const cache = new ManagedProvisionerReplayCache({ maxEntries: 1 });
|
|
cache.consume("first", nowMs + 1000, nowMs);
|
|
assert.throws(() => cache.consume("second", nowMs + 1000, nowMs), hasCode("managed_provisioner_replay_cache_exhausted", 503));
|
|
assert.throws(() => cache.consume("first", nowMs + 1000, nowMs), hasCode("managed_provisioner_request_replayed", 409));
|
|
cache.consume("second", nowMs + 2000, nowMs + 1000);
|
|
|
|
const directory = await mkdtemp(join(tmpdir(), "nodedc-edp-managed-auth-"));
|
|
try {
|
|
const publicKeyPath = join(directory, "engine-public-key.pem");
|
|
const rsaPublicKeyPath = join(directory, "rsa-public-key.pem");
|
|
const privateKeyPath = join(directory, "engine-private-key.pem");
|
|
const symlinkPath = join(directory, "engine-public-key-link.pem");
|
|
await writeFile(publicKeyPath, publicKey.export({ type: "spki", format: "pem" }), { mode: 0o600 });
|
|
assert.equal(loadManagedProvisionerPublicKeyFile(publicKeyPath).asymmetricKeyType, "ed25519");
|
|
assert.throws(
|
|
() => loadManagedProvisionerPublicKeyFile("relative-public-key.pem"),
|
|
/external_data_plane_managed_provisioner_public_key_file_must_be_absolute/,
|
|
);
|
|
assert.throws(
|
|
() => loadManagedProvisionerPublicKeyFile(join(directory, "missing.pem")),
|
|
/external_data_plane_managed_provisioner_public_key_file_unreadable/,
|
|
);
|
|
|
|
await symlink(publicKeyPath, symlinkPath);
|
|
assert.throws(
|
|
() => loadManagedProvisionerPublicKeyFile(symlinkPath),
|
|
/external_data_plane_managed_provisioner_public_key_file_must_be_regular/,
|
|
);
|
|
|
|
await chmod(publicKeyPath, 0o622);
|
|
assert.throws(
|
|
() => loadManagedProvisionerPublicKeyFile(publicKeyPath),
|
|
/external_data_plane_managed_provisioner_public_key_file_permissions_invalid/,
|
|
);
|
|
await chmod(publicKeyPath, 0o600);
|
|
|
|
const { publicKey: rsaPublicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
await writeFile(rsaPublicKeyPath, rsaPublicKey.export({ type: "spki", format: "pem" }), { mode: 0o600 });
|
|
assert.throws(
|
|
() => loadManagedProvisionerPublicKeyFile(rsaPublicKeyPath),
|
|
/external_data_plane_managed_provisioner_public_key_algorithm_invalid/,
|
|
);
|
|
|
|
await writeFile(privateKeyPath, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
|
|
assert.throws(
|
|
() => loadManagedProvisionerPublicKeyFile(privateKeyPath),
|
|
/external_data_plane_managed_provisioner_public_key_file_format_invalid/,
|
|
);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log("external-data-plane managed provisioner auth: ok");
|
|
|
|
function signedRequest({
|
|
method = "PUT",
|
|
path = "/internal/data-plane/v1/writer-bindings/by-key/engine.test.positions",
|
|
rawBody = Buffer.from('{"generation":1}', "utf8"),
|
|
timestamp = new Date(nowMs).toISOString(),
|
|
nonce = nextNonce(),
|
|
} = {}) {
|
|
const bodySha256 = sha256RawBody(rawBody);
|
|
const signature = sign(null, Buffer.from(managedProvisionerSigningPayload({
|
|
audience,
|
|
serviceId,
|
|
keyId,
|
|
method,
|
|
path,
|
|
timestamp,
|
|
nonce,
|
|
bodySha256,
|
|
}), "utf8"), privateKey).toString("base64url");
|
|
const headers = {
|
|
[MANAGED_PROVISIONER_HEADERS.serviceId]: serviceId,
|
|
[MANAGED_PROVISIONER_HEADERS.keyId]: keyId,
|
|
[MANAGED_PROVISIONER_HEADERS.audience]: audience,
|
|
[MANAGED_PROVISIONER_HEADERS.timestamp]: timestamp,
|
|
[MANAGED_PROVISIONER_HEADERS.nonce]: nonce,
|
|
[MANAGED_PROVISIONER_HEADERS.bodySha256]: bodySha256,
|
|
[MANAGED_PROVISIONER_HEADERS.signature]: signature,
|
|
"content-length": String(rawBody.length),
|
|
};
|
|
return {
|
|
method,
|
|
originalUrl: path,
|
|
headers,
|
|
rawHeaders: Object.entries(headers).flat(),
|
|
rawBody,
|
|
rawBodyCaptured: true,
|
|
};
|
|
}
|
|
|
|
function nextNonce() {
|
|
nonceSequence += 1;
|
|
return Buffer.from(`managed-auth-test-${String(nonceSequence).padStart(6, "0")}`).toString("base64url");
|
|
}
|
|
|
|
function header(req, name) {
|
|
return req.headers[name];
|
|
}
|
|
|
|
function setHeader(req, name, value) {
|
|
req.headers[name] = value;
|
|
const index = req.rawHeaders.findIndex((candidate) => String(candidate).toLowerCase() === name);
|
|
req.rawHeaders[index + 1] = value;
|
|
}
|
|
|
|
function hasCode(code, status) {
|
|
return (error) => error?.code === code && error?.status === status;
|
|
}
|