diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs new file mode 100644 index 00000000..57364c10 --- /dev/null +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -0,0 +1,359 @@ +//! Generate the Idunn runtime fixtures that cultnet-ts is tested against. +//! +//! The Rust owner builds every input: real enrolled identities, an Expected that +//! passes `validate`, an Activation issued by `IdunnRuntimeActivationLaunch`, the +//! bundle files written through `SingleFileMessagePackBackingStore`, and +//! runtime-presence vectors signed by the Rust signers. Each vector is also run +//! through `authenticate_runtime_presence_claim` and +//! `correlate_runtime_presence_claim` before it is written, so the bytes +//! committed to the TypeScript tests are ones this verifier accepted (or, for +//! the low-capacity vector, ones it correlates to the typed capacity +//! disagreement). Ed25519 is deterministic, so the TypeScript signer must +//! reproduce each vector byte for byte from the same fields. +//! +//! The `service-stateful` set is the lease-bound shape: Expected carries a state +//! lineage and requires a write lease, and Rust issues `lease.cc` naming the +//! warming vector's digest. Its warming vector is sequence 1; the active and +//! degraded vectors are sequence 2 and bind to that lease. Two further leases are +//! deliberately wrong (`lease-other-warming.cc` names a different warming digest, +//! `lease-other-target.cc` names another target) for the TypeScript refusal tests. +//! +//! Linux only: the provider identity is bound to `/etc/machine-id`, and the +//! generated `machine-id` file records the value the identities were bound to. +//! +//! Usage: idunn_runtime_fixture + +use std::fs; +use std::path::Path; + +use anyhow::{Result, bail, ensure}; +use cultcache_rs::{ + CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore, +}; +use cultnet_rs::{ + GameCultProviderHealthIdentity, GameCultRuntimeCapability, + GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedCapability, IdunnExpectedDependency, + IdunnExpectedIncarnationRecord, IdunnExpectedRoute, IdunnProcessWriteLeaseRecord, + IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, + IdunnRuntimeActivationSigner, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, + authenticate_runtime_presence_claim, correlate_runtime_presence_claim, + enroll_service_identity_at, verify_runtime_authority, +}; + +/// The TypeScript tests pin the same values; drift shows up as a byte mismatch. +const OBSERVED_AT: u64 = 1_790_000_000_000; +const ISSUED_AT: u64 = OBSERVED_AT - 5_000; +const SEQUENCE: u64 = 1; +const DETAIL: &str = "route-observation:route-challenge-1"; +const STORED_AT: &str = "2026-01-01T00:00:00.000Z"; + +struct Set { + dir: &'static str, + target: &'static str, + transport: &'static str, + stable: &'static str, + candidate: &'static str, + minimum_capacity: u32, + odin_dependency: bool, + stateful: bool, +} + +const SETS: [Set; 4] = [ + Set { + dir: "web", + target: "streampixels-web", + transport: "http", + stable: "http://127.0.0.1:8830", + candidate: "http://127.0.0.1:18830", + minimum_capacity: 1, + odin_dependency: false, + stateful: false, + }, + Set { + dir: "service", + target: "streampixels-service", + transport: "tcp", + stable: "tcp://127.0.0.1:8831", + candidate: "tcp://127.0.0.1:18831", + minimum_capacity: 2, + odin_dependency: true, + stateful: false, + }, + Set { + dir: "service-stateful", + target: "streampixels-service", + transport: "tcp", + stable: "tcp://127.0.0.1:8833", + candidate: "tcp://127.0.0.1:18833", + minimum_capacity: 2, + odin_dependency: true, + stateful: true, + }, + Set { + dir: "rudp-route", + target: "streampixels-rudp", + transport: "rudp", + stable: "rudp://127.0.0.1:8832", + candidate: "rudp://127.0.0.1:18832", + minimum_capacity: 1, + odin_dependency: false, + stateful: false, + }, +]; + +fn main() -> Result<()> { + let Some(out) = std::env::args().nth(1) else { + bail!("usage: idunn_runtime_fixture "); + }; + let machine_id = fs::read_to_string("/etc/machine-id")?; + fs::create_dir_all(&out)?; + fs::write(Path::new(&out).join("machine-id"), machine_id.trim())?; + let idunn = enroll_service_identity_at::( + &Path::new(&out).join("idunn-identity.private"), + )?; + let idunn_anchor = idunn.trust_anchor()?; + fs::remove_file(Path::new(&out).join("idunn-identity.private"))?; + let _ = fs::remove_file(Path::new(&out).join("idunn-identity.private.lock")); + for set in &SETS { + build(Path::new(&out).join(set.dir).as_path(), set, &idunn, &idunn_anchor)?; + } + Ok(()) +} + +fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) +} + +fn build( + dir: &Path, + set: &Set, + idunn: &cultnet_rs::ServiceIdentitySigner, + idunn_anchor: &cultnet_rs::ServiceIdentityTrustAnchor, +) -> Result<()> { + fs::create_dir_all(dir)?; + let provider_path = dir.join("provider-identity.credential"); + let provider = enroll_service_identity_at::(&provider_path)?; + let provider_anchor = provider.trust_anchor()?; + let _ = fs::remove_file(dir.join("provider-identity.credential.lock")); + + let capability = format!("{}.api", set.target); + let capability_schema = format!("{}.api.v1", set.target); + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: set.target.into(), + plan_id: digest('1'), + incarnation_id: format!("{}/incarnation-1", set.target), + sealed_release_id: digest('2'), + source_repository: "github.com/GameCult/StreamPixels".into(), + source_revision: "a".repeat(40), + recipe_sha256: digest('3'), + runtime_id: format!("{}-yggdrasil", set.target), + expected_signer_identity_id: provider_anchor.identity_id.clone(), + health_contract: format!("{}.runtime-health", set.target), + artifact_sha256: digest('4'), + state_schema_generation: set.stateful.then(|| "streampixels-state-v1".to_string()), + state_contract_sha256: set.stateful.then(|| digest('7')), + write_lease_required: set.stateful, + route: Some(IdunnExpectedRoute { + route_id: format!("{}-route", set.target), + transport: set.transport.into(), + stable_endpoint: set.stable.into(), + candidate_endpoint: set.candidate.into(), + }), + capabilities: vec![IdunnExpectedCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + minimum_capacity: set.minimum_capacity, + }], + dependencies: if set.odin_dependency { + vec![IdunnExpectedDependency { + kind: "shared-infrastructure".into(), + capability: "odin.verse-rendezvous".into(), + schema: "odin.verse-topology.v1".into(), + compatibility: "v1".into(), + minimum_capacity: 1, + startup: "before-promotion".into(), + provider_id: Some("odin".into()), + provider_authority: Some("managed-incarnation".into()), + provider_expected_projection_sha256: Some(digest('5')), + provider_endpoint: Some("rudp://127.0.0.1:47871".into()), + }] + } else { + Vec::new() + }, + }; + expected.validate()?; + + let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('6'), ISSUED_AT, idunn)?; + let mut credential = Vec::new(); + let activation = launch.write_credential(&mut credential)?; + ensure!(credential.len() == 32, "activation credential is a 32-byte seed"); + fs::write(dir.join("activation-key.seed"), &credential)?; + let activation_signer = IdunnRuntimeActivationSigner::from_credential_reader(&credential[..])?; + + write_record( + &dir.join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes()?, + )?; + write_record( + &dir.join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes()?, + )?; + + let authority = + verify_runtime_authority(&expected, &activation, idunn_anchor, &provider_anchor.public_key)?; + let sign_vector = |name: &str, + state: &str, + capacity: u32, + sequence: u64, + write_lease_sha256: Option, + disagreement: Option<&str>| + -> Result { + let mut presence = GameCultRuntimePresenceHealthRecord { + schema_version: cultnet_rs::GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation.canonical_sha256()?, + state_schema_generation: expected.state_schema_generation.clone(), + state_contract_sha256: expected.state_contract_sha256.clone(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + bound_endpoint: Some(set.candidate.into()), + capabilities: vec![GameCultRuntimeCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + capacity, + }], + health_contract: expected.health_contract.clone(), + state: state.into(), + detail: DETAIL.into(), + write_lease_sha256, + signer_identity_id: provider_anchor.identity_id.clone(), + publisher_sequence: sequence, + observed_at_unix_millis: OBSERVED_AT, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: activation_signer.identity_id(), + activation_signature: Vec::new(), + }; + let proof = presence.canonical_proof_payload()?; + presence.signature = provider + .sign::(&proof) + .signature; + presence.activation_signature = activation_signer.sign_presence_proof(&presence)?; + let bytes = rmp_serde::to_vec(&presence)?; + + let claim = authenticate_runtime_presence_claim( + &bytes, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: OBSERVED_AT + 1_000, + maximum_age_millis: 30_000, + maximum_future_skew_millis: 0, + }, + )?; + let presence_sha256 = claim.signed_presence_sha256().to_string(); + let correlation = correlate_runtime_presence_claim(claim, &authority)?; + let codes: Vec<&str> = correlation + .disagreements() + .iter() + .map(|entry| entry.code.as_str()) + .collect(); + match disagreement { + None => ensure!(codes.is_empty(), "{name}: unexpected disagreements {codes:?}"), + Some(code) => ensure!(codes == [code], "{name}: expected only {code}, got {codes:?}"), + } + fs::write(dir.join(name), bytes)?; + Ok(presence_sha256) + }; + + if !set.stateful { + sign_vector("presence-active.bin", "active", set.minimum_capacity, SEQUENCE, None, None)?; + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + if set.minimum_capacity > 1 { + sign_vector( + "presence-active-below-minimum.bin", + "active", + set.minimum_capacity - 1, + SEQUENCE, + None, + Some("expected-capability-000-capacity"), + )?; + } + return Ok(()); + } + + // Lease-bound set. The warming vector comes first because the lease names it. + let warming_sha256 = + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + let activation_sha256 = activation.canonical_sha256()?; + let lease_for = |target: &str, warming_presence_sha256: &str| IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: target.into(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation_sha256.clone(), + state_schema_generation: expected.state_schema_generation.clone().unwrap_or_default(), + state_contract_sha256: expected.state_contract_sha256.clone().unwrap_or_default(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_presence_sha256.into(), + lease_epoch: 1, + issued_at_unix_millis: OBSERVED_AT - 1_000, + }; + let lease = lease_for(set.target, &warming_sha256); + let other_warming = lease_for(set.target, &digest('d')); + let other_target = lease_for("streampixels-web", &warming_sha256); + for (name, record) in [ + ("lease.cc", &lease), + ("lease-other-warming.cc", &other_warming), + ("lease-other-target.cc", &other_target), + ] { + let bytes = record.canonical_bytes()?; + ensure!( + IdunnProcessWriteLeaseRecord::decode_canonical(&bytes)? == *record, + "{name}: lease does not round-trip" + ); + write_record( + &dir.join(name), + &record.target, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + bytes, + )?; + } + let lease_sha256 = lease.canonical_sha256()?; + for (name, state) in [("presence-active.bin", "active"), ("presence-degraded.bin", "degraded")] { + sign_vector(name, state, set.minimum_capacity, SEQUENCE + 1, Some(lease_sha256.clone()), None)?; + } + Ok(()) +} + +fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec) -> Result<()> { + SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope { + key: key.into(), + r#type: kind.into(), + payload, + stored_at: STORED_AT.into(), + schema_id: Some(schema.into()), + })?; + let mut lock = path.as_os_str().to_owned(); + lock.push(".lock"); + let _ = fs::remove_file(lock); + Ok(()) +} diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index f33b8f83..e72a10aa 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -15,6 +15,11 @@ "require": "./dist/contracts.js", "default": "./dist/contracts.js" }, + "./idunn-runtime": { + "types": "./dist/idunn-runtime-authority.d.ts", + "require": "./dist/idunn-runtime-authority.js", + "default": "./dist/idunn-runtime-authority.js" + }, "./authority": { "types": "./dist/cultmesh-authority.d.ts", "require": "./dist/cultmesh-authority.js", diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts new file mode 100644 index 00000000..3713363d --- /dev/null +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -0,0 +1,244 @@ +import dgram from "node:dgram"; +import { decode, encode } from "@msgpack/msgpack"; + +import { encodeCultNetMessageForWire, parseCultNetMessage } from "./contracts"; +import type { IdunnRuntimeSigner } from "./idunn-runtime-authority"; +import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; +import type { CultNetRudpPacket } from "./rudp"; + +const ODIN_CAPABILITY = "odin.verse-rendezvous"; +const MAX_LEASE_WAIT_ROUNDS = 720; + +export type IdunnRuntimePresencePublisher = { + readonly runtimeId: string; + readonly runtimeInstanceId: string; + readonly requiresWriteLease: boolean; + /** Publishes the signer's presence in the health the signer reports. */ + publish(detail: string): Promise; + /** + * Publishes warming presence until Idunn's lease names one. Call it before + * `reportHealth('active')`: once the signer reports a non-warming health, + * `publish` inside this loop throws (it needs the lease) instead of polling. + */ + waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; + assertWriteLease(): Promise; +}; + +export type IdunnRuntimePresencePublisherOptions = { + signer: IdunnRuntimeSigner; + endpoint: string; +}; + +/** + * Publishes the signer's presence to Odin over RUDP, for services that are + * CultMesh-aware and declare `odin.verse-rendezvous` in Expected. The route + * proof never passes through here: `signer.answerRouteObservation` answers + * Idunn's challenge locally and needs no Odin. Capability shortfalls are not + * judged here either; the correlation owner records a typed disagreement. + */ +export function createIdunnRuntimePresencePublisher( + options: IdunnRuntimePresencePublisherOptions, +): IdunnRuntimePresencePublisher { + const { signer } = options; + const endpoint = parseRudpEndpoint(options.endpoint); + const odinDependency = signer.authority.expected.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" + && dependency.capability === ODIN_CAPABILITY); + if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(options.endpoint)) { + throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); + } + const connectionId = 0x0d1d0002; + let messageSequence = 0; + + const publish = async (detail: string) => { + const signed = signer.sign(detail); + messageSequence += 1; + await publishDocument(endpoint, connectionId, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, + document: signed.document, + }); + return signed.canonicalSha256; + }; + + return { + runtimeId: signer.runtimeId, + runtimeInstanceId: signer.runtimeInstanceId, + requiresWriteLease: signer.requiresWriteLease, + publish, + async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { + if (!signer.requiresWriteLease) return ""; + let warmingSequence = 0; + while (!signal?.aborted) { + await publish("waiting-for-process-write-lease"); + warmingSequence += 1; + try { + return signer.assertWriteLease(); + } catch (error) { + if (warmingSequence > MAX_LEASE_WAIT_ROUNDS) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); + } + await abortableDelay(pollIntervalMs, signal); + } + throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + }, + async assertWriteLease() { + return signer.assertWriteLease(); + }, + }; +} + +async function publishDocument( + endpoint: { host: string; port: number }, + connectionId: number, + message: Record, +): Promise { + const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { + socket.off("error", reject); + resolve(); + }); + }); + const receiver = receivePackets(socket); + const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); + try { + await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); + await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); + const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); + const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); + for (const packet of packets) await sendPacket(socket, endpoint, packet); + await receiveUntil( + receiver, + session, + endpoint, + () => session.outstandingReliablePacketCount === 0, + 2000, + (frame) => { + if (frame.channelId !== "schema") return; + const response = parseCultNetMessage(decode(frame.payload)); + if (response.schemaVersion === "cultnet.error.v0") { + throw new Error(`Odin rejected runtime presence: ${response.error}`); + } + }, + ); + } finally { + receiver.close(); + socket.close(); + } +} + +type PacketReceiver = { + socket: dgram.Socket; + next(timeoutMs: number): Promise; + close(): void; +}; + +function receivePackets(socket: dgram.Socket): PacketReceiver { + const packets: CultNetRudpPacket[] = []; + const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; + const errors: Error[] = []; + const drain = () => { + while (waiters.length && (packets.length || errors.length)) { + const waiter = waiters.shift()!; + clearTimeout(waiter.timer); + if (errors.length) waiter.reject(errors.shift()!); + else waiter.resolve(packets.shift()!); + } + }; + const onMessage = (wire: Buffer) => { + try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } + drain(); + }; + const onError = (error: Error) => { errors.push(error); drain(); }; + socket.on("message", onMessage); + socket.on("error", onError); + return { + socket, + next(timeoutMs) { + if (packets.length) return Promise.resolve(packets.shift()!); + if (errors.length) return Promise.reject(errors.shift()!); + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timer: setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; + error.code = "ETIMEDOUT"; + reject(error); + }, Math.max(1, timeoutMs)), + }; + waiters.push(waiter); + }); + }, + close() { + socket.off("message", onMessage); + socket.off("error", onError); + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error("CultNet RUDP publisher closed.")); + } + }, + }; +} + +async function receiveUntil( + receiver: PacketReceiver, + session: CultNetRudpSession, + endpoint: { host: string; port: number }, + predicate: (packet: CultNetRudpPacket) => boolean, + timeoutMs: number, + onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const packet = await receiver.next(Math.min(100, deadline - Date.now())); + const received = session.receive(packet, Date.now()); + if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); + for (const frame of received.delivered) onDelivered?.(frame); + if (predicate(packet)) return; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; + } + for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); + } + throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); +} + +async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { + const wire = encodeRudpPacket(packet); + await new Promise((resolve, reject) => { + socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); + }); +} + +function parseRudpEndpoint(value: string): { host: string; port: number } { + const text = value.replace(/^rudp:\/\//i, ""); + const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); + if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); + const port = Number(match[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); + return { host: match[1]!, port }; +} + +function normalizeRudpEndpoint(value: string): string { + const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); + const parsed = parseRudpEndpoint(normalized); + return `${parsed.host.toLowerCase()}:${parsed.port}`; +} + +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Aborted.")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 5ea30fff..7f7b882f 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -15,10 +15,7 @@ import { type RuntimePresenceHealth, } from "./runtime-presence-health"; import { truncateUtf8Bytes } from "./utf8-bound"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetRawDocumentRecord, type CultNetSnapshotRequestMessage } from "./contracts"; -import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; -import type { CultNetRudpPacket } from "./rudp"; -import dgram from "node:dgram"; +import type { CultNetRawDocumentRecord, CultNetSnapshotRequestMessage } from "./contracts"; export const IDUNN_EXPECTED_INCARNATION_SCHEMA = "idunn.expected_incarnation.v2"; export const IDUNN_RUNTIME_ACTIVATION_SCHEMA = "idunn.runtime_activation.v2"; @@ -32,7 +29,10 @@ const PROVIDER_PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); +// Limit: more than this many warmings signed between Idunn's observation and +// assertWriteLease would push the leased one out and refuse a valid lease. const MAX_RECENT_WARMING_PROOFS = 64; +const authoritiesWithSigner = new WeakSet(); const authorityKeys = new WeakMap(); type ExpectedIncarnation = { @@ -54,7 +54,6 @@ type ExpectedIncarnation = { route: { routeId: string; transport: string; stableEndpoint: string; candidateEndpoint: string } | null; capabilities: Array<{ capability: string; schema: string; compatibility: string; minimumCapacity: number }>; dependencies: Array<{ kind: string; capability: string; schema: string; compatibility: string; providerEndpoint: string | null }>; - canonicalBytes: Buffer; canonicalSha256: string; }; @@ -64,46 +63,77 @@ type RuntimeActivation = { runtimeId: string; runtimeInstanceId: string; activationSignerIdentityId: string; - activationSignerPublicKey: Buffer; + activationSignerPublicKeyHex: string; canonicalSha256: string; }; -export type IdunnRuntimeAuthority = { +type DeepReadonly = T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T; + +/** + * What Idunn admitted for this launch. Deeply frozen at load: bind, health + * contract, Expected, Activation and identities are read-only for the life of + * the process, so no caller can steer what the signer signs. + */ +export type IdunnRuntimeAuthority = DeepReadonly<{ expected: ExpectedIncarnation; activation: RuntimeActivation; providerSignerIdentityId: string; activationSignerIdentityId: string; boundEndpoint: string; processWriteLeasePath?: string; +}>; + +export type SignedRuntimePresence = { + document: CultNetRawDocumentRecord; + canonicalSha256: string; }; -export type IdunnRuntimePresencePublisher = { +/** + * Signs this launch's runtime presence and answers Idunn's route challenge. It + * owns no network: Odin publication, if a service wants it, is a separate + * consumer of `sign`. The signer is the single owner of the reported health: + * it starts `warming`, the app moves it with `reportHealth`, and every + * presence it signs, however it is carried, states that health. + */ +export type IdunnRuntimeSigner = { + readonly authority: IdunnRuntimeAuthority; readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - latestPresenceDocument(): CultNetRawDocumentRecord | null; - publishRouteObservation(request: CultNetSnapshotRequestMessage): Promise; - publish(state: RuntimePresenceHealth["state"], detail: string): Promise; - waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; - assertWriteLease(): Promise; + /** Records the app's health. Until the first call the signer reports `warming`. */ + reportHealth(state: RuntimePresenceHealth["state"]): void; + /** + * Signs one presence document in the reported health. Every call takes the + * next publisher sequence; every warming presence signed becomes a valid + * write-lease target. + */ + sign(detail: string): SignedRuntimePresence; + /** + * Answers an Idunn route challenge from local authority alone: signs a fresh + * presence in the reported health, with no network. + */ + answerRouteObservation(request: CultNetSnapshotRequestMessage): CultNetRawDocumentRecord; + /** Reads Idunn's write lease and checks it names this incarnation and a warming presence this signer signed. */ + assertWriteLease(): string; }; -export type IdunnRuntimePresencePublisherOptions = { +export type IdunnRuntimeSignerOptions = { authority: IdunnRuntimeAuthority; - endpoint: string; - healthContract: string; capabilities: RuntimePresenceHealth["capabilities"]; }; /** * Loads the root-provisioned Idunn bundle and parent-only signing descriptors. * The expected record and activation witness are accepted only when their - * exact positional contracts and mutual incarnation binding agree. + * exact positional contracts and mutual incarnation binding agree. Nothing + * here reads Odin: whether a target talks to Odin is decided by the + * publisher a service chooses to create. */ export function loadIdunnRuntimeAuthorityFromEnvironment( target: string, healthContract: string, - odinEndpoint: string, ): IdunnRuntimeAuthority { const bundle = requiredEnvironment(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT); const expected = readAuthorityRecord(path.join(bundle, "expected.cc"), "idunn.expected_incarnation", IDUNN_EXPECTED_INCARNATION_SCHEMA); @@ -113,11 +143,6 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( if (expectedValue.target !== target || expectedValue.healthContract !== healthContract) { throw new Error("Idunn Expected target or health contract does not match this runtime."); } - const odinDependency = expectedValue.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" - && dependency.capability === "odin.verse-rendezvous"); - if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(odinEndpoint)) { - throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); - } if (activationValue.expectedProjectionSha256 !== expectedValue.canonicalSha256 || activationValue.runtimeId !== expectedValue.runtimeId) { throw new Error("Idunn Activation does not bind this exact Expected incarnation."); @@ -138,7 +163,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( const activationPublicKey = rawEd25519PublicKey(crypto.createPublicKey(activationPrivateKey)); const activationSignerIdentityId = sha256Hex(Buffer.concat([ACTIVATION_ID_DOMAIN, activationPublicKey])); if (activationSignerIdentityId !== activationValue.activationSignerIdentityId - || !activationPublicKey.equals(activationValue.activationSignerPublicKey)) { + || activationPublicKey.toString("hex") !== activationValue.activationSignerPublicKeyHex) { throw new Error("Idunn activation credential does not match the root-provisioned Activation witness."); } @@ -153,7 +178,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( throw new Error("Provider identity does not match Idunn Expected."); } - const authority: IdunnRuntimeAuthority = { + const authority: IdunnRuntimeAuthority = deepFreeze({ expected: expectedValue, activation: activationValue, providerSignerIdentityId: expectedValue.expectedSignerIdentityId, @@ -162,16 +187,17 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( ...(process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] ? { processWriteLeasePath: process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] } : {}), - }; + }); authorityKeys.set(authority, { providerPrivateKey, activationPrivateKey }); return authority; } + /** * Signs the fixed Idunn v2 positional runtime-presence contract with the * provider identity and this launch's Idunn-issued activation key. */ -export function signIdunnRuntimePresence( +function signIdunnRuntimePresence( authority: IdunnRuntimeAuthority, fields: Omit, ): { presence: RuntimePresenceHealth; payload: Uint8Array; canonicalSha256: string } { @@ -205,42 +231,35 @@ export function signIdunnRuntimePresence( }; } -export function createIdunnRuntimePresencePublisher( - options: IdunnRuntimePresencePublisherOptions, -): IdunnRuntimePresencePublisher { - const endpoint = parseRudpEndpoint(options.endpoint); - const expected = options.authority.expected; - if (options.healthContract !== expected.healthContract) throw new Error("Runtime presence contract differs from Expected."); - if (options.authority.boundEndpoint !== expected.route?.candidateEndpoint) throw new Error("Runtime presence bind differs from Expected."); - for (const required of expected.capabilities) { - if (!options.capabilities.some((actual) => actual.capability === required.capability - && actual.schema === required.schema - && actual.compatibility === required.compatibility - && actual.capacity >= required.minimumCapacity)) { - throw new Error(`Runtime does not provide Expected capability ${required.capability}/${required.schema}.`); - } - } - const connectionId = 0x0d1d0002; +/** + * One signer per loaded authority: the publisher sequence and the warming proofs + * are the signer's, and a second signer would restart the sequence under the + * same runtimeInstanceId. + */ +export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner { + const { authority } = options; + if (authoritiesWithSigner.has(authority)) throw new Error("This Idunn runtime authority already has a signer."); + authoritiesWithSigner.add(authority); + const expected = authority.expected; let sequence = 0; - let latestLeaseSha256: string | null = null; - let latestPresenceDocument: CultNetRawDocumentRecord | null = null; + let health: RuntimePresenceHealth["state"] = "warming"; const recentWarmingProofs: string[] = []; - const assertWriteLease = async () => { + const assertWriteLease = () => { if (!expected.writeLeaseRequired) return ""; - const leasePath = options.authority.processWriteLeasePath; + const leasePath = authority.processWriteLeasePath; if (!leasePath) throw new Error("Idunn process write lease is required but no lease path is configured."); const record = readAuthorityRecord(leasePath, "idunn.process_write_lease", IDUNN_PROCESS_WRITE_LEASE_SCHEMA); const lease = decodeArray(record.payload, 14, "Idunn process write lease"); - assertLeaseMatches(lease, options.authority, recentWarmingProofs); - latestLeaseSha256 = prefixedSha256(Buffer.from(encode(lease))); - return latestLeaseSha256; + assertLeaseMatches(lease, authority, recentWarmingProofs); + return prefixedSha256(Buffer.from(encode(lease))); }; - const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { - const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : await assertWriteLease(); + const sign = (detail: string): SignedRuntimePresence => { + const state = health; + const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : assertWriteLease(); sequence += 1; - const signed = signIdunnRuntimePresence(options.authority, { + const signed = signIdunnRuntimePresence(authority, { capabilities: options.capabilities, state, detail: truncateUtf8Bytes(detail, RUNTIME_PRESENCE_DETAIL_MAX_BYTES), @@ -248,44 +267,35 @@ export function createIdunnRuntimePresencePublisher( publisherSequence: sequence, observedAtUnixMillis: Date.now(), }); - const document: CultNetRawDocumentRecord = { - schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - recordKey: expected.target, - storedAt: new Date().toISOString(), - payloadEncoding: "messagepack", - payload: new Uint8Array(signed.payload), - sourceRuntimeId: expected.runtimeId, - sourceRole: "runtime-presence-health-publisher", - tags: ["cultnet.transport.rudp.v0", "runtime-presence"], - }; - await publishDocument(endpoint, connectionId, { - schemaVersion: "cultnet.document_put_raw.v0", - messageId: `runtime-presence:${expected.target}:${options.authority.activation.runtimeInstanceId}:${sequence}`, - document, - }); - latestPresenceDocument = document; if (state === "warming") { recentWarmingProofs.push(signed.canonicalSha256); if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); } - return signed.canonicalSha256; - }; - - const copyLatestPresenceDocument = (): CultNetRawDocumentRecord | null => { - if (!latestPresenceDocument) return null; return { - ...latestPresenceDocument, - payload: new Uint8Array(latestPresenceDocument.payload), - ...(latestPresenceDocument.tags ? { tags: [...latestPresenceDocument.tags] } : {}), + canonicalSha256: signed.canonicalSha256, + document: { + schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, + recordKey: expected.target, + storedAt: new Date().toISOString(), + payloadEncoding: "messagepack", + payload: new Uint8Array(signed.payload), + sourceRuntimeId: expected.runtimeId, + sourceRole: "runtime-presence-health-publisher", + tags: ["cultnet.transport.rudp.v0", "runtime-presence"], + }, }; }; return { + authority, runtimeId: expected.runtimeId, - runtimeInstanceId: options.authority.activation.runtimeInstanceId, + runtimeInstanceId: authority.activation.runtimeInstanceId, requiresWriteLease: expected.writeLeaseRequired, - latestPresenceDocument: copyLatestPresenceDocument, - async publishRouteObservation(request) { + reportHealth(state) { + health = state; + }, + sign, + answerRouteObservation(request) { if (!request.messageId || request.messageId.trim() !== request.messageId) throw new Error("Idunn route observation requires a message id."); if ((request.schemaIds && !request.schemaIds.includes(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) || (request.recordKeys && !request.recordKeys.includes(expected.target)) @@ -293,157 +303,18 @@ export function createIdunnRuntimePresencePublisher( || request.shardEpoch !== undefined) { throw new Error("Idunn route observation does not request this runtime presence document."); } - await publish("active", `route-observation:${request.messageId}`); - const document = copyLatestPresenceDocument(); - if (!document) throw new Error("Idunn route observation was published without a presence document."); - return document; - }, - publish, - async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { - if (!expected.writeLeaseRequired) return ""; - let warmingSequence = 0; - while (!signal?.aborted) { - await publish("warming", "waiting-for-process-write-lease"); - warmingSequence += 1; - try { - return await assertWriteLease(); - } catch (error) { - if (warmingSequence > 720) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); - } - await abortableDelay(pollIntervalMs, signal); - } - throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + return sign(`route-observation:${request.messageId}`).document; }, assertWriteLease, }; } -async function publishDocument( - endpoint: { host: string; port: number }, - connectionId: number, - message: Record, -): Promise { - const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); - await new Promise((resolve, reject) => { - socket.once("error", reject); - socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { - socket.off("error", reject); - resolve(); - }); - }); - const receiver = receivePackets(socket); - const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); - try { - await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); - await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); - const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); - const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); - for (const packet of packets) await sendPacket(socket, endpoint, packet); - await receiveUntil( - receiver, - session, - endpoint, - () => session.outstandingReliablePacketCount === 0, - 2000, - (frame) => { - if (frame.channelId !== "schema") return; - const response = parseCultNetMessage(decode(frame.payload)); - if (response.schemaVersion === "cultnet.error.v0") { - throw new Error(`Odin rejected runtime presence: ${response.error}`); - } - }, - ); - } finally { - receiver.close(); - socket.close(); +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); } -} - -type PacketReceiver = { - socket: dgram.Socket; - next(timeoutMs: number): Promise; - close(): void; -}; - -function receivePackets(socket: dgram.Socket): PacketReceiver { - const packets: CultNetRudpPacket[] = []; - const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; - const errors: Error[] = []; - const drain = () => { - while (waiters.length && (packets.length || errors.length)) { - const waiter = waiters.shift()!; - clearTimeout(waiter.timer); - if (errors.length) waiter.reject(errors.shift()!); - else waiter.resolve(packets.shift()!); - } - }; - const onMessage = (wire: Buffer) => { - try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } - drain(); - }; - const onError = (error: Error) => { errors.push(error); drain(); }; - socket.on("message", onMessage); - socket.on("error", onError); - return { - socket, - next(timeoutMs) { - if (packets.length) return Promise.resolve(packets.shift()!); - if (errors.length) return Promise.reject(errors.shift()!); - return new Promise((resolve, reject) => { - const waiter = { - resolve, - reject, - timer: setTimeout(() => { - const index = waiters.indexOf(waiter); - if (index >= 0) waiters.splice(index, 1); - const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; - error.code = "ETIMEDOUT"; - reject(error); - }, Math.max(1, timeoutMs)), - }; - waiters.push(waiter); - }); - }, - close() { - socket.off("message", onMessage); - socket.off("error", onError); - for (const waiter of waiters.splice(0)) { - clearTimeout(waiter.timer); - waiter.reject(new Error("CultNet RUDP publisher closed.")); - } - }, - }; -} - -async function receiveUntil( - receiver: PacketReceiver, - session: CultNetRudpSession, - endpoint: { host: string; port: number }, - predicate: (packet: CultNetRudpPacket) => boolean, - timeoutMs: number, - onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const packet = await receiver.next(Math.min(100, deadline - Date.now())); - const received = session.receive(packet, Date.now()); - if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); - for (const frame of received.delivered) onDelivered?.(frame); - if (predicate(packet)) return; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; - } - for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); - } - throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); -} - -async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { - const wire = encodeRudpPacket(packet); - await new Promise((resolve, reject) => { - socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); - }); + return value; } function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { @@ -495,9 +366,7 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { if (!/^sha256-[0-9a-f]{64}$/u.test(hash)) throw new Error(`Expected ${name} hash is malformed.`); } if (!/^[0-9a-f]{40}$/u.test(sourceRevision)) throw new Error("Expected source revision is malformed."); - if (!route || route.transport !== "http" || !route.candidateEndpoint.startsWith("http://")) { - throw new Error("Idunn Expected must include an HTTP candidate route."); - } + if (route) validateRoute(route); return { schemaVersion: string(values[0], "Expected schema"), target, @@ -517,7 +386,6 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { route, capabilities: decodeExpectedCapabilities(values[16]), dependencies: decodeExpectedDependencies(values[17]), - canonicalBytes, canonicalSha256: prefixedSha256(canonicalBytes), }; } @@ -545,7 +413,7 @@ function decodeActivation(payload: Uint8Array): RuntimeActivation { runtimeId: string(values[2], "Activation runtime id"), runtimeInstanceId: string(values[3], "Activation instance id"), activationSignerIdentityId, - activationSignerPublicKey: Buffer.from(publicKey), + activationSignerPublicKeyHex: Buffer.from(publicKey).toString("hex"), canonicalSha256: prefixedSha256(canonicalBytes), }; } @@ -575,6 +443,24 @@ function decodeExpectedDependencies(value: unknown): ExpectedIncarnation["depend }); } +// The Rust owner (IdunnExpectedRoute::validate) accepts http, tcp and rudp +// routes; a runtime is not entitled to a narrower admission than Idunn's. +const ROUTE_SCHEMES: Record = { + http: { stable: ["http://", "https://"], candidate: ["http://"] }, + tcp: { stable: ["tcp://"], candidate: ["tcp://"] }, + rudp: { stable: ["rudp://"], candidate: ["rudp://"] }, +}; + +function validateRoute(route: NonNullable): void { + const schemes = ROUTE_SCHEMES[route.transport]; + if (!schemes) throw new Error("Idunn Expected route transport is unsupported."); + if (!schemes.stable.some((scheme) => route.stableEndpoint.startsWith(scheme)) + || !schemes.candidate.some((scheme) => route.candidateEndpoint.startsWith(scheme))) { + throw new Error(`Idunn Expected ${route.transport} route endpoints use an unsupported scheme.`); + } + if (route.stableEndpoint === route.candidateEndpoint) throw new Error("Idunn Expected stable and candidate route endpoints are identical."); +} + function decodeRoute(value: unknown): ExpectedIncarnation["route"] { if (value === null) return null; const route = array(value, 4, "Expected route"); @@ -595,7 +481,7 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { if (enrollmentNonce.length !== 32) throw new Error("Provider-health enrollment nonce has an invalid length."); const publicKey = Buffer.from(providerIdentityByteVector(publicKeyValue, "Provider-health public key")); const protectedSeed = Buffer.from(providerIdentityByteVector(protectedSeedValue, "Provider-health protected seed")); - const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim(); + const machineId = readMachineId(); if (!machineId) throw new Error("Linux machine-id is unavailable for provider-health identity."); const expectedBinding = `${PROVIDER_PROTECTOR_CONTEXT}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; if (protectorKind !== "linux_file_mode_machine_id_binding" || binding !== expectedBinding || assurance !== "os_installation_file_bound_cloneable_baseline") { @@ -616,6 +502,15 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { return key; } +// Same sources, same order as the Rust identity protector. +function readMachineId(): string { + try { + return fs.readFileSync("/etc/machine-id", "utf8").trim(); + } catch { + return fs.readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); + } +} + function readProviderHealthIdentityCredential(bytes: Buffer): Uint8Array { const decoded = decode(bytes); if (!Array.isArray(decoded) || decoded.length !== 1) { @@ -693,40 +588,12 @@ function assertLeaseMatches(values: unknown[], authority: IdunnRuntimeAuthority, if (!matches) throw new Error("Idunn process write lease does not match the current Expected incarnation."); } -function parseRudpEndpoint(value: string): { host: string; port: number } { - const text = value.replace(/^rudp:\/\//i, ""); - const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); - if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); - const port = Number(match[2]); - if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); - return { host: match[1]!, port }; -} - -function normalizeRudpEndpoint(value: string): string { - const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); - const parsed = parseRudpEndpoint(normalized); - return `${parsed.host.toLowerCase()}:${parsed.port}`; -} - function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required for Idunn runtime presence.`); return value; } -function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, milliseconds); - const onAbort = () => { - clearTimeout(timer); - reject(signal?.reason ?? new Error("Aborted.")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} function array(value: unknown, length: number, label: string): unknown[] { if (!Array.isArray(value) || value.length !== length) throw new Error(`${label} is not a ${length}-field positional contract.`); diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 3db890ef..8950f638 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -205,9 +205,15 @@ export { IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IDUNN_RUNTIME_ACTIVATION_SCHEMA, - createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, type IdunnRuntimeAuthority, + type IdunnRuntimeSigner, + type IdunnRuntimeSignerOptions, + type SignedRuntimePresence, +} from "./idunn-runtime-authority"; +export { + createIdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, -} from "./idunn-runtime-authority"; +} from "./idunn-odin-presence-publisher"; diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md new file mode 100644 index 00000000..dd683a16 --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -0,0 +1,21 @@ +# Idunn runtime fixtures + +Written by the Rust owner, not by the TypeScript tests. Regenerate with +`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `9ba6ac4`; it uses +cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's). +It needs Linux and a machine-id equal to the `machine-id` file here, because the +provider identity is bound to it. On Yggdrasil: + + DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' + +then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by +`authenticate_runtime_presence_claim` and correlated by +`correlate_runtime_presence_claim` before being written; the +`below-minimum` vector correlates to exactly `expected-capability-000-capacity`. +Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for byte. + +`service-stateful` is the lease-bound shape (`streampixels-service`, state lineage set, +`write_lease_required: true`). Rust issues `lease.cc`, which names the digest of its +`presence-warming.bin`; `presence-active.bin` and `presence-degraded.bin` are sequence 2 and +carry that lease's digest. `lease-other-warming.cc` names a different warming digest and +`lease-other-target.cc` names another target; the TypeScript lease check must refuse both. diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id new file mode 100644 index 00000000..a71716fc --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id @@ -0,0 +1 @@ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed new file mode 100644 index 00000000..b0f0159c --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -0,0 +1 @@ +¢_Î#Ÿ/ "Q0\ûiCâ]\Ê�¦‚†ó;ÛY¨ø„k \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc new file mode 100644 index 00000000..57e2c860 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc new file mode 100644 index 00000000..878c090b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin new file mode 100644 index 00000000..084817c3 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin new file mode 100644 index 00000000..246e99cb Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential new file mode 100644 index 00000000..d8026bb4 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed new file mode 100644 index 00000000..491dbddb --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed @@ -0,0 +1 @@ +KýaŸz´-2‰Ë¡V>)ë»xìºÚÏ®¢T =/‰* \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc new file mode 100644 index 00000000..3d670ab6 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc new file mode 100644 index 00000000..8e96b29b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc new file mode 100644 index 00000000..9f99abd0 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc new file mode 100644 index 00000000..cc21e54c Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc new file mode 100644 index 00000000..b387f6e6 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin new file mode 100644 index 00000000..b1c33139 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin new file mode 100644 index 00000000..a3abdf3a Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin new file mode 100644 index 00000000..9634ec21 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential new file mode 100644 index 00000000..3429906f Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed new file mode 100644 index 00000000..40f59dbb --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed @@ -0,0 +1 @@ +ö�„Bf®Õ=?ÞÀ�Z¹jÇâÏ&ŠÜæ3¦õÜŸf…mJ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc new file mode 100644 index 00000000..3fa6a1f3 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc new file mode 100644 index 00000000..99c564f2 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin new file mode 100644 index 00000000..4cc1442f Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin new file mode 100644 index 00000000..d28b1349 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin new file mode 100644 index 00000000..aa6f563b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential new file mode 100644 index 00000000..ccb9d640 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed new file mode 100644 index 00000000..7b6d37ff --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/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/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc new file mode 100644 index 00000000..3931baf2 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc new file mode 100644 index 00000000..dbf7153b Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin new file mode 100644 index 00000000..37edbf65 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin new file mode 100644 index 00000000..55409b8d Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin differ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential new file mode 100644 index 00000000..2e617139 Binary files /dev/null and b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential differ diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 7969fde2..b5c9236a 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -1,30 +1,98 @@ -import { mock, test } from "node:test"; +import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; -import crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; import dgram from "node:dgram"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { decode, encode } from "@msgpack/msgpack"; import { createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, - signIdunnRuntimePresence, - systemdListenPidMatches, -} from "../src/idunn-runtime-authority"; + type IdunnRuntimeAuthority, +} from "../src"; +import { systemdListenPidMatches } from "../src/idunn-runtime-authority"; +import type { CultNetSnapshotRequestMessage } from "../src/contracts"; import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; -import { - runtimePresenceActivationSigningMessage, - runtimePresenceProofPayload, - runtimePresenceProviderSigningMessage, - RUNTIME_PRESENCE_SLOT, -} from "../src/runtime-presence-health"; +import { RUNTIME_PRESENCE_SLOT } from "../src/runtime-presence-health"; -const providerIdentityContext = "gamecult-provider-health-identity-v1"; -const providerIdDomain = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); -const activationIdDomain = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); +// Every input below was written by the Rust owner; see fixtures/idunn-runtime/README.md. +// The only values this file pins are the ones the generator also pins. +const FIXTURES = path.join(__dirname, "..", "..", "test", "fixtures", "idunn-runtime"); +const OBSERVED_AT = 1_790_000_000_000; +const CHALLENGE: CultNetSnapshotRequestMessage = { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "route-challenge-1", +}; + +type FixtureSet = "web" | "service" | "rudp-route" | "service-stateful"; + +function fixtureFile(set: FixtureSet, name: string): Buffer { + return fs.readFileSync(path.join(FIXTURES, set, name)); +} + +/** The Expected fields the tests need, read from the Rust-written file rather than re-spelled. */ +function expectedFacts(set: FixtureSet) { + const store = decode(fixtureFile(set, "expected.cc")) as unknown[][][]; + const values = decode(store[2]![0]![3] as Uint8Array) as unknown[]; + const route = values[15] as string[]; + const capabilities = (values[16] as Array<[string, string, string, number]>).map(([capability, schema, compatibility, minimum]) => ({ + capability, schema, compatibility, capacity: minimum, + })); + const dependencies = values[17] as unknown[][]; + return { + target: values[1] as string, + healthContract: values[10] as string, + candidate: route[3]!, + capabilities, + odinEndpoint: dependencies.map((dependency) => dependency[9] as string)[0], + }; +} + +/** Points the process at one Rust-written bundle and its inherited descriptors, as Idunn's systemd unit would. */ +function openAuthority( + context: TestContext, + set: FixtureSet, + options: { machineId?: "etc" | "dbus"; lease?: string } = {}, +): IdunnRuntimeAuthority { + const facts = expectedFacts(set); + const originalEnvironment = { ...process.env }; + context.after(() => { + for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; + Object.assign(process.env, originalEnvironment); + }); + process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); + process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; + if (options.lease) process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE = path.join(FIXTURES, set, options.lease); + else delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; + process.env.LISTEN_PID = String(process.pid); + process.env.LISTEN_FDS = "2"; + process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; + const machineId = fs.readFileSync(path.join(FIXTURES, "machine-id"), "utf8"); + const realReadFileSync = fs.readFileSync.bind(fs); + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (filePath === 3) return fixtureFile(set, "activation-key.seed"); + if (filePath === 4) return fixtureFile(set, "provider-identity.credential"); + if (filePath === "/etc/machine-id") { + if (options.machineId === "dbus") throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return machineId; + } + if (filePath === "/var/lib/dbus/machine-id") return machineId; + return realReadFileSync(filePath as never, ...args as never[]); + }); + return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); +} + +function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0, lease?: string) { + const authority = openAuthority(context, set, { lease }); + const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); + return createIdunnRuntimeSigner({ authority, capabilities }); +} + +function freezeClock(context: TestContext): void { + context.mock.method(Date, "now", () => OBSERVED_AT); +} test("matches systemd's host PID for the private PID namespace init only", () => { assert.equal(systemdListenPidMatches(String(process.pid), process.pid), true); @@ -34,216 +102,290 @@ test("matches systemd's host PID for the private PID namespace init only", () => assert.equal(systemdListenPidMatches(undefined, 1), false); }); -test("opens Idunn identity and publishes valid CultNet data while surfacing piggybacked application errors", async (context) => { - const directory = mkdtempSync(path.join(os.tmpdir(), "cultnet-runtime-authority-")); - context.after(() => rmSync(directory, { recursive: true, force: true })); +for (const set of ["web", "service", "rudp-route"] as const) { + test(`opens the Rust-written ${set} bundle with no Odin endpoint, whatever route transport Rust admits`, (context) => { + const authority = openAuthority(context, set); + assert.equal(authority.expected.target, expectedFacts(set).target); + assert.equal(authority.boundEndpoint, expectedFacts(set).candidate); + }); +} - const machineId = "1234567890abcdef1234567890abcdef"; - const providerSeed = Buffer.alloc(32, 0x31); - const activationSeed = Buffer.alloc(32, 0x72); - const providerKey = privateKeyFromSeed(providerSeed); - const activationKey = privateKeyFromSeed(activationSeed); - const providerPublic = rawPublic(crypto.createPublicKey(providerKey)); - const activationPublic = rawPublic(crypto.createPublicKey(activationKey)); - const providerIdentity = sha256Hex(Buffer.concat([providerIdDomain, providerPublic])); - const activationIdentity = sha256Hex(Buffer.concat([activationIdDomain, activationPublic])); - const target = "streampixels-service"; - const contract = "streampixels-service.cultnet-rudp-service-health"; - const candidate = "http://127.0.0.1:18831"; - const expectedValues = [ - "idunn.expected_incarnation.v2", target, `sha256-${"1".repeat(64)}`, "incarnation-1", - `sha256-${"2".repeat(64)}`, "https://github.com/GameCult/StreamPixels.git", "a".repeat(40), - `sha256-${"3".repeat(64)}`, "streampixels-service-yggdrasil", providerIdentity, contract, - `sha256-${"4".repeat(64)}`, "service-boundary-v1", `sha256-${"5".repeat(64)}`, false, - ["streampixels-service-http", "http", "http://127.0.0.1:8831", candidate], - [["streampixels.service.api", "streampixels.api.v1", "v1", 1]], - [["shared-infrastructure", "odin.verse-rendezvous", "odin.verse-topology.v1", "v1", 1, "before-promotion", null, null, null, "rudp://127.0.0.1:17871"]], +test("a target that declares no Odin dependency opens and answers Idunn's challenge without one", (context) => { + assert.equal(expectedFacts("web").odinEndpoint, undefined); + const signer = signerFor(context, "web"); + assert.ok(signer.answerRouteObservation(CHALLENGE).payload.byteLength > 0); +}); + +test("finds the machine-id at the dbus path when /etc/machine-id is absent, as the Rust protector does", (context) => { + assert.ok(openAuthority(context, "web", { machineId: "dbus" })); +}); + +test("rejects a credential file holding more than one envelope", (context) => { + openAuthority(context, "web"); + const credential = decode(fixtureFile("web", "provider-identity.credential")) as unknown[][]; + const doubled = Buffer.from(encode([...credential, ...credential])); + const realReadFileSync = fs.readFileSync; + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => + filePath === 4 ? doubled : (realReadFileSync as (...all: unknown[]) => unknown)(filePath, ...args)); + assert.throws( + () => loadIdunnRuntimeAuthorityFromEnvironment(expectedFacts("web").target, expectedFacts("web").healthContract), + /exactly one envelope/, + ); +}); + +function presenceSlot(document: { payload: Uint8Array }, slot: number): unknown { + return (decode(document.payload) as unknown[])[slot]; +} + +// Every committed vector is asserted, dead ones included. Stateless sets sign at sequence 1. +for (const set of ["web", "service", "rudp-route"] as const) { + test(`${set}: a fresh signer answers warming, and after reportHealth('active') answers active, byte for byte as the Rust-verified vectors`, (context) => { + freezeClock(context); + assert.deepEqual(Buffer.from(signerFor(context, set).answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-warming.bin")); + const active = signerFor(context, set); + active.reportHealth("active"); + assert.deepEqual(Buffer.from(active.answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-active.bin")); + }); +} + +test("a shortfall signs the vector Rust correlates to the typed capacity disagreement", (context) => { + freezeClock(context); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile("service", "presence-active-below-minimum.bin")); +}); + +test("the signer owns the reported health: a fresh signer is warming, reportHealth moves it, the sequence keeps counting", (context) => { + freezeClock(context); + const signer = signerFor(context, "web"); + const first = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(first, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.sign("detail").document, RUNTIME_PRESENCE_SLOT.state), "warming"); + signer.reportHealth("active"); + const third = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.publisherSequence), 3); +}); + +test("answers locally: synchronously, with no socket opened", (context) => { + const sockets = context.mock.method(dgram, "createSocket"); + const signer = signerFor(context, "web"); + const answer = signer.answerRouteObservation(CHALLENGE); + assert.equal(typeof (answer as unknown as { then?: unknown }).then, "undefined"); + assert.equal(sockets.mock.callCount(), 0); +}); + +test("refuses a challenge that does not ask for this runtime's presence", (context) => { + const signer = signerFor(context, "web"); + const cases: CultNetSnapshotRequestMessage[] = [ + { ...CHALLENGE, messageId: "" }, + { ...CHALLENGE, messageId: " padded" }, + { ...CHALLENGE, recordKeys: ["another-target"] }, + { ...CHALLENGE, schemaIds: ["another.schema"] }, + { ...CHALLENGE, shardId: "shard-1" }, ]; - const expectedBytes = Buffer.from(encode(expectedValues)); - const expectedSha = `sha256-${sha256Hex(expectedBytes)}`; - const activationValues = [ - "idunn.runtime_activation.v2", expectedSha, "streampixels-service-yggdrasil", - `sha256-${"6".repeat(64)}`, activationIdentity, activationPublic, Date.now(), - "7".repeat(64), "ed25519", new Uint8Array(64).fill(0x17), - ]; - const bundle = path.join(directory, "bundle"); - fs.mkdirSync(bundle); - writeFileSync(path.join(bundle, "expected.cc"), cultCacheStore( - "idunn.expected_incarnation", "idunn.expected_incarnation.v2", "expected", expectedBytes, - )); - writeFileSync(path.join(bundle, "activation.cc"), cultCacheStore( - "idunn.runtime_activation", "idunn.runtime_activation.v2", "activation", Buffer.from(encode(activationValues)), - )); - - const binding = `${providerIdentityContext}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; - const mask = crypto.createHash("sha256").update(Buffer.concat([ - Buffer.from("gamecult-linux-service-seed-v1\0", "utf8"), - Buffer.from(providerIdentityContext, "utf8"), - Buffer.from(binding, "utf8"), - ])).digest(); - const protectedSeed = Buffer.from(providerSeed.map((value, index) => value ^ mask[index]!)); - const providerCredential = Buffer.from(encode([[ - "gamecult-provider-health-identity", - "gamecult.provider_health_identity.private.v1", - Buffer.from(encode([ - "gamecult.provider_health_identity.private.v1", providerIdentity, Array.from(providerPublic), Array.from(protectedSeed), - "linux_file_mode_machine_id_binding", binding, "v1", "os_installation_file_bound_cloneable_baseline", - "2026-01-01T00:00:00Z", Array(32).fill(0x2a), - ])), - "2026-01-01T00:00:00Z", - "gamecult.provider_health_identity.private.v1", - ]])); - let descriptorProviderCredential = providerCredential; - - const originalEnvironment = { ...process.env }; - context.after(() => { - for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; - Object.assign(process.env, originalEnvironment); - }); - process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = bundle; - process.env.GAMECULT_IDUNN_CANDIDATE_BIND = candidate; - delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; - process.env.LISTEN_PID = String(process.pid); - process.env.LISTEN_FDS = "2"; - process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; - const realReadFileSync = fs.readFileSync.bind(fs); - mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if (filePath === 3) return activationSeed; - if (filePath === 4) return descriptorProviderCredential; - if (filePath === "/etc/machine-id") return machineId; - return realReadFileSync(filePath as never, ...args as never[]); - }); - - const authority = loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"); - const signed = signIdunnRuntimePresence(authority, { - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - state: "warming", - detail: "starting", - writeLeaseSha256: null, - publisherSequence: 1, - observedAtUnixMillis: Date.now(), - }); - const proof = runtimePresenceProofPayload(signed.presence); - assert.equal((decode(expectedBytes) as unknown[]).length, 18); - assert.equal(signed.presence.expectedProjectionSha256, expectedSha); - const signedRecord = decode(signed.payload) as unknown[]; - assert.equal(crypto.verify(null, runtimePresenceProviderSigningMessage(proof), crypto.createPublicKey(providerKey), Buffer.from(signedRecord[21] as Uint8Array)), true); - assert.equal(crypto.verify(null, runtimePresenceActivationSigningMessage(proof), crypto.createPublicKey(activationKey), Buffer.from(signedRecord[23] as Uint8Array)), true); + for (const request of cases) assert.throws(() => signer.answerRouteObservation(request), /route observation/); +}); +async function startOdinPeer(context: TestContext, endpoint: string) { const peer = dgram.createSocket("udp4"); + const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1)); await new Promise((resolve, reject) => { peer.once("error", reject); - peer.bind(0, "127.0.0.1", resolve); + peer.bind(port, "127.0.0.1", resolve); }); context.after(() => peer.close()); - const address = peer.address(); - assert.equal(typeof address, "object"); - const peerPort = (address as { port: number }).port; + const state = { + reject: false, + received: [] as Array<{ schemaVersion?: string; document: { payload: Uint8Array } }>, + responseAck: undefined as number | undefined, + failure: undefined as Error | undefined, + }; let serverSession: CultNetRudpSession | undefined; - let receivedMessage: unknown; - let responseAck: number | undefined; - let peerFailure: Error | undefined; - let rejectOdinAdmission = true; peer.on("message", (wire, remote) => { try { const packet = decodeRudpPacket(wire); if (packet.packetType === "connect") { serverSession = new CultNetRudpSession({ connectionId: packet.connectionId, initialSequence: 100 }); - const accept = serverSession.acceptConnect(packet, Date.now()); - peer.send(encodeRudpPacket(accept), remote.port, remote.address); + peer.send(encodeRudpPacket(serverSession.acceptConnect(packet, Date.now())), remote.port, remote.address); return; } if (!serverSession) throw new Error("Publisher sent data before establishing RUDP."); - const result = serverSession.receive(packet, Date.now()); - const frame = result.delivered.find((candidate) => candidate.channelId === "schema"); + const frame = serverSession.receive(packet, Date.now()).delivered.find((candidate) => candidate.channelId === "schema"); if (!frame) return; - receivedMessage = decode(frame.payload); - const responsePayload = encode(rejectOdinAdmission ? { - schemaVersion: "cultnet.error.v0", - error: "test admission denied", - routingHint: null, - code: null, - details: null, - } : { - schemaVersion: "cultnet.snapshot_response.v0", - messageId: "ack", - documents: [], - }); - const [response] = serverSession.sendMany("schema", responsePayload, { + state.received.push(decode(frame.payload) as never); + const [response] = serverSession.sendMany("schema", encode(state.reject + ? { schemaVersion: "cultnet.error.v0", error: "test admission denied", routingHint: null, code: null, details: null } + : { schemaVersion: "cultnet.snapshot_response.v0", messageId: "ack", documents: [] }), { reliable: true, ordered: true, nowMs: Date.now(), }); assert.ok(response); - responseAck = response.ack; + state.responseAck = response.ack; peer.send(encodeRudpPacket(response), remote.port, remote.address); } catch (error) { - peerFailure = error as Error; + state.failure = error as Error; } }); - const publisher = createIdunnRuntimePresencePublisher({ - authority, - endpoint: `rudp://127.0.0.1:${peerPort}`, - healthContract: contract, - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - }); - await assert.rejects(publisher.publish("warming", "local RUDP integration"), /Odin rejected runtime presence: test admission denied/); - assert.deepEqual(receivedMessage && (receivedMessage as { schemaVersion?: string }).schemaVersion, "cultnet.document_put_raw.v0"); - assert.ok(Buffer.from(encode(receivedMessage)).byteLength > 0); - assert.equal(responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); - assert.equal(peerFailure, undefined, peerFailure?.message); - assert.equal(publisher.latestPresenceDocument(), null, "a rejected publication must not become the snapshot source"); + return state; +} - rejectOdinAdmission = false; - await publisher.publish("warming", "accepted presence"); - const snapshotDocument = await publisher.publishRouteObservation({ - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "route-challenge-1", - schemaIds: ["gamecult.runtime_presence_health.v2"], - recordKeys: [target], - }); - const publishedDocument = (receivedMessage as { document: { payload: Uint8Array; schemaId: string; recordKey: string } }).document; - assert.ok(snapshotDocument); - assert.equal(snapshotDocument.schemaId, publishedDocument.schemaId); - assert.equal(snapshotDocument.recordKey, publishedDocument.recordKey); - assert.deepEqual(Buffer.from(snapshotDocument.payload), Buffer.from(publishedDocument.payload)); - const routePresence = decode(snapshotDocument.payload) as unknown[]; - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.state], "active"); - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.detail], "route-observation:route-challenge-1"); - snapshotDocument.payload[0] = snapshotDocument.payload[0]! ^ 0xff; - assert.deepEqual(Buffer.from(publisher.latestPresenceDocument()?.payload ?? []), Buffer.from(publishedDocument.payload), "snapshot readers cannot mutate publisher state"); +test("the Odin publisher publishes the signer's document to the endpoint Expected names and surfaces Odin's rejection", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - descriptorProviderCredential = Buffer.from(encode([ - ...(decode(providerCredential) as unknown[][]), - ...(decode(providerCredential) as unknown[][]), - ])); + await publisher.publish("accepted presence"); + assert.equal(peer.received[0]?.schemaVersion, "cultnet.document_put_raw.v0"); + const published = decode(peer.received[0]!.document.payload) as unknown[]; + assert.equal(published[RUNTIME_PRESENCE_SLOT.state], "warming"); + assert.equal(published[RUNTIME_PRESENCE_SLOT.publisherSequence], 1); + assert.equal(peer.responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); + + peer.reject = true; + signer.reportHealth("active"); + await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); + assert.equal(peer.failure, undefined, peer.failure?.message); +}); + +test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await publisher.publish("below minimum"); + const capabilities = (decode(peer.received[0]!.document.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.capabilities] as unknown[][]; + assert.equal(capabilities[0]![3], expectedFacts("service").capabilities[0]!.capacity - 1); +}); + +test("the Odin publisher takes only the endpoint Expected names", (context) => { assert.throws( - () => loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"), - /exactly one envelope/, + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service"), endpoint: "rudp://127.0.0.1:1" }), + /does not match Idunn Expected dependency authority/, + ); + assert.throws( + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "web"), endpoint: expectedFacts("service").odinEndpoint! }), + /does not match Idunn Expected dependency authority/, ); }); -function cultCacheStore(type: string, schemaId: string, key: string, payload: Uint8Array): Buffer { - return Buffer.from(encode([ - "cultcache.store.v1", - [[schemaId, type, `${schemaId}.v1`, schemaId, "{}", [schemaId], []]], - [[key, schemaId, "2026-01-01T00:00:00Z", payload]], - ])); +test("the route answer and the Odin publish report the same state, because the signer owns it", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + + await publisher.publish("first"); + assert.equal(presenceSlot(peer.received[0]!.document, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "warming"); + + signer.reportHealth("active"); + await publisher.publish("second"); + assert.equal(presenceSlot(peer.received[1]!.document, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "active"); + // One sequence across both carriers. + assert.deepEqual( + [1, 2].map((index) => presenceSlot(peer.received[index - 1]!.document, RUNTIME_PRESENCE_SLOT.publisherSequence)), + [1, 3], + ); +}); + +// The lease-bound set: Rust issued lease.cc naming presence-warming.bin's digest, and presence-active.bin +// and presence-degraded.bin carry that lease's digest. +const STATEFUL = "service-stateful" as const; + +function statefulSigner(context: TestContext, lease = "lease.cc") { + return signerFor(context, STATEFUL, 0, lease); } -function privateKeyFromSeed(seed: Uint8Array): crypto.KeyObject { - return crypto.createPrivateKey({ - key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed)]), - format: "der", - type: "pkcs8", +test("stateful: warming, active and degraded match the Rust-verified vectors byte for byte, active and degraded bound to Rust's lease", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-warming.bin")); + signer.reportHealth("active"); + const active = signer.answerRouteObservation(CHALLENGE); + assert.deepEqual(Buffer.from(active.payload), fixtureFile(STATEFUL, "presence-active.bin")); + assert.match(String(presenceSlot(active, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)), /^sha256-[0-9a-f]{64}$/); + + const degraded = statefulSigner(context); + degraded.answerRouteObservation(CHALLENGE); + degraded.reportHealth("degraded"); + assert.deepEqual(Buffer.from(degraded.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-degraded.bin")); +}); + +test("a warming answered on the route becomes a lease target: Rust's lease naming it is accepted", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/, "no warming signed yet"); + signer.answerRouteObservation(CHALLENGE); + assert.equal(signer.assertWriteLease(), presenceSlot({ payload: fixtureFile(STATEFUL, "presence-active.bin") }, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)); +}); + +for (const [lease, reason] of [["lease-other-warming.cc", "names another warming digest"], ["lease-other-target.cc", "names another target"]] as const) { + test(`a lease that ${reason} is refused, and the signer cannot sign active under it`, (context) => { + freezeClock(context); + const signer = statefulSigner(context, lease); + signer.answerRouteObservation(CHALLENGE); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/); + signer.reportHealth("active"); + assert.throws(() => signer.answerRouteObservation(CHALLENGE), /does not match the current Expected incarnation/); }); } -function rawPublic(key: crypto.KeyObject): Buffer { - const der = key.export({ type: "spki", format: "der" }) as Buffer; - return Buffer.from(der.subarray(der.length - 32)); +test("one signer per loaded authority", (context) => { + const authority = openAuthority(context, "web"); + const capabilities = expectedFacts("web").capabilities; + createIdunnRuntimeSigner({ authority, capabilities }); + assert.throws(() => createIdunnRuntimeSigner({ authority, capabilities }), /already has a signer/); +}); + +test("the loaded authority is frozen: the bind, contract, Expected, Activation and identities cannot change, and the signer signs the loaded endpoint", (context) => { + freezeClock(context); + const authority = openAuthority(context, "web"); + const loaded = expectedFacts("web"); + const open = authority as unknown as { [key: string]: any }; + const mutations: Array<[string, () => void]> = [ + ["boundEndpoint", () => { open.boundEndpoint = "http://127.0.0.1:1"; }], + ["providerSignerIdentityId", () => { open.providerSignerIdentityId = "forged"; }], + ["activationSignerIdentityId", () => { open.activationSignerIdentityId = "forged"; }], + ["expected.healthContract", () => { open.expected.healthContract = "forged"; }], + ["expected.target", () => { open.expected.target = "forged"; }], + ["expected.canonicalSha256", () => { open.expected.canonicalSha256 = "forged"; }], + ["expected.route.candidateEndpoint", () => { open.expected.route.candidateEndpoint = "http://127.0.0.1:1"; }], + ["expected.capabilities[0].minimumCapacity", () => { open.expected.capabilities[0].minimumCapacity = 0; }], + ["expected.capabilities.push", () => { open.expected.capabilities.push({}); }], + ["activation.runtimeInstanceId", () => { open.activation.runtimeInstanceId = "forged"; }], + ["activation.canonicalSha256", () => { open.activation.canonicalSha256 = "forged"; }], + ]; + for (const [name, mutate] of mutations) assert.throws(mutate, TypeError, name); + const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities }); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate); +}); + +// A web app must be able to import the signer without pulling the Odin publisher and dgram. +function loadUnderDgramStub(modulePath: string): string { + const script = [ + 'const Module = require("node:module");', + "const load = Module._load;", + 'Module._load = function (request, ...rest) { if (request === "dgram" || request === "node:dgram") throw new Error("dgram was loaded"); return load.call(this, request, ...rest); };', + "try { require(process.argv[1]); process.stdout.write(\"loaded\"); } catch (error) { process.stdout.write(\"failed: \" + error.message); }", + ].join("\n"); + const run = spawnSync(process.execPath, ["-e", script, modulePath], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + return run.stdout; } -function sha256Hex(bytes: Uint8Array): string { - return crypto.createHash("sha256").update(bytes).digest("hex"); -} +test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => { + const subpath = require.resolve("cultnet-ts/idunn-runtime"); + assert.equal(loadUnderDgramStub(subpath), "loaded"); + assert.equal(loadUnderDgramStub(require.resolve("cultnet-ts")), "failed: dgram was loaded"); + const exported = require(subpath) as Record; + assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); + assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); + assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); + assert.equal(exported.signIdunnRuntimePresence, undefined); + assert.equal((require("cultnet-ts") as Record).signIdunnRuntimePresence, undefined); +});