Heimdall opens its Idunn presence authority once, before it listens
index.ts declared the presence sequence below the startup try, so the bundle's hoisted var was undefined at the first publish; decodePrivateIdentity refused the Rust owner's integer-array key fields; both descriptors were re-read every pulse and returned nothing after the first. openPresenceAuthority now reads the bundle facts and each descriptor once (whole, from offset 0, then closed), owns the one publisher sequence, and signs through them; a launch-contract fault throws before anything listens, so the process exits non-zero. decodePrivateIdentity takes the shape of its source and names the failed check, never a value. openActivationSigner and the per-pulse descriptor reads are deleted. tests/idunn-boot.test.ts boots the bundle under the Idunn launch contract and checks the signed warming frames and the fail-fast exits. The descriptor, sequence and generation tests now use the Rust-written fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c0d30e8081
commit
c044155855
8 changed files with 608 additions and 226 deletions
79
tests/fixtures/presence-descriptors.ts
vendored
79
tests/fixtures/presence-descriptors.ts
vendored
|
|
@ -1,64 +1,45 @@
|
|||
/**
|
||||
* What Idunn hands a generation for runtime presence: a parent-only identity
|
||||
* store and an activation seed, passed as descriptors named in LISTEN_FDNAMES.
|
||||
* Every key here is generated for the test or a canary byte pattern.
|
||||
* What Idunn hands a generation for runtime presence: the bundle directory, a
|
||||
* parent-only identity store and an activation seed, the last two passed as
|
||||
* descriptors named in LISTEN_FDNAMES. The bytes are the Rust owner's, copied
|
||||
* from CultLib (see idunn-runtime/README.md); every key in them is a test key.
|
||||
*/
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, copyFile, mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
|
||||
import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts";
|
||||
|
||||
import { openOrEnrollProviderHealthIdentity } from "../../src/provider-health-identity.js";
|
||||
import type { RuntimeBundleFacts } from "../../src/runtime-presence.js";
|
||||
export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime");
|
||||
|
||||
export const bundleFacts: RuntimeBundleFacts = {
|
||||
target: "heimdall",
|
||||
planId: "plan-1",
|
||||
incarnationId: "incarnation-1",
|
||||
sealedReleaseId: "release-1",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeInstanceId: "instance-1",
|
||||
expectedProjectionSha256: "sha256-projection",
|
||||
activationWitnessSha256: "sha256-witness",
|
||||
activationSignerIdentityId: "activation-signer",
|
||||
stateSchemaGeneration: null,
|
||||
stateContractSha256: null,
|
||||
capabilities: [],
|
||||
healthContract: "health-contract",
|
||||
};
|
||||
const FIXTURE_FILES = [
|
||||
"expected.cc",
|
||||
"activation.cc",
|
||||
"activation-key.seed",
|
||||
"provider-identity.credential",
|
||||
"machine-id",
|
||||
] as const;
|
||||
|
||||
/** Write both stores into `dir`, readable by any uid (the children run as others). */
|
||||
/** The machine id the fixture identity is bound to, in place of /etc/machine-id. */
|
||||
export const fixtureMachineId = async (dir: string = idunnRuntimeFixtures): Promise<string> =>
|
||||
(await readFile(path.join(dir, "machine-id"), "utf8")).trim();
|
||||
|
||||
/** Copy the fixtures into `dir`, readable by any uid (the children run as others). */
|
||||
export async function writePresenceFixtures(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
const enrolled = path.join(dir, "enrolled.cc");
|
||||
await openOrEnrollProviderHealthIdentity(enrolled);
|
||||
const [record] = await new SingleFileMessagePackBackingStore(enrolled).pullAll();
|
||||
// Re-wrapped the way cultcache-rs writes a store, which is how Idunn writes it.
|
||||
await writeFile(
|
||||
path.join(dir, "runtime-presence-identity.cc"),
|
||||
encode([
|
||||
[
|
||||
"gamecult-provider-health-identity",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
record!.payload,
|
||||
"2026-09-06T00:00:00.000Z",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
],
|
||||
]),
|
||||
{ mode: 0o644 }
|
||||
);
|
||||
await writeFile(path.join(dir, "activation-seed"), Buffer.alloc(32, 7), { mode: 0o644 });
|
||||
for (const name of FIXTURE_FILES) {
|
||||
const target = path.join(dir, name);
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), target);
|
||||
await chmod(target, 0o644);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */
|
||||
export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } {
|
||||
const fds = [
|
||||
[openSync(path.join(dir, "runtime-presence-identity.cc"), "r"), "gamecult-runtime-presence-identity"],
|
||||
[openSync(path.join(dir, "activation-seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
|
||||
[openSync(path.join(dir, "provider-identity.credential"), "r"), "gamecult-runtime-presence-identity"],
|
||||
[openSync(path.join(dir, "activation-key.seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
|
||||
] as const;
|
||||
const names: string[] = [];
|
||||
for (const [fd, name] of fds) {
|
||||
|
|
@ -67,6 +48,14 @@ export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv;
|
|||
}
|
||||
return {
|
||||
env: { LISTEN_FDNAMES: names.join(":") },
|
||||
close: () => fds.forEach(([fd]) => closeSync(fd)),
|
||||
// The authority closes each descriptor it reads; this releases only what it did not.
|
||||
close: () =>
|
||||
fds.forEach(([fd]) => {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// already closed by the reader
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
27
tests/fixtures/state-root-generation-child.ts
vendored
27
tests/fixtures/state-root-generation-child.ts
vendored
|
|
@ -10,8 +10,8 @@ import { readFile, writeFile } from "node:fs/promises";
|
|||
import path from "node:path";
|
||||
|
||||
import { loadConfig } from "../../src/config.js";
|
||||
import { PublisherSequence, buildPresenceStatement } from "../../src/runtime-presence.js";
|
||||
import { bundleFacts, openPresenceDescriptors } from "./presence-descriptors.js";
|
||||
import { openPresenceAuthority } from "../../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors } from "./presence-descriptors.js";
|
||||
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
|
||||
|
||||
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
|
||||
|
|
@ -25,18 +25,17 @@ const config = loadConfig({
|
|||
async function publishPresence(): Promise<void> {
|
||||
const descriptors = openPresenceDescriptors(fixtureDir);
|
||||
try {
|
||||
const observedAtUnixMillis = Date.now();
|
||||
await buildPresenceStatement({
|
||||
bundle: bundleFacts,
|
||||
state: "warming",
|
||||
detail: "generation probe",
|
||||
boundEndpoint: null,
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: new PublisherSequence().next(observedAtUnixMillis),
|
||||
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
|
||||
env: descriptors.env,
|
||||
});
|
||||
const authority = await openPresenceAuthority(
|
||||
{
|
||||
idunnRuntimeBundlePath: fixtureDir,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
|
||||
},
|
||||
descriptors.env,
|
||||
() => fixtureMachineId(fixtureDir),
|
||||
);
|
||||
authority.sign("warming", "generation probe", Date.now());
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
|
|
|
|||
302
tests/idunn-boot.test.ts
Normal file
302
tests/idunn-boot.test.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createPublicKey, createPrivateKey, verify } from "node:crypto";
|
||||
import dgram from "node:dgram";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import {
|
||||
RUNTIME_PRESENCE_SLOT,
|
||||
parseCultNetMessage,
|
||||
runtimePresenceActivationSigningMessage,
|
||||
runtimePresenceProviderSigningMessage,
|
||||
} from "cultnet-ts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { startFakeIdunnRudp, type FakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
import { idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const GENERATION_UID = 61184;
|
||||
const STATE_GID = 4242;
|
||||
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
||||
// The launch needs root (to pick the uid and bind a machine-id) and a mount
|
||||
// namespace (so the fixture machine-id does not replace the host's). The
|
||||
// stopgap's node container has both; anywhere else the test steps aside.
|
||||
const canLaunch =
|
||||
process.getuid?.() === 0 &&
|
||||
(await run("unshare", ["--mount", "--", "true"]).then(
|
||||
() => true,
|
||||
() => false
|
||||
));
|
||||
|
||||
/**
|
||||
* The artifact under test. By default the esbuild bundle `pnpm bundle` makes,
|
||||
* run by this process's node; a runbook proves a sealed release by naming its
|
||||
* bundle and node here instead.
|
||||
*/
|
||||
const bundleOverride = process.env.HEIMDALL_BOOT_BUNDLE;
|
||||
const nodeOverride = process.env.HEIMDALL_BOOT_NODE;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const { port } = probe.address() as net.AddressInfo;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface Launch {
|
||||
child: ChildProcess;
|
||||
stderr: () => string;
|
||||
exited: Promise<number | null>;
|
||||
candidatePort: number;
|
||||
}
|
||||
|
||||
describe.skipIf(!canLaunch)("the bundle booted as Idunn launches it", () => {
|
||||
let base: string;
|
||||
let bundle: string;
|
||||
let node: string;
|
||||
let idunn: FakeIdunnRudp;
|
||||
let odin: dgram.Socket;
|
||||
let odinPort: number;
|
||||
const launched: ChildProcess[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
base = await mkdtemp(path.join(os.tmpdir(), "heimdall-boot-"));
|
||||
await chmod(base, 0o755);
|
||||
if (bundleOverride) {
|
||||
bundle = bundleOverride;
|
||||
} else {
|
||||
await run("corepack", ["pnpm", "bundle"], { cwd: path.join(import.meta.dirname, "..") });
|
||||
bundle = path.join(base, "heimdall.mjs");
|
||||
await copyFile(path.join(import.meta.dirname, "..", "dist", "heimdall"), bundle);
|
||||
await chmod(bundle, 0o644);
|
||||
}
|
||||
node = nodeOverride ?? process.execPath;
|
||||
idunn = await startFakeIdunnRudp();
|
||||
odin = dgram.createSocket("udp4");
|
||||
await new Promise<void>((resolve) => odin.bind(0, "127.0.0.1", resolve));
|
||||
odinPort = odin.address().port;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
for (const child of launched) child.kill("SIGKILL");
|
||||
idunn?.close();
|
||||
odin?.close();
|
||||
});
|
||||
|
||||
/** Credential files as Idunn holds them: readable only by the parent. */
|
||||
async function credentials(dir: string, identity: Buffer | undefined) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const seed = path.join(dir, "activation-key.seed");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "activation-key.seed"), seed);
|
||||
await chmod(seed, 0o400);
|
||||
let identityFile: string | undefined;
|
||||
if (identity) {
|
||||
identityFile = path.join(dir, "provider-identity.credential");
|
||||
await writeFile(identityFile, identity, { mode: 0o400 });
|
||||
}
|
||||
return { seed, identityFile };
|
||||
}
|
||||
|
||||
/** Start the bundle as the generation unit does, with the fixture machine-id bound over the host's. */
|
||||
async function launch(label: string, identity: Buffer | undefined): Promise<Launch> {
|
||||
const dir = path.join(base, label);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
|
||||
// Idunn's runtime bundle: the two records, read-only.
|
||||
const bundleDir = path.join(dir, "bundle");
|
||||
await mkdir(bundleDir);
|
||||
for (const name of ["expected.cc", "activation.cc"]) {
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), path.join(bundleDir, name));
|
||||
await chmod(path.join(bundleDir, name), 0o444);
|
||||
}
|
||||
await chmod(bundleDir, 0o555);
|
||||
|
||||
const machineId = path.join(dir, "machine-id");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "machine-id"), machineId);
|
||||
await chmod(machineId, 0o444);
|
||||
|
||||
const stateRoot = path.join(dir, "state");
|
||||
await mkdir(stateRoot);
|
||||
await run("chown", [`0:${STATE_GID}`, stateRoot]);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
const heimdall = path.join(dir, "heimdall.mjs");
|
||||
await copyFile(bundle, heimdall);
|
||||
await chmod(heimdall, 0o444);
|
||||
|
||||
const files = await credentials(path.join(dir, "credentials"), identity);
|
||||
const candidatePort = await freePort();
|
||||
|
||||
const fds = [openSync(files.seed, "r")];
|
||||
const names = ["gamecult-idunn-runtime-activation-key"];
|
||||
if (files.identityFile) {
|
||||
fds.push(openSync(files.identityFile, "r"));
|
||||
names.push("gamecult-runtime-presence-identity");
|
||||
}
|
||||
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
LISTEN_FDS: String(fds.length),
|
||||
LISTEN_FDNAMES: names.join(":"),
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: `127.0.0.1:${candidatePort}`,
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: bundleDir,
|
||||
GAMECULT_IDUNN_PROCESS_WRITE_LEASE: path.join(dir, "process-write-lease.cc"),
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.test",
|
||||
GC_ACCESS_ISSUER: "https://heimdall.test",
|
||||
GC_ACCESS_STORAGE_BACKEND: "memory",
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${idunn.port}`,
|
||||
GC_ACCESS_IDUNN_HEALTH_CONTRACT: "heimdall.cultnet-rudp-provider-health",
|
||||
GC_ACCESS_ODIN_CULTMESH_URI: `rudp://127.0.0.1:${odinPort}`,
|
||||
CULTMESH_URI_ODIN_RUDP: `rudp://127.0.0.1:${odinPort}`,
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: path.join(stateRoot, "signing.pem"),
|
||||
GC_ACCESS_SIGNING_KEY_ID: "boot-test",
|
||||
};
|
||||
|
||||
// uid, gid and umask as Idunn's DynamicUser unit gives them. The fixture
|
||||
// machine-id is bound only inside this mount namespace.
|
||||
const script =
|
||||
`mount --bind ${machineId} /etc/machine-id && ` +
|
||||
`exec setpriv --reuid ${GENERATION_UID} --regid ${STATE_GID} --clear-groups ` +
|
||||
`sh -c 'umask 0007; cd ${dir}; exec ${node} heimdall.mjs --state-root ${stateRoot}'`;
|
||||
const child = spawn("unshare", ["--mount", "--", "sh", "-c", script], {
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "pipe", ...fds],
|
||||
});
|
||||
launched.push(child);
|
||||
fds.forEach((fd) => closeSync(fd));
|
||||
|
||||
let stderr = "";
|
||||
child.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
return { child, stderr: () => stderr, exited, candidatePort };
|
||||
}
|
||||
|
||||
async function answers(port: number): Promise<number | undefined> {
|
||||
return fetch(`http://127.0.0.1:${port}/.well-known/jwks.json`).then(
|
||||
(response) => response.status,
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
it("signs warming presence on every pulse and answers HTTP", async () => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const generation = await launch("signs", credential);
|
||||
|
||||
// Startup publication, then one per 10 s pulse.
|
||||
await Promise.race([
|
||||
idunn.frame(2),
|
||||
generation.exited.then((code) => {
|
||||
throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(await answers(generation.candidatePort)).toBe(200);
|
||||
|
||||
// Public keys of the two proofs, from the fixtures' test keys.
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const providerKey = createPublicKey({
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(record[2] as number[])]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
const activationKey = createPublicKey(
|
||||
createPrivateKey({
|
||||
key: Buffer.concat([
|
||||
PKCS8_ED25519_SEED_PREFIX,
|
||||
await readFile(path.join(idunnRuntimeFixtures, "activation-key.seed")),
|
||||
]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
})
|
||||
);
|
||||
|
||||
let previousSequence = 0;
|
||||
for (const frame of idunn.frames.slice(0, 3)) {
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") throw new Error("not a raw document put");
|
||||
const fields = decode(message.document.payload) as unknown[];
|
||||
expect(fields[RUNTIME_PRESENCE_SLOT.state]).toBe("warming");
|
||||
|
||||
const unsigned = [...fields];
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.signature] = new Uint8Array();
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.activationSignature] = new Uint8Array();
|
||||
const proof = encode(unsigned);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceProviderSigningMessage(proof),
|
||||
providerKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.signature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
activationKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.activationSignature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const sequence = fields[RUNTIME_PRESENCE_SLOT.publisherSequence] as number;
|
||||
expect(sequence).toBeGreaterThan(previousSequence);
|
||||
previousSequence = sequence;
|
||||
}
|
||||
|
||||
expect(generation.stderr()).not.toContain("failed on startup");
|
||||
expect(generation.stderr()).not.toContain("failed on interval");
|
||||
generation.child.kill("SIGKILL");
|
||||
}, 90_000);
|
||||
|
||||
it.each([
|
||||
["the identity descriptor is not passed", undefined, "gamecult-runtime-presence-identity"],
|
||||
["the identity is damaged", "damaged", "identity id check"],
|
||||
])("exits before listening when %s", async (_name, damage, expected) => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const publicKey = [...(record[2] as number[])];
|
||||
const original = { publicKeyHex: Buffer.from(publicKey).toString("hex"), id: record[1] as string, binding: record[5] as string };
|
||||
let identity: Buffer | undefined;
|
||||
if (damage) {
|
||||
publicKey[0] = publicKey[0]! ^ 1;
|
||||
record[2] = publicKey;
|
||||
identity = Buffer.from(encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
}
|
||||
const started = Date.now();
|
||||
const generation = await launch(damage ? "damaged" : "no-identity", identity);
|
||||
|
||||
const code = await Promise.race([
|
||||
generation.exited,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
|
||||
]);
|
||||
|
||||
expect(code, generation.stderr()).not.toBe("timeout");
|
||||
expect(code).not.toBe(0);
|
||||
expect(Date.now() - started).toBeLessThan(6_000);
|
||||
expect(await answers(generation.candidatePort)).toBeUndefined();
|
||||
expect(generation.stderr()).toContain(expected);
|
||||
// No byte of the credential: not the key bytes, not the id, not the binding.
|
||||
expect(generation.stderr()).not.toContain(original.publicKeyHex);
|
||||
expect(generation.stderr()).not.toContain(original.id.slice(0, 16));
|
||||
expect(generation.stderr()).not.toContain(original.binding.slice(-16));
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync, closeSync } from "node:fs";
|
||||
import { openSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
openOrEnrollProviderHealthIdentity,
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
providerHealthPublicIdentity,
|
||||
} from "../src/provider-health-identity.js";
|
||||
import { fixtureMachineId, idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
||||
() => true,
|
||||
|
|
@ -23,59 +22,79 @@ const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
|||
* The identity is machine-id bound, so enrolment only works where a machine-id
|
||||
* exists. These run on Linux and are skipped elsewhere.
|
||||
*/
|
||||
describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor", () => {
|
||||
it("reads an Idunn-written store, not a cultcache-ts one", async () => {
|
||||
/** Name the presence descriptor so it lands on whatever fd open() returned. */
|
||||
function presenceEnv(fd: number): NodeJS.ProcessEnv {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on the descriptor open() actually returned.
|
||||
// Assuming it is 3 is how this passes on one machine and reads the wrong
|
||||
// descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
return { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) };
|
||||
}
|
||||
|
||||
describe("presence identity from a passed descriptor", () => {
|
||||
it("decodes the Rust owner's record, whose key fields are integer arrays", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-"));
|
||||
const fd = openSync(path.join(idunnRuntimeFixtures, "provider-identity.credential"), "r");
|
||||
|
||||
// Enrol with the TypeScript client to get a genuine machine-bound payload,
|
||||
// then re-wrap that payload the way cultcache-rs writes a store: a bare
|
||||
// array of positional [key, type, payload, stored_at, schema_id] envelopes.
|
||||
// Idunn writes with the Rust client, and cultcache-ts cannot read that
|
||||
// layout, so wrapping it here is what makes this test resemble reality.
|
||||
const tsStore = path.join(dir, "ts-enrolled.cc");
|
||||
const enrolled = await openOrEnrollProviderHealthIdentity(tsStore);
|
||||
const records = await new SingleFileMessagePackBackingStore(tsStore).pullAll();
|
||||
expect(records).toHaveLength(1);
|
||||
|
||||
const idunnStore = path.join(dir, "runtime-presence-identity.cc");
|
||||
await writeFile(
|
||||
idunnStore,
|
||||
encode([
|
||||
[
|
||||
"gamecult-provider-health-identity",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
records[0]!.payload,
|
||||
"2026-09-06T00:00:00.000Z",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
],
|
||||
])
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
presenceEnv(fd),
|
||||
fixtureMachineId
|
||||
);
|
||||
|
||||
const fd = openSync(idunnStore, "r");
|
||||
try {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on whatever descriptor open() actually
|
||||
// returned. Assuming it is 3 is how this passes on one machine and reads
|
||||
// the wrong descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
// The fixture's own public key means it came from the descriptor. A fresh
|
||||
// self-enrolment would produce a different key, which is exactly the failure
|
||||
// that makes Idunn refuse this daemon's health and stall promotion.
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
Buffer.from(record[2] as number[]).toString("hex")
|
||||
);
|
||||
await expect(access(path.join(dir, "would-be-self-enrolled.cc"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
{ LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }
|
||||
);
|
||||
it("refuses a bin-shaped record on the descriptor path, naming the key-shape check and no value", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-bin-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
// What this process's own enroller writes: the same record with bin key fields.
|
||||
const binRecord = record.map((field, index) =>
|
||||
index === 2 || index === 3 ? Uint8Array.from(field as number[]) : field
|
||||
);
|
||||
const binStore = path.join(dir, "bin-shaped.cc");
|
||||
await writeFile(binStore, encode([[store[0]![0], store[0]![1], encode(binRecord), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(binStore, "r");
|
||||
|
||||
// Same key means it came from the descriptor. A fresh self-enrolment
|
||||
// would produce a different key -- which is exactly the failure that
|
||||
// makes Idunn refuse this daemon's health and stall promotion.
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
providerHealthPublicIdentity(enrolled).publicKeyHex
|
||||
);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const failure = await openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId).then(
|
||||
() => undefined,
|
||||
(error: Error) => error
|
||||
);
|
||||
|
||||
expect(failure?.message).toContain("key shape check");
|
||||
expect(failure?.message).not.toContain((record[1] as string).slice(0, 16));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["schema", 0, "bogus", "schema check"],
|
||||
["protector", 4, "bogus", "protector check"],
|
||||
["binding", 5, "bogus", "binding check"],
|
||||
["version", 6, "v9", "version check"],
|
||||
])("a record failing only the %s check is refused by that check", async (_name, index, replacement, check) => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-check-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
record[index] = replacement;
|
||||
const damaged = path.join(dir, "damaged.cc");
|
||||
await writeFile(damaged, encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(damaged, "r");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId)
|
||||
).rejects.toThrow(check);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
import { mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { PublisherSequence, buildPresenceStatement } from "../src/runtime-presence.js";
|
||||
import { bundleFacts, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
import { PublisherSequence, openPresenceAuthority } from "../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
describe("PublisherSequence", () => {
|
||||
it("starts at the observed millis", () => {
|
||||
|
|
@ -51,10 +46,11 @@ describe("PublisherSequence", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!machineIdAvailable)("presence publication and the state root", () => {
|
||||
it("touches no path under dataRoot", async () => {
|
||||
describe("the presence authority", () => {
|
||||
it("opens once, touches no path under dataRoot, and signs every pulse after its descriptors are gone", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
|
||||
await writePresenceFixtures(path.join(dir, "fixtures"));
|
||||
const fixtures = path.join(dir, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
// A regular file as dataRoot: opening anything beneath it fails with
|
||||
// ENOTDIR for every uid, root included.
|
||||
const dataRoot = path.join(dir, "not-a-directory");
|
||||
|
|
@ -64,21 +60,21 @@ describe.skipIf(!machineIdAvailable)("presence publication and the state root",
|
|||
GC_ACCESS_DATA_ROOT: dataRoot,
|
||||
});
|
||||
|
||||
const descriptors = openPresenceDescriptors(path.join(dir, "fixtures"));
|
||||
const descriptors = openPresenceDescriptors(fixtures);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{ ...config, idunnRuntimeBundlePath: fixtures },
|
||||
descriptors.env,
|
||||
fixtureMachineId,
|
||||
);
|
||||
// The descriptors were read whole and closed: a second read would find
|
||||
// nothing, so signing again proves the authority holds what it decoded.
|
||||
const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z");
|
||||
const statement = await buildPresenceStatement({
|
||||
bundle: bundleFacts,
|
||||
state: "warming",
|
||||
detail: "probe",
|
||||
boundEndpoint: null,
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: new PublisherSequence().next(observedAtUnixMillis),
|
||||
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
||||
env: descriptors.env,
|
||||
});
|
||||
expect(statement.target).toBe("heimdall");
|
||||
const first = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
const second = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
expect(first.target).toBe("heimdall");
|
||||
expect(second.target).toBe("heimdall");
|
||||
expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false);
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue