diff --git a/README.md b/README.md index a34dc85..b86dbdc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,19 @@ The point is painfully simple: become "everything phones home for every request" - keep app-domain data separate so "shared auth" does not become "shared swamp" +## Front Door + +- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally. +- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps. +- **Entry points:** + - `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows. + - `src/verifier.ts`: the local verifier helper an app backend imports to validate Heimdall JWTs without phoning home. + - `src/odin-publication.ts` and `plugins/gamecult.heimdall.access`: the redacted Odin/Eve provider advertisement and the `gamecult.heimdall.access` plugin (`docs/eve-access-plugin.md`). + - `docs/architecture.md`, `docs/service-contract.md`, `docs/verse-service-contract.md`, `docs/app-profiles/`: the contracts. + - `pnpm install`, `pnpm dev`, `pnpm test` (see Dev Commands). +- **Live:** a stateful first slice: signing and JWKS, OAuth state for six providers, in-memory and Postgres stores, a real Discord callback path for the Repixelizer flow, sealed provider tokens, the loopback CultNet operation boundary, Idunn health publication, and the deployed Ghostlight Eve host consuming the discovered boundary. +- **Planned:** the two-account and eight-account Ghostlight human canaries, refresh and revocation flows, admin surfaces, and broader inspection projections beyond the redacted access boundary. + ## Current Shape This repo now has a stateful first slice instead of a decorative skeleton. diff --git a/package.json b/package.json index 0c6cf4f..36394ef 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts", "cultmesh-ts": "file:vendor/CultLib/packages/cultmesh-ts", "cultnet-ts": "file:vendor/CultLib/packages/cultnet-ts", - "fastify": "^5.8.5", + "fastify": "^5.12.6", "pg": "^8.20.0", "zod": "^3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 005bb26..855ad01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: file:vendor/CultLib/packages/cultnet-ts version: file:vendor/CultLib/packages/cultnet-ts fastify: - specifier: ^5.8.5 - version: 5.8.5 + specifier: ^5.12.6 + version: 5.12.7 pg: specifier: ^8.20.0 version: 8.20.0 @@ -532,17 +532,23 @@ packages: fast-json-stringify@6.3.0: resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + fast-json-stringify@7.1.3: + resolution: {integrity: sha512-5TDM1yOF/BDFOclKz2G6rs1RsIl5HXMCrg/npmdNIPNQCezshR5kIHjhwBNuMY67jhkTUAm9DguL8xkL/x0Wtw==} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@4.2.3: + resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==} + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - fastify@5.8.5: - resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + fastify@5.12.7: + resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -556,8 +562,8 @@ packages: picomatch: optional: true - find-my-way@9.5.0: - resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + find-my-way@9.10.0: + resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==} engines: {node: '>=20'} fsevents@2.3.3: @@ -853,8 +859,8 @@ packages: process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1475,15 +1481,26 @@ snapshots: json-schema-ref-resolver: 3.0.0 rfdc: 1.4.1 + fast-json-stringify@7.1.3: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 4.2.3 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 fast-uri@3.1.0: {} + fast-uri@4.2.3: {} + fastify-plugin@5.1.0: {} - fastify@5.8.5: + fastify@5.12.7: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -1491,11 +1508,11 @@ snapshots: '@fastify/proxy-addr': 5.1.0 abstract-logging: 2.0.1 avvio: 9.2.0 - fast-json-stringify: 6.3.0 - find-my-way: 9.5.0 + fast-json-stringify: 7.1.3 + find-my-way: 9.10.0 light-my-request: 6.6.0 pino: 10.3.1 - process-warning: 5.0.0 + process-warning: 5.1.1 rfdc: 1.4.1 secure-json-parse: 4.1.0 semver: 7.7.4 @@ -1509,7 +1526,7 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - find-my-way@9.5.0: + find-my-way@9.10.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 @@ -1733,7 +1750,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.0.0 + process-warning: 5.1.1 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -1758,7 +1775,7 @@ snapshots: process-warning@4.0.1: {} - process-warning@5.0.0: {} + process-warning@5.1.1: {} quick-format-unescaped@4.0.4: {} diff --git a/src/index.ts b/src/index.ts index 4c89a70..0bc13c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -7,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, - nextPublisherSequence, - 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; @@ -70,13 +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; - async function publishHealthState(): Promise { const pulse = createHeimdallRuntimePulse(config); @@ -94,32 +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: await nextPublisherSequence( - path.join(config.dataRoot, "runtime-presence-sequence"), - ), - 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 847d39c..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,19 +124,50 @@ 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) { + 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); } 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 { @@ -168,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 8cf0615..3c1054b 100644 --- a/src/runtime-presence.ts +++ b/src/runtime-presence.ts @@ -19,8 +19,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 { @@ -34,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"); @@ -178,70 +179,37 @@ export async function readRuntimeBundleFacts(bundlePath: 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; + 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 { bundle: RuntimeBundleFacts; + identity: PrivateIdentity; + activationKey: KeyObject; state: RuntimePresenceState; detail: string; boundEndpoint: string | null; writeLeaseSha256: string | null; observedAtUnixMillis: number; publisherSequence: number; - providerHealthIdentityPath: string; - env?: NodeJS.ProcessEnv; } export interface PresenceStatement { @@ -250,24 +218,14 @@ export interface PresenceStatement { } /** - * Build and dual-sign one presence statement. + * Build and dual-sign one presence statement from keys and facts already open. * * 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); +export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement { + const { identity } = inputs; const presence: RuntimePresenceHealth = { target: inputs.bundle.target, @@ -295,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), ); @@ -304,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/fastify-malformed-url.test.ts b/tests/fastify-malformed-url.test.ts new file mode 100644 index 0000000..f515c74 --- /dev/null +++ b/tests/fastify-malformed-url.test.ts @@ -0,0 +1,47 @@ +import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; +import { afterEach, describe, expect, it } from "vitest"; + +// GHSA-p68q-wchp-6fh7 (fastify < 5.12.2): a malformed request target under one +// plugin prefix, with a method that has no route in the main router, was +// dispatched to the not-found handler of a sibling plugin, skipping that +// handler's preHandler. This pins the framework fix; it is not about a Heimdall +// route. +describe("fastify malformed URL under sibling not-found handlers", () => { + const apps: Array> = []; + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + async function build() { + const app = Fastify(); + apps.push(app); + await app.register(async (pub) => { + pub.get("/ping", async () => "pong"); + }, { prefix: "/public" }); + await app.register(async (priv) => { + priv.setNotFoundHandler( + { + preHandler: async (request: FastifyRequest, reply: FastifyReply) => { + if (!request.headers.authorization) return reply.code(401).send({ error: "auth" }); + }, + }, + (_request: FastifyRequest, reply: FastifyReply) => reply.code(404).send({ secret: "PRIVATE-TENANT-DATA" }), + ); + priv.get("/x", async () => "x"); + }, { prefix: "/private" }); + return app; + } + + it("fails closed with 400 and never reaches another prefix's not-found handler", async () => { + const app = await build(); + const response = await app.inject({ method: "DELETE", url: "/public/%zz" }); + expect(response.body).not.toContain("PRIVATE-TENANT-DATA"); + expect(response.statusCode).toBe(400); + }); + + it("still runs the authenticated not-found preHandler for a well-formed URL", async () => { + const app = await build(); + const response = await app.inject({ method: "DELETE", url: "/private/nothing" }); + expect(response.statusCode).toBe(401); + }); +}); diff --git a/tests/fixtures/fake-idunn-rudp.ts b/tests/fixtures/fake-idunn-rudp.ts new file mode 100644 index 0000000..e084666 --- /dev/null +++ b/tests/fixtures/fake-idunn-rudp.ts @@ -0,0 +1,64 @@ +/** + * A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks + * the frame that follows and keeps every delivered frame. Heimdall opens a + * fresh socket and session per publication, so sessions are kept per remote. + */ +import dgram from "node:dgram"; +import { + CultNetRudpSession, + decodeRudpPacket, + encodeRudpPacket, + type CultNetTransportFrame, +} from "cultnet-ts"; + +export interface FakeIdunnRudp { + port: number; + frames: CultNetTransportFrame[]; + /** Resolves with the nth delivered frame (0-based) once it has arrived. */ + frame(index: number): Promise; + close(): void; +} + +export async function startFakeIdunnRudp(): Promise { + const server = dgram.createSocket("udp4"); + const sessions = new Map(); + const frames: CultNetTransportFrame[] = []; + const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = []; + + server.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + const key = `${remote.address}:${remote.port}`; + if (packet.packetType === "connect") { + const session = new CultNetRudpSession({ + connectionId: 0x1d0d0001, + initialSequence: 1, + resendDelayMs: 100, + }); + sessions.set(key, session); + server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address); + return; + } + const session = sessions.get(key); + if (!session) return; + const result = session.receive(packet, Date.now()); + if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address); + for (const frame of result.delivered) { + frames.push(frame); + for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame); + } + if (result.delivered.length > 0) { + server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address); + } + }); + await new Promise((resolve) => server.bind(0, "127.0.0.1", resolve)); + + return { + port: server.address().port, + frames, + frame: (index) => + frames[index] + ? Promise.resolve(frames[index]!) + : new Promise((resolve) => waiting.push({ index, resolve })), + close: () => server.close(), + }; +} diff --git a/tests/fixtures/idunn-runtime/.gitattributes b/tests/fixtures/idunn-runtime/.gitattributes new file mode 100644 index 0000000..fa1385d --- /dev/null +++ b/tests/fixtures/idunn-runtime/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/tests/fixtures/idunn-runtime/README.md b/tests/fixtures/idunn-runtime/README.md new file mode 100644 index 0000000..0f26117 --- /dev/null +++ b/tests/fixtures/idunn-runtime/README.md @@ -0,0 +1,11 @@ +# Idunn runtime fixtures + +Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib +`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`, +`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's +`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs` +(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a +test that decodes it supplies that machine id instead of `/etc/machine-id`. + +Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the +presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes. diff --git a/tests/fixtures/idunn-runtime/activation-key.seed b/tests/fixtures/idunn-runtime/activation-key.seed new file mode 100644 index 0000000..7b6d37f --- /dev/null +++ b/tests/fixtures/idunn-runtime/activation-key.seed @@ -0,0 +1 @@ +¯ÂOd×f¯l*oµ‘ÀFªv‰n�hLûÑpšS´p \ No newline at end of file diff --git a/tests/fixtures/idunn-runtime/activation.cc b/tests/fixtures/idunn-runtime/activation.cc new file mode 100644 index 0000000..3931baf Binary files /dev/null and b/tests/fixtures/idunn-runtime/activation.cc differ diff --git a/tests/fixtures/idunn-runtime/expected.cc b/tests/fixtures/idunn-runtime/expected.cc new file mode 100644 index 0000000..dbf7153 Binary files /dev/null and b/tests/fixtures/idunn-runtime/expected.cc differ diff --git a/tests/fixtures/idunn-runtime/machine-id b/tests/fixtures/idunn-runtime/machine-id new file mode 100644 index 0000000..a71716f --- /dev/null +++ b/tests/fixtures/idunn-runtime/machine-id @@ -0,0 +1 @@ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/tests/fixtures/idunn-runtime/provider-identity.credential b/tests/fixtures/idunn-runtime/provider-identity.credential new file mode 100644 index 0000000..2e61713 Binary files /dev/null and b/tests/fixtures/idunn-runtime/provider-identity.credential differ diff --git a/tests/fixtures/presence-descriptors.ts b/tests/fixtures/presence-descriptors.ts new file mode 100644 index 0000000..8e724f2 --- /dev/null +++ b/tests/fixtures/presence-descriptors.ts @@ -0,0 +1,61 @@ +/** + * 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, copyFile, mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts"; + +export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime"); + +const FIXTURE_FILES = [ + "expected.cc", + "activation.cc", + "activation-key.seed", + "provider-identity.credential", + "machine-id", +] as const; + +/** 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); + 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, "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) { + while (names.length < fd - 3) names.push(`pad-${names.length}`); + names[fd - 3] = name; + } + return { + env: { LISTEN_FDNAMES: names.join(":") }, + // 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 new file mode 100644 index 0000000..932d513 --- /dev/null +++ b/tests/fixtures/state-root-generation-child.ts @@ -0,0 +1,57 @@ +/** + * 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: + */ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { loadConfig } from "../../src/config.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]; +process.umask(0o007); + +const config = loadConfig({ + GC_ACCESS_BASE_URL: "https://heimdall.example.test", + GC_ACCESS_DATA_ROOT: stateRoot, +}); + +async function publishPresence(): Promise { + const descriptors = openPresenceDescriptors(fixtureDir); + try { + 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(); + } +} + +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 }); +} diff --git a/tests/idunn-boot.test.ts b/tests/idunn-boot.test.ts new file mode 100644 index 0000000..a645986 --- /dev/null +++ b/tests/idunn-boot.test.ts @@ -0,0 +1,320 @@ +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. A publication fault is + // reported as it is logged rather than after the frames never arrive. + const publicationFault = new Promise((_resolve, reject) => { + const watch = setInterval(() => { + if (generation.stderr().includes(" failed on ")) { + clearInterval(watch); + reject(new Error(`publication failed; stderr: ${generation.stderr()}`)); + } + }, 200); + void idunn.frame(2).then(() => clearInterval(watch)); + }); + const exitedEarly = generation.exited.then((code) => { + throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`); + }); + // The first statement is the startup publication, which follows the listen + // directly; the 10 s pulse loop has not yet fired. + await Promise.race([ + idunn.frame(0), + publicationFault, + exitedEarly, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error(`no presence at startup; stderr: ${generation.stderr()}`)), 8_000) + ), + ]); + await Promise.race([idunn.frame(2), publicationFault, exitedEarly]); + + 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/idunn-rudp-health.test.ts b/tests/idunn-rudp-health.test.ts index 66127d4..fa40393 100644 --- a/tests/idunn-rudp-health.test.ts +++ b/tests/idunn-rudp-health.test.ts @@ -1,18 +1,12 @@ -import dgram from "node:dgram"; import { mkdtemp } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { decode } from "@msgpack/msgpack"; -import { - CultNetRudpSession, - decodeRudpPacket, - encodeRudpPacket, - parseCultNetMessage, - type CultNetTransportFrame, -} from "cultnet-ts"; +import { parseCultNetMessage } from "cultnet-ts"; import { describe, expect, it, vi } from "vitest"; import { loadConfig } from "../src/config.js"; import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js"; +import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js"; vi.mock("node:fs/promises", async (importOriginal) => { const original = await importOriginal(); @@ -27,37 +21,12 @@ vi.mock("node:fs/promises", async (importOriginal) => { describe("Heimdall Idunn health publication", () => { it("completes the canonical RUDP handshake before publishing its signed frame", async () => { - const server = dgram.createSocket("udp4"); - const session = new CultNetRudpSession({ - connectionId: 0x1d0d0001, - initialSequence: 1, - resendDelayMs: 100, - }); - let resolveFrame!: (frame: CultNetTransportFrame) => void; - const frameReceived = new Promise((resolve) => { - resolveFrame = resolve; - }); - - server.on("message", (wire, remote) => { - const packet = decodeRudpPacket(wire); - if (packet.packetType === "connect") { - server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address); - return; - } - const result = session.receive(packet, Date.now()); - if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address); - for (const frame of result.delivered) resolveFrame(frame); - if (result.delivered.length > 0) { - server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address); - } - }); - await new Promise((resolve) => server.bind(0, "127.0.0.1", resolve)); + const server = await startFakeIdunnRudp(); try { const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-")); - const address = server.address(); const config = loadConfig({ - GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`, + GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`, GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"), }); const observedAt = "2026-08-22T00:45:00.000Z"; @@ -78,7 +47,7 @@ describe("Heimdall Idunn health publication", () => { observedAt, presence, }); - const frame = await frameReceived; + const frame = await server.frame(0); const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0"); expect(frame.channelId).toBe("schema"); diff --git a/tests/presence-identity-descriptor.test.ts b/tests/presence-identity-descriptor.test.ts index 97c5233..d01797b 100644 --- a/tests/presence-identity-descriptor.test.ts +++ b/tests/presence-identity-descriptor.test.ts @@ -1,18 +1,17 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { openSync, closeSync } from "node:fs"; +import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; +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); }); }); @@ -112,3 +131,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(); + }); +}); diff --git a/tests/publisher-sequence.test.ts b/tests/publisher-sequence.test.ts new file mode 100644 index 0000000..cfddac2 --- /dev/null +++ b/tests/publisher-sequence.test.ts @@ -0,0 +1,83 @@ +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, openPresenceAuthority } from "../src/runtime-presence.js"; +import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js"; + +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("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-")); + 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"); + await writeFile(dataRoot, ""); + const config = loadConfig({ + GC_ACCESS_BASE_URL: "https://heimdall.example.test", + GC_ACCESS_DATA_ROOT: dataRoot, + }); + + 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 first = authority.sign("warming", "probe", observedAtUnixMillis); + const second = authority.sign("warming", "probe", observedAtUnixMillis); + expect(first.target).toBe("streampixels-web"); + expect(second.target).toBe("streampixels-web"); + expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false); + } finally { + descriptors.close(); + } + expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]); + }); +}); diff --git a/tests/state-root-generations.test.ts b/tests/state-root-generations.test.ts new file mode 100644 index 0000000..b8424f3 --- /dev/null +++ b/tests/state-root-generations.test.ts @@ -0,0 +1,98 @@ +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 { build } from "esbuild"; +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 { + 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); + + // The generations run as uids that cannot read the checkout, so each runs + // one self-contained bundle built into the world-readable scratch root. + const child = path.join(base, "generation.mjs"); + await build({ + entryPoints: [path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts")], + bundle: true, + platform: "node", + format: "esm", + target: "node24", + outfile: child, + external: ["pg-native"], + banner: { + js: "import{createRequire}from'node:module';const require=createRequire(import.meta.url);", + }, + logLevel: "silent", + }); + await chmod(child, 0o644); + const generation = (role: "first" | "next", uid: number) => + run(process.execPath, [child, role, stateRoot, fixtures], { + uid, + gid: STATE_GID, + cwd: base, + 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); +});