Presence: the publisher sequence is in-process, the state root keeps no owner-only file, an Idunn generation never self-enrolls

nextPublisherSequence read and rewrote <dataRoot>/runtime-presence-sequence at
mode 0600, which the next generation (a fresh DynamicUser uid in the shared
state group) cannot open. Odin compares publisher_sequence only within one
activation and Idunn mints a new activation per start, so PublisherSequence
derives it from the observation clock, never below the previous value plus
one, as Muninn does. openProviderHealthIdentity under
GAMECULT_IDUNN_RUNTIME_BUNDLE now refuses when the presence descriptor is
absent instead of enrolling an identity Idunn does not trust.

Adds the cross-generation test (root only) and the sequence, no-dataRoot and
missing-descriptor tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 14:25:11 +02:00
commit 55732a901d
8 changed files with 351 additions and 32 deletions

View file

@ -1,4 +1,3 @@
import path from "node:path";
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
@ -9,7 +8,7 @@ import type { CultNetOperationServer } from "cultnet-ts";
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
import {
buildPresenceStatement,
nextPublisherSequence,
PublisherSequence,
readRuntimeBundleFacts,
type RuntimeBundleFacts,
} from "./runtime-presence.js";
@ -76,6 +75,7 @@ async function publishVerseState(): Promise<void> {
* work that can only ever return the same answer or fail.
*/
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
const presenceSequence = new PublisherSequence();
async function publishHealthState(): Promise<void> {
const pulse = createHeimdallRuntimePulse(config);
@ -111,9 +111,7 @@ async function publishHealthState(): Promise<void> {
// refuses the pair, and Idunn needs the warming statement to promote it.
writeLeaseSha256: null,
observedAtUnixMillis,
publisherSequence: await nextPublisherSequence(
path.join(config.dataRoot, "runtime-presence-sequence"),
),
publisherSequence: presenceSequence.next(observedAtUnixMillis),
providerHealthIdentityPath: config.providerHealthIdentityPath,
});

View file

@ -128,6 +128,14 @@ export async function openProviderHealthIdentity(
): Promise<PrivateIdentity> {
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
if (descriptor === undefined) {
if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) {
// Inside an Idunn generation a self-enrolled key is one Idunn does not
// trust, and its private store would land in a state root the next
// generation must share. The descriptor is the only identity there.
throw new Error(
`Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll`
);
}
return openOrEnrollProviderHealthIdentity(path);
}

View file

@ -20,7 +20,6 @@
import path from "node:path";
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
import { readFileSync } from "node:fs";
import { readFile, writeFile, rename } from "node:fs/promises";
import { decode } from "@msgpack/msgpack";
import {
@ -201,35 +200,25 @@ export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyO
}
/**
* The publisher sequence must strictly increase for the life of a signer
* identity, because Idunn treats a repeat or a regression as a replay and
* refuses the observation.
* The publisher sequence a process stamps on its presence statements.
*
* An in-process counter is therefore not enough: a restart would reset it and
* every statement afterwards would be refused until the count climbed past the
* old high-water mark. It lives beside the target's state instead, and is
* advanced before use.
* Odin compares `publisher_sequence` only against stored presence of the same
* activation, and Idunn mints a fresh activation for every start, continuity
* restarts included. So the sequence need only strictly increase within this
* process; nothing has to survive a restart, and nothing is written to the
* state root. Muninn derives its sequence the same way: the observation clock,
* never below the previous value plus one.
*/
export async function nextPublisherSequence(sequencePath: string): Promise<number> {
let current = 0;
try {
const raw = (await readFile(sequencePath, "utf8")).trim();
const parsed = Number(raw);
if (Number.isSafeInteger(parsed) && parsed > 0) {
current = parsed;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
export class PublisherSequence {
private last = 0;
next(observedAtUnixMillis: number): number {
if (!Number.isSafeInteger(observedAtUnixMillis)) {
throw new Error("observedAtUnixMillis is not a safe integer");
}
this.last = Math.max(observedAtUnixMillis, this.last + 1);
return this.last;
}
const next = current + 1;
// Written through a temporary file: a torn write here would look like a
// regression and cost the target every future observation.
const temporary = `${sequencePath}.next`;
await writeFile(temporary, `${next}\n`, { mode: 0o600 });
await rename(temporary, sequencePath);
return next;
}
export interface PresenceStatementInputs {

72
tests/fixtures/presence-descriptors.ts vendored Normal file
View file

@ -0,0 +1,72 @@
/**
* 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.
*/
import { closeSync, openSync } from "node:fs";
import { chmod, mkdir, writeFile } 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 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",
};
/** Write both stores 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 });
}
/** 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],
] as const;
const names: string[] = [];
for (const [fd, name] of fds) {
while (names.length < fd - 3) names.push(`pad-${names.length}`);
names[fd - 3] = name;
}
return {
env: { LISTEN_FDNAMES: names.join(":") },
close: () => fds.forEach(([fd]) => closeSync(fd)),
};
}

View file

@ -0,0 +1,58 @@
/**
* One Idunn generation, as a process: it runs as the uid and gid its parent
* gave it, under UMask=0007, and does what Heimdall does to a state root on a
* pulse. The parent test spawns two of these under different uids that share
* a group.
*
* argv: <role: first|next> <stateRoot> <fixtureDir>
*/
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 { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
process.umask(0o007);
const config = loadConfig({
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
GC_ACCESS_DATA_ROOT: stateRoot,
});
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,
});
} finally {
descriptors.close();
}
}
if (role === "next") {
// Generation N+1 reads what N left before it replaces it.
const previous = await readFile(config.cultCachePath);
if (previous.length === 0) throw new Error("previous generation's snapshot is empty");
}
await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config));
await publishPresence();
if (role === "first") {
// The 0600 counter the 2026-09-07 generation left behind. Nothing reads it
// now; it stays as the stale fixture the next generation must live beside.
await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 });
}

