diff --git a/src/index.ts b/src/index.ts index 8ee966b..0bc13c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,14 +6,16 @@ import { publishHeimdallOdinState } from "./odin-publication.js"; import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js"; import type { CultNetOperationServer } from "cultnet-ts"; import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js"; -import { - buildPresenceStatement, - PublisherSequence, - readRuntimeBundleFacts, - type RuntimeBundleFacts, -} from "./runtime-presence.js"; +import { openPresenceAuthority } from "./runtime-presence.js"; const config = loadConfig(); +// Under Idunn the launch's presence keys, bundle facts and publisher sequence +// open here, before anything listens. A launch-contract fault throws, uncaught, +// and the process exits non-zero with no listener: Idunn fails Warming in +// seconds rather than waiting on a candidate that can never sign. +const presenceAuthority = config.idunnRuntimeBundlePath + ? await openPresenceAuthority(config) + : undefined; const app = await buildApp({ config }); const versePulseIntervalMs = 60_000; const healthPulseIntervalMs = 10_000; @@ -69,14 +71,6 @@ async function publishVerseState(): Promise { await publishHeimdallOdinState(config, pulse); } -/** - * Read once and keep: the bundle is Idunn's immutable record of this launch and - * cannot change while the process lives, so re-reading it every pulse would be - * work that can only ever return the same answer or fail. - */ -let runtimeBundleFacts: RuntimeBundleFacts | undefined; -const presenceSequence = new PublisherSequence(); - async function publishHealthState(): Promise { const pulse = createHeimdallRuntimePulse(config); @@ -94,30 +88,17 @@ async function publishHealthState(): Promise { ? buildHeimdallHealthDetail(config, pulse) : `Heimdall candidate warming; ${lease.reason}`; - if (!config.idunnRuntimeBundlePath) { + if (!presenceAuthority) { // Outside Idunn there is no launch to attest and nothing that admits the // statement; publishing is simply not part of that world. return; } - runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath); - - const observedAtUnixMillis = Date.parse(pulse.updatedAt); - const presence = await buildPresenceStatement({ - bundle: runtimeBundleFacts, - state: lease.mayWrite ? "active" : "warming", - detail, - boundEndpoint: `http://${config.host}:${config.port}`, - // A warming candidate holds no lease and must not claim one; the contract - // refuses the pair, and Idunn needs the warming statement to promote it. - writeLeaseSha256: null, - observedAtUnixMillis, - publisherSequence: presenceSequence.next(observedAtUnixMillis), - providerHealthIdentityPath: config.providerHealthIdentityPath, - }); + const state = lease.mayWrite ? "active" : "warming"; + const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt)); await publishIdunnRudpHealth(config, { daemonId: config.daemonId, - state: lease.mayWrite ? "active" : "warming", + state, detail, observedAt: pulse.updatedAt, presence, diff --git a/src/provider-health-identity.ts b/src/provider-health-identity.ts index ad4903f..ca89f2c 100644 --- a/src/provider-health-identity.ts +++ b/src/provider-health-identity.ts @@ -7,7 +7,7 @@ import { sign, type KeyObject, } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { closeSync, fstatSync, readSync } from "node:fs"; import { chmod, readFile } from "node:fs/promises"; import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts"; import { readPrivateStoreRecord } from "./idunn-store.js"; @@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise< if (existing.length !== 1) { throw new Error("Provider-health identity store must contain exactly one record."); } - return decodePrivateIdentity(existing[0]!.payload, await readMachineId()); + return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin"); } const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity"; @@ -124,7 +124,8 @@ export function passedDescriptor( */ export async function openProviderHealthIdentity( path: string, - env: NodeJS.ProcessEnv = process.env + env: NodeJS.ProcessEnv = process.env, + machineId: () => Promise = readMachineId ): Promise { const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env); if (descriptor === undefined) { @@ -140,11 +141,33 @@ export async function openProviderHealthIdentity( } const payload = readPrivateStoreRecord( - readFileSync(descriptor), + readPassedDescriptor(descriptor), "Idunn runtime presence identity" ); - return decodePrivateIdentity(payload, await readMachineId()); + // The descriptor's writer is the Rust owner, whose key fields are integer arrays. + return decodePrivateIdentity(payload, await machineId(), "int-array"); +} + +/** + * Read a passed descriptor whole, from offset 0, and close it. A descriptor is + * a one-shot input: reading it again starts at the offset the last read left, + * which is the end, so the owner reads once at open and keeps what it decoded. + */ +export function readPassedDescriptor(descriptor: number): Buffer { + try { + const size = fstatSync(descriptor).size; + const bytes = Buffer.alloc(size); + let read = 0; + while (read < size) { + const count = readSync(descriptor, bytes, read, size - read, read); + if (count === 0) break; + read += count; + } + return bytes.subarray(0, read); + } finally { + closeSync(descriptor); + } } export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array { @@ -176,23 +199,58 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): { return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") }; } -function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity { +/** + * Which writer produced the record. The self-enrolling store this process + * writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs + * service_identity.rs) serialises a Vec without serde_bytes, so its key + * fields are arrays of integers. Each source is read in its own shape and the + * other is refused. + */ +type KeyShape = "bin" | "int-array"; + +function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array { + const bytes = + shape === "bin" + ? value instanceof Uint8Array + ? value + : undefined + : Array.isArray(value) && + value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255) + ? Uint8Array.from(value as number[]) + : undefined; + if (bytes === undefined || bytes.length !== 32) { + throw new Error( + `Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.` + ); + } + return bytes; +} + +function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity { const value = decode(payload); if (!Array.isArray(value) || value.length !== 10) { - throw new Error("Provider-health identity payload is malformed."); + throw new Error("Provider-health identity schema check failed: payload is not a 10-field record."); } - const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value; - if ( - schema !== PRIVATE_SCHEMA || - typeof identityId !== "string" || - !(publicKey instanceof Uint8Array) || publicKey.length !== 32 || - !(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 || - protector !== "linux_file_mode_machine_id_binding" || - binding !== protectorBinding(machineId) || - version !== "v1" || - identityId !== deriveIdentityId(publicKey) - ) { - throw new Error("Provider-health identity does not belong to this host or profile."); + const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value; + if (schema !== PRIVATE_SCHEMA) { + throw new Error("Provider-health identity schema check failed: unexpected schema id."); + } + if (typeof identityId !== "string") { + throw new Error("Provider-health identity id check failed: id is not a string."); + } + const publicKey = keyBytes(publicKeyField, shape, "public key"); + const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed"); + if (protector !== "linux_file_mode_machine_id_binding") { + throw new Error("Provider-health identity protector check failed: unexpected protector."); + } + if (binding !== protectorBinding(machineId)) { + throw new Error("Provider-health identity binding check failed: not bound to this host's machine id."); + } + if (version !== "v1") { + throw new Error("Provider-health identity version check failed: unexpected version."); + } + if (identityId !== deriveIdentityId(publicKey)) { + throw new Error("Provider-health identity id check failed: id does not derive from the public key."); } const seed = xorSeed(protectedSeed, binding); const privateKey = privateKeyFromSeed(seed); diff --git a/src/runtime-presence.ts b/src/runtime-presence.ts index e3ed0b1..3c1054b 100644 --- a/src/runtime-presence.ts +++ b/src/runtime-presence.ts @@ -19,7 +19,6 @@ import path from "node:path"; import { createHash, createPrivateKey, type KeyObject } from "node:crypto"; -import { readFileSync } from "node:fs"; import { decode } from "@msgpack/msgpack"; import { @@ -33,11 +32,14 @@ import { type RuntimePresenceState, } from "cultnet-ts"; +import type { HeimdallConfig } from "./config.js"; import { readIdunnStoreRecord } from "./idunn-store.js"; import { openProviderHealthIdentity, passedDescriptor, + readPassedDescriptor, signWithIdentity, + type PrivateIdentity, } from "./provider-health-identity.js"; const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); @@ -176,29 +178,6 @@ export async function readRuntimeBundleFacts(bundlePath: string): Promise { - const env = inputs.env ?? process.env; - const activationKey = openActivationSigner(env); - if (activationKey === undefined) { - throw new Error( - "Idunn passed no runtime activation credential, so this process cannot prove it is the " + - "incarnation Idunn started", - ); - } - const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env); +export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement { + const { identity } = inputs; const presence: RuntimePresenceHealth = { target: inputs.bundle.target, @@ -284,7 +253,7 @@ export async function buildPresenceStatement( const proof = runtimePresenceProofPayload(presence); const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof)); const activationSignature = signWithIdentity( - { ...identity, privateKey: activationKey }, + { ...identity, privateKey: inputs.activationKey }, runtimePresenceActivationSigningMessage(proof), ); @@ -293,3 +262,72 @@ export async function buildPresenceStatement( payload: encodeRuntimePresenceHealth(presence, signature, activationSignature), }; } + +export interface PresenceAuthority { + sign( + state: RuntimePresenceState, + detail: string, + observedAtUnixMillis: number, + ): PresenceStatement; +} + +/** + * Open everything this launch's presence statements stand on, once, before the + * process listens: Idunn's bundle facts, the activation seed and the presence + * identity (each descriptor is read whole and closed), and the one publisher + * sequence. It owns them for the life of the process; a pulse supplies only its + * state, detail and observation time. + * + * A launch-contract fault (a missing or malformed bundle, descriptor or + * identity) throws here, so the process exits before it accepts a request and + * Idunn fails Warming at once, instead of a listener that can never sign. + */ +export async function openPresenceAuthority( + config: Pick, + env: NodeJS.ProcessEnv = process.env, + machineId?: () => Promise, +): Promise { + if (!config.idunnRuntimeBundlePath) { + throw new Error("no Idunn runtime bundle path: presence has no launch to attest"); + } + const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath); + + const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env); + if (activationDescriptor === undefined) { + throw new Error( + "Idunn passed no runtime activation credential, so this process cannot prove it is the " + + "incarnation Idunn started", + ); + } + const seed = readPassedDescriptor(activationDescriptor); + if (seed.length !== 32) { + throw new Error("Idunn runtime activation credential is not a 32-byte seed"); + } + const activationKey = createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]), + format: "der", + type: "pkcs8", + }); + + const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId); + const boundEndpoint = `http://${config.host}:${config.port}`; + const sequence = new PublisherSequence(); + + return { + sign: (state, detail, observedAtUnixMillis) => + buildPresenceStatement({ + bundle, + identity, + activationKey, + state, + detail, + boundEndpoint, + // A warming candidate holds no lease and must not claim one; the + // contract refuses the pair, and Idunn needs the warming statement to + // promote it. + writeLeaseSha256: null, + observedAtUnixMillis, + publisherSequence: sequence.next(observedAtUnixMillis), + }), + }; +} diff --git a/tests/fixtures/presence-descriptors.ts b/tests/fixtures/presence-descriptors.ts index 0448330..8e724f2 100644 --- a/tests/fixtures/presence-descriptors.ts +++ b/tests/fixtures/presence-descriptors.ts @@ -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 => + (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 { 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 + } + }), }; } diff --git a/tests/fixtures/state-root-generation-child.ts b/tests/fixtures/state-root-generation-child.ts index 0e53ade..932d513 100644 --- a/tests/fixtures/state-root-generation-child.ts +++ b/tests/fixtures/state-root-generation-child.ts @@ -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 { 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(); } diff --git a/tests/idunn-boot.test.ts b/tests/idunn-boot.test.ts new file mode 100644 index 0000000..d758910 --- /dev/null +++ b/tests/idunn-boot.test.ts @@ -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 { + 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; + 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((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 { + 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((resolve) => child.once("exit", (code) => resolve(code))); + return { child, stderr: () => stderr, exited, candidatePort }; + } + + async function answers(port: number): Promise { + 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); +}); diff --git a/tests/presence-identity-descriptor.test.ts b/tests/presence-identity-descriptor.test.ts index 731d16e..d01797b 100644 --- a/tests/presence-identity-descriptor.test.ts +++ b/tests/presence-identity-descriptor.test.ts @@ -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); }); }); diff --git a/tests/publisher-sequence.test.ts b/tests/publisher-sequence.test.ts index 9a4f147..efded2c 100644 --- a/tests/publisher-sequence.test.ts +++ b/tests/publisher-sequence.test.ts @@ -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(); }