diff --git a/package.json b/package.json index cf44c9a..5a24912 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@fastify/cors": "^11.2.0", "@msgpack/msgpack": "^3.1.3", + "cultcache-ts": "file:../CultLib/packages/cultcache-ts", "cultnet-ts": "file:../CultLib/packages/cultnet-ts", "fastify": "^5.8.5", "pg": "^8.20.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e60119..b756803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@msgpack/msgpack': specifier: ^3.1.3 version: 3.1.3 + cultcache-ts: + specifier: file:../CultLib/packages/cultcache-ts + version: file:../CultLib/packages/cultcache-ts cultnet-ts: specifier: file:../CultLib/packages/cultnet-ts version: file:../CultLib/packages/cultnet-ts diff --git a/src/config.ts b/src/config.ts index 0d685a4..6f42e6a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,7 @@ export interface HeimdallConfig { daemonId: string; idunnRudpHealth: string | undefined; idunnHealthContract: string; + providerHealthIdentityPath: string; sessionTtlSeconds: number; refreshTtlSeconds: number; stateTtlSeconds: number; @@ -140,6 +141,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig daemonId: env.GC_ACCESS_IDUNN_DAEMON ?? "yggdrasil-heimdall", idunnRudpHealth: readOptionalString(env.GC_ACCESS_IDUNN_RUDP_HEALTH), idunnHealthContract: env.GC_ACCESS_IDUNN_HEALTH_CONTRACT ?? "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: + env.GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH ?? path.join(dataRoot, "provider-health-identity.cc"), sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600), refreshTtlSeconds: readInt(env.GC_ACCESS_REFRESH_TTL_SECONDS, 60 * 60 * 24 * 30), stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600), diff --git a/src/idunn-rudp-health.ts b/src/idunn-rudp-health.ts index 9124a5a..de0e5fa 100644 --- a/src/idunn-rudp-health.ts +++ b/src/idunn-rudp-health.ts @@ -1,4 +1,5 @@ import dgram from "node:dgram"; +import { randomUUID } from "node:crypto"; import { encode } from "@msgpack/msgpack"; import { encodeCultNetMessageForWire, @@ -7,12 +8,19 @@ import { type CultNetRudpPacket, } from "cultnet-ts"; import type { HeimdallConfig } from "./config.js"; +import { + openOrEnrollProviderHealthIdentity, + signProviderHealthPayload, +} from "./provider-health-identity.js"; const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0"; const IDUNN_HEALTH_RUDP_CONNECTION_ID = 0x1d0d0001; const RUDP_HEALTH_CONNECT_ATTEMPTS = 3; const RUDP_PULSE_POST_SEND_GRACE_MS = 1000; const RUDP_CONNECT_TO_DATA_GRACE_MS = 300; +const SIGNED_DAEMON_HEALTH_SCHEMA = "idunn.signed_daemon_health.v1"; +const publisherIncarnationId = randomUUID(); +let publisherSequence = 0; type Endpoint = { host: string; @@ -59,7 +67,7 @@ async function publishIdunnRudpHealthOnce( const connect = buildConnectPacket(); await sendPacket(socket, endpoint, connect); await sleep(RUDP_CONNECT_TO_DATA_GRACE_MS); - const payload = buildDocumentPutPayload(config, health); + const payload = await buildSignedDocumentPutPayload(config, health); await sendPacket(socket, endpoint, buildSchemaDataPacket(payload)); await sleep(RUDP_PULSE_POST_SEND_GRACE_MS); } finally { @@ -67,26 +75,41 @@ async function publishIdunnRudpHealthOnce( } } -function buildDocumentPutPayload(config: HeimdallConfig, health: IdunnHealthInput): Uint8Array { - const recordPayload = encode([ +async function buildSignedDocumentPutPayload(config: HeimdallConfig, health: IdunnHealthInput): Promise { + const identity = await openOrEnrollProviderHealthIdentity(config.providerHealthIdentityPath); + publisherSequence += 1; + const unsigned = [ + SIGNED_DAEMON_HEALTH_SCHEMA, health.daemonId, + config.idunnHealthContract, + "heimdall-service", health.state, health.detail, - health.observedAt, - config.idunnHealthContract, - "daemon-published", - CULTNET_RUDP_PROTOCOL_ID, - ]); + identity.identityId, + publisherIncarnationId, + publisherSequence, + Date.parse(health.observedAt), + null, + null, + null, + null, + "ed25519", + new Uint8Array(), + false, + ]; + const signature = signProviderHealthPayload(identity, encode(unsigned)); + const recordPayload = encode([...unsigned.slice(0, 15), signature, false]); const message: CultNetDocumentPutRawMessage = { schemaVersion: "cultnet.document_put_raw.v0", messageId: `heimdall-health:${health.daemonId}:${health.observedAt.replace(/[:.]/g, "-")}`, document: { - schemaId: "idunn.daemon_health", + schemaId: "idunn.signed_daemon_health", recordKey: health.daemonId, storedAt: health.observedAt, payloadEncoding: "messagepack", payload: recordPayload, sourceRuntimeId: "heimdall-service", + sourceAgentId: identity.identityId, sourceRole: "daemon-health-publisher", tags: [CULTNET_RUDP_PROTOCOL_ID], }, diff --git a/src/provider-health-identity.ts b/src/provider-health-identity.ts new file mode 100644 index 0000000..8700257 --- /dev/null +++ b/src/provider-health-identity.ts @@ -0,0 +1,161 @@ +import { decode, encode } from "@msgpack/msgpack"; +import { + createHash, + createPrivateKey, + createPublicKey, + randomBytes, + sign, + type KeyObject, +} from "node:crypto"; +import { chmod, readFile } from "node:fs/promises"; +import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "cultcache-ts"; + +const PRIVATE_SCHEMA = "gamecult.provider_health_identity.private.v1"; +const PRIVATE_KEY = "gamecult-provider-health-identity"; +const ID_DOMAIN = Buffer.from("gamecult.provider-health.identity.v1\0"); +const SIGNATURE_DOMAIN = Buffer.from("gamecult.provider-health.signature.v1\0"); +const SIGNING_PURPOSE = Buffer.from("idunn.signed_daemon_health.v1"); +const PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; +const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); + +type PrivateIdentity = { + identityId: string; + publicKey: Uint8Array; + privateKey: KeyObject; +}; + +export async function openOrEnrollProviderHealthIdentity(path: string): Promise { + const store = new SingleFileMessagePackBackingStore(path); + const existing = await store.pullAll(); + if (existing.length === 0) { + const seed = randomBytes(32); + const privateKey = privateKeyFromSeed(seed); + const publicKey = rawPublicKey(privateKey); + const identityId = deriveIdentityId(publicKey); + const machineId = await readMachineId(); + const binding = protectorBinding(machineId); + const protectedSeed = xorSeed(seed, binding); + const createdAt = new Date().toISOString(); + const payload = encode([ + PRIVATE_SCHEMA, + identityId, + publicKey, + protectedSeed, + "linux_file_mode_machine_id_binding", + binding, + "v1", + "os_installation_file_bound_cloneable_baseline", + createdAt, + randomBytes(32), + ]); + const envelope: CultCacheEnvelope = { + key: PRIVATE_KEY, + type: PRIVATE_SCHEMA, + schemaId: PRIVATE_SCHEMA, + storedAt: createdAt, + payload, + catalogEntry: { + schemaId: PRIVATE_SCHEMA, + schemaName: PRIVATE_SCHEMA, + schemaVersion: "1", + contentHash: createHash("sha256").update(PRIVATE_SCHEMA).digest("hex"), + canonicalSchemaJson: JSON.stringify({ title: PRIVATE_SCHEMA, type: "array" }), + }, + }; + await store.push(envelope); + await chmod(path, 0o600); + return { identityId, publicKey, privateKey }; + } + if (existing.length !== 1) { + throw new Error("Provider-health identity store must contain exactly one record."); + } + return decodePrivateIdentity(existing[0]!.payload, await readMachineId()); +} + +export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array { + const message = Buffer.concat([ + SIGNATURE_DOMAIN, + u64be(SIGNING_PURPOSE.length), + SIGNING_PURPOSE, + u64be(payload.length), + payload, + ]); + return sign(null, message, identity.privateKey); +} + +export function providerHealthPublicIdentity(identity: PrivateIdentity): { + identityId: string; + publicKeyHex: string; +} { + return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") }; +} + +function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity { + const value = decode(payload); + if (!Array.isArray(value) || value.length !== 10) { + throw new Error("Provider-health identity payload is malformed."); + } + const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value; + if ( + schema !== PRIVATE_SCHEMA || + typeof identityId !== "string" || + !(publicKey instanceof Uint8Array) || publicKey.length !== 32 || + !(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 || + protector !== "linux_file_mode_machine_id_binding" || + binding !== protectorBinding(machineId) || + version !== "v1" || + identityId !== deriveIdentityId(publicKey) + ) { + throw new Error("Provider-health identity does not belong to this host or profile."); + } + const seed = xorSeed(protectedSeed, binding); + const privateKey = privateKeyFromSeed(seed); + if (!rawPublicKey(privateKey).equals(Buffer.from(publicKey))) { + throw new Error("Provider-health private seed does not match its public key."); + } + return { identityId, publicKey, privateKey }; +} + +async function readMachineId(): Promise { + for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { + try { + const value = (await readFile(path, "utf8")).trim(); + if (value) return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + throw new Error("Linux machine-id is unavailable."); +} + +function protectorBinding(machineId: string): string { + return `${PROTECTOR_CONTEXT}:machine-id-sha256:${createHash("sha256").update(machineId).digest("hex")}`; +} + +function xorSeed(seed: Uint8Array, binding: string): Buffer { + const mask = createHash("sha256") + .update(Buffer.from("gamecult-linux-service-seed-v1\0")) + .update(PROTECTOR_CONTEXT) + .update(binding) + .digest(); + return Buffer.from(seed.map((byte, index) => byte ^ mask[index]!)); +} + +function deriveIdentityId(publicKey: Uint8Array): string { + return createHash("sha256").update(ID_DOMAIN).update(publicKey).digest("hex"); +} + +function privateKeyFromSeed(seed: Uint8Array): KeyObject { + return createPrivateKey({ key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]), format: "der", type: "pkcs8" }); +} + +function rawPublicKey(privateKey: KeyObject): Buffer { + const der = createPublicKey(privateKey).export({ format: "der", type: "spki" }); + return Buffer.from(der.subarray(der.length - 32)); +} + +function u64be(value: number): Buffer { + const output = Buffer.alloc(8); + output.writeBigUInt64BE(BigInt(value)); + return output; +} diff --git a/tests/app.test.ts b/tests/app.test.ts index 2b1bb61..04743e6 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -21,6 +21,7 @@ function createTestConfig(): HeimdallConfig { daemonId: "yggdrasil-heimdall", idunnRudpHealth: undefined, idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", sessionTtlSeconds: 3600, refreshTtlSeconds: 60 * 60 * 24 * 30, stateTtlSeconds: 600, diff --git a/tests/security.test.ts b/tests/security.test.ts index 1aa7c02..134054b 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -19,6 +19,7 @@ function createTestConfig(overrides: Partial = {}): HeimdallConf daemonId: "yggdrasil-heimdall", idunnRudpHealth: undefined, idunnHealthContract: "heimdall.cultnet-rudp-provider-health", + providerHealthIdentityPath: "E:/Projects/Heimdall/.heimdall-data/provider-health-identity.cc", sessionTtlSeconds: 3600, refreshTtlSeconds: 60 * 60 * 24 * 30, stateTtlSeconds: 600,