View file

@ -1,4 +1,4 @@
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { openSync, closeSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@ -112,3 +112,29 @@ describe("presence identity descriptor selection", () => {
});
});
});
describe("an Idunn generation without the presence identity descriptor", () => {
it("refuses, naming the descriptor, and enrolls nothing", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-"));
const identityPath = path.join(dir, "provider-health-identity.cc");
await expect(
openProviderHealthIdentity(identityPath, {
GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle",
LISTEN_FDNAMES: "some-other-descriptor",
})
).rejects.toThrow("gamecult-runtime-presence-identity");
await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("still self-enrolls outside Idunn", async () => {
if (!machineIdAvailable) return;
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-"));
const identityPath = path.join(dir, "provider-health-identity.cc");
await openProviderHealthIdentity(identityPath, {});
await expect(access(identityPath)).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,87 @@
import { mkdtemp, readdir, readFile, 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
);
describe("PublisherSequence", () => {
it("starts at the observed millis", () => {
expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000);
});
it("strictly increases for equal, earlier and later observations", () => {
const sequence = new PublisherSequence();
const first = sequence.next(1_700_000_000_000);
const equal = sequence.next(1_700_000_000_000);
const earlier = sequence.next(1_699_999_990_000);
const later = sequence.next(1_700_000_050_000);
expect([first, equal, earlier, later]).toEqual([
1_700_000_000_000,
1_700_000_000_001,
1_700_000_000_002,
1_700_000_050_000,
]);
});
it("never falls below the observed millis once the clock moves ahead", () => {
const sequence = new PublisherSequence();
sequence.next(1_700_000_000_000);
sequence.next(1_700_000_000_000);
sequence.next(1_700_000_000_000);
expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003);
expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010);
});
it("refuses a value that is not a safe integer and keeps its place", () => {
const sequence = new PublisherSequence();
expect(sequence.next(10)).toBe(10);
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) {
expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis");
}
expect(sequence.next(5)).toBe(11);
});
});
describe.skipIf(!machineIdAvailable)("presence publication and the state root", () => {
it("touches no path under dataRoot", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
await writePresenceFixtures(path.join(dir, "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");
await writeFile(dataRoot, "");
const config = loadConfig({
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
GC_ACCESS_DATA_ROOT: dataRoot,
});
const descriptors = openPresenceDescriptors(path.join(dir, "fixtures"));
try {
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");
} finally {
descriptors.close();
}
expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]);
});
});

View file

@ -0,0 +1,81 @@
import { execFile } from "node:child_process";
import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { writePresenceFixtures } from "./fixtures/presence-descriptors.js";
const run = promisify(execFile);
const STATE_GID = 4242;
const GENERATION_N = 61184;
const GENERATION_N_PLUS_1 = 61185;
// Creating uids and groups needs root; the stopgap's node container runs as root.
const isRoot = process.getuid?.() === 0;
async function walk(dir: string): Promise<string[]> {
const found: string[] = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
found.push(full);
if (entry.isDirectory()) found.push(...(await walk(full)));
}
return found;
}
/**
* Idunn gives every generation a fresh DynamicUser uid and one shared group,
* UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after
* N and must be able to read and replace everything N left there.
*/
describe.skipIf(!isRoot)("Idunn state root across generations", () => {
it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => {
const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-"));
await chmod(base, 0o755);
// The fixtures stand in for what Idunn passes as descriptors.
const fixtures = path.join(base, "fixtures");
await writePresenceFixtures(fixtures);
const stateRoot = path.join(base, "state");
await mkdir(stateRoot);
await chown(stateRoot, 0, STATE_GID);
await chmod(stateRoot, 0o2770);
const child = path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts");
const generation = (role: "first" | "next", uid: number) =>
run(process.execPath, ["--import", "tsx", child, role, stateRoot, fixtures], {
uid,
gid: STATE_GID,
cwd: path.resolve(import.meta.dirname, ".."),
env: { PATH: process.env.PATH ?? "", HOME: fixtures },
});
await generation("first", GENERATION_N);
const left = await walk(stateRoot);
expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([
"cultcache",
path.join("cultcache", "heimdall.service.cc"),
"runtime-presence-sequence",
]);
await generation("next", GENERATION_N_PLUS_1);
// Generation N+1 replaced the snapshot, and no owner-only file is left
// behind for generation N+2: everything but the stale 0600 fixture is
// readable and writable by the state group.
const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc"));
expect(snapshot.uid).toBe(GENERATION_N_PLUS_1);
expect(snapshot.gid).toBe(STATE_GID);
for (const entry of await walk(stateRoot)) {
if (path.basename(entry) === "runtime-presence-sequence") continue;
expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060);
}
expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]);
}, 60_000);
});