From 97ea97695485e62cf6bac2bca77a6ad381dff772 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 7 Sep 2026 22:19:51 +0200 Subject: [PATCH] Publish runtime presence in the contract Odin admits Heimdall published `idunn.signed_daemon_health.v1`, the previous generation's contract. Odin admits a document as runtime presence only when it carries `gamecult.runtime_presence_health.v2` and its record key equals the signed target; everything else it stores as an ordinary document. So Heimdall's health arrived, was filed away, and no topology correlation for it was ever produced -- which is what held every deployment at Warming. The statement is dual-proved, so the process must also prove it is the incarnation Idunn started, using the activation key passed as a descriptor beside the presence identity. Every field describing the launch is read from the runtime bundle rather than configuration. Two things had to be fixed for that to be possible at all: The descriptor lookup compared LISTEN_PID to our own pid. Under PrivatePIDs systemd sets it to a pid in the outer namespace, so it never matched, and because the lookup answers "undefined" rather than throwing, Heimdall quietly self-enrolled a key nothing trusts -- exactly the failure its own comment warns about. The name in LISTEN_FDNAMES identifies the descriptor. The publisher sequence was an in-process counter starting at zero every boot. Idunn treats a repeat or regression as a replay, so a restart would have made every later statement refusable; it now lives beside the target's state. Co-Authored-By: Claude Opus 5 --- src/idunn-rudp-health.ts | 57 ++-- src/index.ts | 38 +++ src/provider-health-identity.ts | 40 ++- src/runtime-presence.ts | 306 +++++++++++++++++++++ tests/idunn-rudp-health.test.ts | 25 +- tests/presence-identity-descriptor.test.ts | 30 +- vendor/CultLib | 2 +- 7 files changed, 418 insertions(+), 80 deletions(-) create mode 100644 src/runtime-presence.ts diff --git a/src/idunn-rudp-health.ts b/src/idunn-rudp-health.ts index d9c3c01..135476e 100644 --- a/src/idunn-rudp-health.ts +++ b/src/idunn-rudp-health.ts @@ -1,7 +1,7 @@ import dgram from "node:dgram"; -import { randomUUID } from "node:crypto"; import { encode } from "@msgpack/msgpack"; import { + GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, CultNetRudpSession, decodeRudpPacket, encodeCultNetMessageForWire, @@ -10,19 +10,13 @@ import { type CultNetRudpPacket, } from "cultnet-ts"; import type { HeimdallConfig } from "./config.js"; -import { - openProviderHealthIdentity, - signProviderHealthPayload, -} from "./provider-health-identity.js"; +import type { PresenceStatement } from "./runtime-presence.js"; const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0"; const IDUNN_HEALTH_RUDP_CONNECTION_ID = 0x1d0d0001; const RUDP_HEALTH_CONNECT_ATTEMPTS = 3; const RUDP_ACCEPT_TIMEOUT_MS = 2_000; const RUDP_ACK_TIMEOUT_MS = 1_000; -const SIGNED_DAEMON_HEALTH_SCHEMA = "idunn.signed_daemon_health.v1"; -const publisherIncarnationId = randomUUID(); -let publisherSequence = 0; type Endpoint = { host: string; @@ -34,6 +28,8 @@ type IdunnHealthInput = { state: string; detail: string; observedAt: string; + /** The dual-proved statement, already built and signed. */ + presence: PresenceStatement; }; export async function publishIdunnRudpHealth(config: HeimdallConfig, health: IdunnHealthInput): Promise { @@ -91,42 +87,25 @@ async function publishIdunnRudpHealthOnce( } } -async function buildSignedDocumentPutPayload(config: HeimdallConfig, health: IdunnHealthInput): Promise { - const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath); - publisherSequence += 1; - const unsigned = [ - SIGNED_DAEMON_HEALTH_SCHEMA, - health.daemonId, - config.idunnHealthContract, - "heimdall-service", - health.state, - health.detail, - identity.identityId, - publisherIncarnationId, - publisherSequence, - Date.parse(health.observedAt), - null, - null, - null, - null, - "ed25519", - new Uint8Array(), - false, - ]; - const signature = signProviderHealthPayload(identity, encode(unsigned)); - const recordPayload = encode([...unsigned.slice(0, 15), signature, false]); +async function buildSignedDocumentPutPayload( + config: HeimdallConfig, + health: IdunnHealthInput, +): Promise { + // The record key must equal the target the statement signs. Odin refuses the + // pair when they differ, and it is the one part of the envelope the transport + // cannot infer from the payload without decoding it. const message: CultNetDocumentPutRawMessage = { schemaVersion: "cultnet.document_put_raw.v0", - messageId: `heimdall-health:${health.daemonId}:${health.observedAt.replace(/[:.]/g, "-")}`, + messageId: `heimdall-presence:${health.presence.target}:${health.observedAt.replace(/[:.]/g, "-")}`, document: { - schemaId: SIGNED_DAEMON_HEALTH_SCHEMA, - recordKey: health.daemonId, + schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, + recordKey: health.presence.target, storedAt: health.observedAt, payloadEncoding: "messagepack", - payload: recordPayload, - sourceRuntimeId: "heimdall-service", - sourceAgentId: identity.identityId, - sourceRole: "daemon-health-publisher", + payload: health.presence.payload, + sourceRuntimeId: config.idunnHealthContract, + sourceAgentId: health.presence.target, + sourceRole: "runtime-presence-publisher", tags: [CULTNET_RUDP_PROTOCOL_ID], }, }; diff --git a/src/index.ts b/src/index.ts index 8c34e86..4c89a70 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js"; import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; @@ -6,6 +7,12 @@ 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, + nextPublisherSequence, + readRuntimeBundleFacts, + type RuntimeBundleFacts, +} from "./runtime-presence.js"; const config = loadConfig(); const app = await buildApp({ config }); @@ -63,6 +70,13 @@ 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; + async function publishHealthState(): Promise { const pulse = createHeimdallRuntimePulse(config); @@ -80,11 +94,35 @@ async function publishHealthState(): Promise { ? buildHeimdallHealthDetail(config, pulse) : `Heimdall candidate warming; ${lease.reason}`; + if (!config.idunnRuntimeBundlePath) { + // 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: await nextPublisherSequence( + path.join(config.dataRoot, "runtime-presence-sequence"), + ), + providerHealthIdentityPath: config.providerHealthIdentityPath, + }); + await publishIdunnRudpHealth(config, { daemonId: config.daemonId, state: lease.mayWrite ? "active" : "warming", detail, observedAt: pulse.updatedAt, + presence, }); } diff --git a/src/provider-health-identity.ts b/src/provider-health-identity.ts index a4a4c64..16e5826 100644 --- a/src/provider-health-identity.ts +++ b/src/provider-health-identity.ts @@ -20,7 +20,7 @@ const SIGNING_PURPOSE = Buffer.from("idunn.signed_daemon_health.v1"); const PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); -type PrivateIdentity = { +export type PrivateIdentity = { identityId: string; publicKey: Uint8Array; privateKey: KeyObject; @@ -87,22 +87,24 @@ const SYSTEMD_LISTEN_FDS_START = 3; * cannot open the 0600 root-owned store, and a descriptor cannot be re-read by * anything that did not inherit it. */ -function presenceIdentityDescriptor( - env: NodeJS.ProcessEnv, - pid: number +export function passedDescriptor( + descriptorName: string, + env: NodeJS.ProcessEnv ): number | undefined { const names = env.LISTEN_FDNAMES; if (!names) { return undefined; } - // LISTEN_PID guards against consuming descriptors meant for a parent or a - // sibling; systemd sets it to the intended recipient. - if (env.LISTEN_PID && Number(env.LISTEN_PID) !== pid) { - return undefined; - } - - const index = names.split(":").indexOf(PRESENCE_IDENTITY_FD_NAME); + // LISTEN_PID cannot be compared to our own pid. Idunn launches candidates + // with PrivatePIDs=yes, and systemd sets LISTEN_PID to the pid it knows, + // which is in the *outer* namespace; inside, process.pid is the + // namespace-local one. The comparison could never hold, and because this + // function answers "undefined" rather than throwing, the caller quietly + // enrolled a self-signed identity instead -- exactly the failure the next + // doc comment warns about, and one that shows up only as health nobody + // admits. What identifies the descriptor is its name in LISTEN_FDNAMES. + const index = names.split(":").indexOf(descriptorName); return index === -1 ? undefined : SYSTEMD_LISTEN_FDS_START + index; } @@ -122,10 +124,9 @@ function presenceIdentityDescriptor( */ export async function openProviderHealthIdentity( path: string, - env: NodeJS.ProcessEnv = process.env, - pid: number = process.pid + env: NodeJS.ProcessEnv = process.env ): Promise { - const descriptor = presenceIdentityDescriptor(env, pid); + const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env); if (descriptor === undefined) { return openOrEnrollProviderHealthIdentity(path); } @@ -149,6 +150,17 @@ export function signProviderHealthPayload(identity: PrivateIdentity, payload: Ui return sign(null, message, identity.privateKey); } +/** + * Sign an already domain-separated message with this identity. + * + * The domain and purpose belong to the contract being signed, not to the key, + * so they are built by the caller from the shared CultNet helpers rather than + * assumed here. + */ +export function signWithIdentity(identity: PrivateIdentity, message: Buffer): Uint8Array { + return sign(null, message, identity.privateKey); +} + export function providerHealthPublicIdentity(identity: PrivateIdentity): { identityId: string; publicKeyHex: string; diff --git a/src/runtime-presence.ts b/src/runtime-presence.ts new file mode 100644 index 0000000..8cf0615 --- /dev/null +++ b/src/runtime-presence.ts @@ -0,0 +1,306 @@ +/** + * Heimdall's runtime presence statement. + * + * Odin admits a document as runtime presence only when it carries the + * `gamecult.runtime_presence_health.v2` schema and its record key equals the + * signed target. Anything else it stores as an ordinary document and never + * correlates, so a candidate publishing the wrong contract warms forever + * without ever being promoted — healthy from every angle except the one that + * decides. + * + * The statement is dual-proved. The stable provider-health key says "Heimdall + * made this statement"; the activation key Idunn passed at launch says "and it + * is the incarnation Idunn started". Both cover one payload, and neither + * establishes admission alone. Every field describing the launch is read from + * the runtime bundle rather than from configuration: the bundle is Idunn's own + * immutable record of what it started, and a statement assembled from anything + * else would be describing a launch nobody authorised. + */ + +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 { + IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, + encodeRuntimePresenceHealth, + runtimePresenceActivationSigningMessage, + runtimePresenceProofPayload, + runtimePresenceProviderSigningMessage, + type GameCultRuntimeCapability, + type RuntimePresenceHealth, + type RuntimePresenceState, +} from "cultnet-ts"; + +import { readIdunnStoreRecord } from "./idunn-store.js"; +import { + openProviderHealthIdentity, + passedDescriptor, + signWithIdentity, +} from "./provider-health-identity.js"; + +const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +/** Positional slots in `idunn.expected_incarnation.v2`. */ +const EXPECTED = { + target: 1, + planId: 2, + incarnationId: 3, + sealedReleaseId: 4, + runtimeId: 8, + healthContract: 10, + stateSchemaGeneration: 12, + stateContractSha256: 13, + capabilities: 16, +} as const; + +/** Positional slots in `idunn.runtime_activation.v2`. */ +const ACTIVATION = { + expectedProjectionSha256: 1, + runtimeInstanceId: 3, + activationSignerIdentityId: 4, +} as const; + +export interface RuntimeBundleFacts { + target: string; + planId: string; + incarnationId: string; + sealedReleaseId: string; + runtimeId: string; + runtimeInstanceId: string; + expectedProjectionSha256: string; + activationWitnessSha256: string; + activationSignerIdentityId: string; + stateSchemaGeneration: string | null; + stateContractSha256: string | null; + capabilities: GameCultRuntimeCapability[]; + healthContract: string; +} + +function fields(payload: Uint8Array, label: string): unknown[] { + const decoded = decode(payload); + if (!Array.isArray(decoded)) { + throw new Error(`${label} is not positional MessagePack`); + } + return decoded; +} + +function text(values: unknown[], index: number, label: string): string { + const value = values[index]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`${label} field ${index} is not a non-empty string`); + } + return value; +} + +function optionalText(values: unknown[], index: number): string | null { + const value = values[index]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function capabilities(values: unknown[], index: number): GameCultRuntimeCapability[] { + const claims = values[index]; + if (claims === undefined || claims === null) { + return []; + } + if (!Array.isArray(claims)) { + throw new Error("expected incarnation capabilities are not a list"); + } + return claims.map((claim) => { + if (!Array.isArray(claim) || claim.length < 4) { + throw new Error("expected incarnation capability is not a positional tuple"); + } + const [capability, schema, compatibility, capacity] = claim as unknown[]; + if ( + typeof capability !== "string" || + typeof schema !== "string" || + typeof compatibility !== "string" || + typeof capacity !== "number" + ) { + throw new Error("expected incarnation capability has an unexpected shape"); + } + return { capability, schema, compatibility, capacity }; + }); +} + +/** + * Read what Idunn recorded about this launch. + * + * `expected.cc` describes the incarnation Idunn intends; `activation.cc` names + * the process it actually started and the activation signer whose proof this + * statement must carry. + */ +export async function readRuntimeBundleFacts(bundlePath: string): Promise { + const expected = fields( + await readIdunnStoreRecord(path.join(bundlePath, "expected.cc"), "expected incarnation"), + "expected incarnation", + ); + const activationPayload = await readIdunnStoreRecord( + path.join(bundlePath, "activation.cc"), + "runtime activation", + ); + const activation = fields(activationPayload, "runtime activation"); + + const generation = optionalText(expected, EXPECTED.stateSchemaGeneration); + const contract = optionalText(expected, EXPECTED.stateContractSha256); + if ((generation === null) !== (contract === null)) { + throw new Error("expected incarnation state lineage is partial"); + } + + return { + target: text(expected, EXPECTED.target, "expected incarnation"), + planId: text(expected, EXPECTED.planId, "expected incarnation"), + incarnationId: text(expected, EXPECTED.incarnationId, "expected incarnation"), + sealedReleaseId: text(expected, EXPECTED.sealedReleaseId, "expected incarnation"), + runtimeId: text(expected, EXPECTED.runtimeId, "expected incarnation"), + runtimeInstanceId: text(activation, ACTIVATION.runtimeInstanceId, "runtime activation"), + expectedProjectionSha256: text( + activation, + ACTIVATION.expectedProjectionSha256, + "runtime activation", + ), + // The witness is the digest of the activation record itself, not a field + // inside it: it is what binds this statement to the exact launch Idunn + // recorded, so it is computed over the bytes actually delivered. + activationWitnessSha256: `sha256-${createHash("sha256").update(activationPayload).digest("hex")}`, + activationSignerIdentityId: text( + activation, + ACTIVATION.activationSignerIdentityId, + "runtime activation", + ), + stateSchemaGeneration: generation, + stateContractSha256: contract, + capabilities: capabilities(expected, EXPECTED.capabilities), + healthContract: text(expected, EXPECTED.healthContract, "expected incarnation"), + }; +} + +/** + * Open the activation signing key Idunn passed as a descriptor. + * + * The credential is exactly 32 bytes: the raw Ed25519 seed. It is passed as a + * parent-only descriptor and not as a path, so only the process Idunn launched + * can produce this half of the proof. + */ +export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyObject | undefined { + const descriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env); + if (descriptor === undefined) { + return undefined; + } + const seed = readFileSync(descriptor); + if (seed.length !== 32) { + throw new Error("Idunn runtime activation credential is not a 32-byte seed"); + } + return createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]), + format: "der", + type: "pkcs8", + }); +} + +/** + * 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. + * + * 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. + */ +export async function nextPublisherSequence(sequencePath: string): Promise { + 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; + } + } + 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 { + bundle: RuntimeBundleFacts; + state: RuntimePresenceState; + detail: string; + boundEndpoint: string | null; + writeLeaseSha256: string | null; + observedAtUnixMillis: number; + publisherSequence: number; + providerHealthIdentityPath: string; + env?: NodeJS.ProcessEnv; +} + +export interface PresenceStatement { + target: string; + payload: Uint8Array; +} + +/** + * Build and dual-sign one presence statement. + * + * Returns the target alongside the bytes because the document key must equal + * the signed target; Odin refuses the pair when they differ, and that is the + * one part of the envelope the transport cannot infer. + */ +export async function buildPresenceStatement( + inputs: PresenceStatementInputs, +): 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); + + const presence: RuntimePresenceHealth = { + target: inputs.bundle.target, + expectedProjectionSha256: inputs.bundle.expectedProjectionSha256, + planId: inputs.bundle.planId, + incarnationId: inputs.bundle.incarnationId, + sealedReleaseId: inputs.bundle.sealedReleaseId, + activationWitnessSha256: inputs.bundle.activationWitnessSha256, + stateSchemaGeneration: inputs.bundle.stateSchemaGeneration, + stateContractSha256: inputs.bundle.stateContractSha256, + runtimeId: inputs.bundle.runtimeId, + runtimeInstanceId: inputs.bundle.runtimeInstanceId, + boundEndpoint: inputs.boundEndpoint, + capabilities: inputs.bundle.capabilities, + healthContract: inputs.bundle.healthContract, + state: inputs.state, + detail: inputs.detail, + writeLeaseSha256: inputs.writeLeaseSha256, + signerIdentityId: identity.identityId, + publisherSequence: inputs.publisherSequence, + observedAtUnixMillis: inputs.observedAtUnixMillis, + activationSignerIdentityId: inputs.bundle.activationSignerIdentityId, + }; + + const proof = runtimePresenceProofPayload(presence); + const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof)); + const activationSignature = signWithIdentity( + { ...identity, privateKey: activationKey }, + runtimePresenceActivationSigningMessage(proof), + ); + + return { + target: presence.target, + payload: encodeRuntimePresenceHealth(presence, signature, activationSignature), + }; +} diff --git a/tests/idunn-rudp-health.test.ts b/tests/idunn-rudp-health.test.ts index 6bc1e09..66127d4 100644 --- a/tests/idunn-rudp-health.test.ts +++ b/tests/idunn-rudp-health.test.ts @@ -62,11 +62,21 @@ describe("Heimdall Idunn health publication", () => { }); const observedAt = "2026-08-22T00:45:00.000Z"; + // The transport is handed an already-signed statement; building one is + // the presence module's job and is covered against the Rust vectors in + // cultnet-ts. What this asserts is that the envelope keys the document by + // the signed target, which is the pairing Odin refuses when it differs. + const presence = { + target: "heimdall", + payload: new Uint8Array([0x01, 0x02, 0x03]), + }; + await publishIdunnRudpHealth(config, { daemonId: "yggdrasil-heimdall", state: "active", detail: "private-command-plane-ready", observedAt, + presence, }); const frame = await frameReceived; const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0"); @@ -76,17 +86,10 @@ describe("Heimdall Idunn health publication", () => { if (message.schemaVersion !== "cultnet.document_put_raw.v0") { throw new Error("Expected raw document publication."); } - expect(message.document.schemaId).toBe("idunn.signed_daemon_health.v1"); - expect(message.document.recordKey).toBe("yggdrasil-heimdall"); - expect(message.document.sourceRuntimeId).toBe("heimdall-service"); - expect(message.document.sourceRole).toBe("daemon-health-publisher"); - const statement = decode(message.document.payload) as unknown[]; - expect(statement).toHaveLength(17); - expect(statement[0]).toBe("idunn.signed_daemon_health.v1"); - expect(statement[1]).toBe("yggdrasil-heimdall"); - expect(statement[15]).toBeInstanceOf(Uint8Array); - expect((statement[15] as Uint8Array).byteLength).toBe(64); - expect(statement[16]).toBe(false); + expect(message.document.schemaId).toBe("gamecult.runtime_presence_health.v2"); + expect(message.document.recordKey).toBe(presence.target); + expect(message.document.sourceRole).toBe("runtime-presence-publisher"); + expect(Buffer.from(message.document.payload)).toEqual(Buffer.from(presence.payload)); } finally { server.close(); } diff --git a/tests/presence-identity-descriptor.test.ts b/tests/presence-identity-descriptor.test.ts index 8b2628b..d8ede1c 100644 --- a/tests/presence-identity-descriptor.test.ts +++ b/tests/presence-identity-descriptor.test.ts @@ -63,8 +63,7 @@ describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor const opened = await openProviderHealthIdentity( path.join(dir, "would-be-self-enrolled.cc"), - { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }, - process.pid + { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) } ); // Same key means it came from the descriptor. A fresh self-enrolment @@ -82,24 +81,25 @@ describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor describe("presence identity descriptor selection", () => { const dir = os.tmpdir(); - it("ignores descriptors addressed to another process", async () => { - // LISTEN_PID naming a different process means these descriptors are not - // ours; consuming them would read a sibling's identity. The call must take - // the path branch, so it must not fail with a descriptor-read error. - await openProviderHealthIdentity( - path.join(dir, `heimdall-wrong-pid-${process.pid}.cc`), - { LISTEN_FDNAMES: "gamecult-runtime-presence-identity", LISTEN_PID: String(process.pid + 1) }, - process.pid - ).catch((error) => { - expect(String(error)).not.toContain("Idunn runtime presence identity"); - }); + it("takes the named descriptor even when LISTEN_PID is another namespace's pid", async () => { + // Idunn launches candidates with PrivatePIDs=yes, so systemd sets + // LISTEN_PID to the pid it knows in the outer namespace while this process + // sees a namespace-local one. They never match. Refusing the descriptor on + // that basis sent Heimdall down the self-enrolling path, where it signed + // health with a key nothing trusts and warmed forever -- so the descriptor + // must still be taken, and the attempt to read it is the proof. + await expect( + openProviderHealthIdentity(path.join(dir, `heimdall-outer-pid-${process.pid}.cc`), { + LISTEN_FDNAMES: "gamecult-runtime-presence-identity", + LISTEN_PID: String(process.pid + 1), + }) + ).rejects.toThrow(); }); it("ignores a descriptor set that does not name the presence identity", async () => { await openProviderHealthIdentity( path.join(dir, `heimdall-other-fd-${process.pid}.cc`), - { LISTEN_FDNAMES: "some-other-credential", LISTEN_PID: String(process.pid) }, - process.pid + { LISTEN_FDNAMES: "some-other-credential", LISTEN_PID: String(process.pid) } ).catch((error) => { expect(String(error)).not.toContain("Idunn runtime presence identity"); }); diff --git a/vendor/CultLib b/vendor/CultLib index 5cefa0d..d1a9eda 160000 --- a/vendor/CultLib +++ b/vendor/CultLib @@ -1 +1 @@ -Subproject commit 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9 +Subproject commit d1a9edad3a116be341b0b5198c6998c31a083bda