From f12de09d00083c19e62886e927906440f99a4752 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 21 Aug 2026 20:15:21 +0200 Subject: [PATCH] Add Yggdrasil runtime and signed health support --- Cargo.lock | 126 ++++ crates/ghostlight-dungeon/Cargo.toml | 1 + .../src/bin/ghostlight-action-smoke.rs | 2 +- .../src/bin/ghostlight-compiler-smoke.rs | 2 +- .../bin/ghostlight-gestalt-dynamics-smoke.rs | 2 +- .../src/bin/ghostlight-gestalt-scale-smoke.rs | 2 +- .../src/bin/ghostlight-live-turn-smoke.rs | 2 +- .../src/bin/ghostlight-narrator-smoke.rs | 2 +- .../bin/ghostlight-outcome-verifier-smoke.rs | 2 +- .../ghostlight-provider-health-identity.rs | 35 + .../src/bin/ghostlight-strategic-smoke.rs | 2 +- crates/ghostlight-dungeon/src/idunn_health.rs | 300 ++++++++ crates/ghostlight-dungeon/src/lib.rs | 1 + crates/ghostlight-dungeon/src/main.rs | 81 +- crates/ghostlight-dungeon/src/mesh.rs | 57 +- crates/ghostlight-dungeon/src/model.rs | 21 + vendor/cultnet-rs/Cargo.toml | 4 + vendor/cultnet-rs/src/lib.rs | 2 + vendor/cultnet-rs/src/service_identity.rs | 706 ++++++++++++++++++ 19 files changed, 1322 insertions(+), 28 deletions(-) create mode 100644 crates/ghostlight-dungeon/src/bin/ghostlight-provider-health-identity.rs create mode 100644 crates/ghostlight-dungeon/src/idunn_health.rs create mode 100644 vendor/cultnet-rs/src/service_identity.rs diff --git a/Cargo.lock b/Cargo.lock index 72ebbc1..db216c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -307,6 +313,12 @@ dependencies = [ "inout", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -427,6 +439,7 @@ dependencies = [ "base64", "chrono", "cultcache-rs 0.1.0", + "ed25519-dalek", "hmac", "pretty_assertions", "rand 0.9.5", @@ -439,6 +452,34 @@ dependencies = [ "socket2 0.5.10", "tempfile", "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -447,6 +488,16 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -487,6 +538,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -529,6 +604,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -723,6 +804,7 @@ dependencies = [ "rmp-serde", "schemars", "serde", + "serde_bytes", "serde_json", "sha2", "tempfile", @@ -1364,6 +1446,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "polyval" version = "0.6.2" @@ -1735,6 +1827,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1828,6 +1929,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1951,6 +2058,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -1995,6 +2111,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml index 22b46c8..79629ec 100644 --- a/crates/ghostlight-dungeon/Cargo.toml +++ b/crates/ghostlight-dungeon/Cargo.toml @@ -23,6 +23,7 @@ rmp-serde.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +serde_bytes = "0.11" sha2.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs index 801125f..bdfcbdc 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-action-smoke.rs @@ -35,7 +35,7 @@ async fn main() -> anyhow::Result<()> { let campaign = action_campaign(); let store = CampaignStore::open(root.join("campaign.cc"))?; store.create_campaign(&campaign, &[], &[])?; - let model: Arc = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?); + let model: Arc = Arc::new(DeepSeekPort::from_runtime_secret(secret)?); let assessor = ActionAssessor::new(model.clone(), "deepseek-v4-pro"); let kernel = WorldKernel::start(store.clone()); let started = Instant::now(); diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-compiler-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-compiler-smoke.rs index 4826d17..9d7ae09 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-compiler-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-compiler-smoke.rs @@ -17,7 +17,7 @@ async fn main() -> anyhow::Result<()> { let secret = std::env::var_os("GHOSTLIGHT_DEEPSEEK_BLOB") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(r"F:\GameCult\GhostlightDungeon\secrets\deepseek.dpapi")); - let model: Arc = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?); + let model: Arc = Arc::new(DeepSeekPort::from_runtime_secret(secret)?); let compiler = WorldCompiler::new( Arc::new(VoidBotMcpVault::starfire_loopback()), model, diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs index 268b0bc..d3fd2f5 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-dynamics-smoke.rs @@ -204,7 +204,7 @@ async fn main() -> anyhow::Result<()> { let kernel = WorldKernel::start(store.clone()); let model_calls = Arc::new(std::sync::Mutex::new(Vec::new())); let model: Arc = Arc::new(LiveFireModelRecorder { - inner: DeepSeekPort::from_machine_dpapi(secret)?, + inner: DeepSeekPort::from_runtime_secret(secret)?, calls: model_calls.clone(), }); let started = Instant::now(); diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs index 575024a..986dc1b 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs @@ -52,7 +52,7 @@ async fn main() -> anyhow::Result<()> { .clone(); let store = CampaignStore::open(root.join("campaign.cc"))?; store.create_campaign(&campaign, &[], &[])?; - let model: Arc = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?); + let model: Arc = Arc::new(DeepSeekPort::from_runtime_secret(secret)?); let started = Instant::now(); let output = propose_resolution_wave( model, diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs index 1843bd3..37ee9f6 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-live-turn-smoke.rs @@ -38,7 +38,7 @@ async fn main() -> anyhow::Result<()> { let campaign = four_actor_campaign(); let store = CampaignStore::open(root.join("campaign.cc"))?; store.create_campaign(&campaign, &[], &[])?; - let model: Arc = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?); + let model: Arc = Arc::new(DeepSeekPort::from_runtime_secret(secret)?); let engine = PersonaProjectionEngine { model, permit: Arc::new(SnapshotPermit::new(store.clone(), campaign.id, 0)), diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs index 46560de..56ceb4a 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-narrator-smoke.rs @@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> { .load::("campaign.v1", &campaign_id)? .map(|(_, value)| value) .ok_or_else(|| anyhow::anyhow!("campaign disappeared"))?; - let model: Arc = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?); + let model: Arc = Arc::new(DeepSeekPort::from_runtime_secret(secret)?); let narrator = Narrator { model, model_name: "deepseek-v4-pro".into(), diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs index f3e58e0..39f5b40 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-outcome-verifier-smoke.rs @@ -137,7 +137,7 @@ async fn main() -> anyhow::Result<()> { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(r"F:\GameCult\GhostlightDungeon\secrets\deepseek.dpapi")); let model: Arc = Arc::new(ObservedModel { - inner: DeepSeekPort::from_machine_dpapi(secret)?, + inner: DeepSeekPort::from_runtime_secret(secret)?, calls: calls.clone(), }); let started = Instant::now(); diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-provider-health-identity.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-provider-health-identity.rs new file mode 100644 index 0000000..9ffcf10 --- /dev/null +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-provider-health-identity.rs @@ -0,0 +1,35 @@ +use anyhow::{Result, bail}; +use cultnet_rs::{ + GameCultProviderHealthIdentity, enroll_service_identity_at, open_service_identity_at, +}; +use std::path::PathBuf; + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let action = args.next().unwrap_or_default(); + let path = args.next().map(PathBuf::from); + if args.next().is_some() + || path.is_none() + || !matches!(action.as_str(), "enroll" | "public-key-hex") + { + bail!("Usage: ghostlight-provider-health-identity enroll|public-key-hex "); + } + let path = path.expect("validated above"); + let signer = if action == "enroll" { + enroll_service_identity_at::(&path)? + } else { + open_service_identity_at::(&path)? + }; + println!("{}", hex(&signer.entry().public_key)); + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(DIGITS[(byte >> 4) as usize] as char); + output.push(DIGITS[(byte & 0x0f) as usize] as char); + } + output +} diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs index d5b0108..a35eb0c 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs @@ -52,7 +52,7 @@ async fn main() -> anyhow::Result<()> { .clone(); let store = CampaignStore::open(root.join("campaign.cc"))?; store.create_campaign(&campaign, &[], &[])?; - let model: Arc = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?); + let model: Arc = Arc::new(DeepSeekPort::from_runtime_secret(secret)?); let started = Instant::now(); let output = propose_resolution_wave( model, diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs new file mode 100644 index 0000000..dd61f84 --- /dev/null +++ b/crates/ghostlight-dungeon/src/idunn_health.rs @@ -0,0 +1,300 @@ +use anyhow::{Context, Result, anyhow, bail}; +use cultnet_rs::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, + GameCultProviderHealthIdentity, IdunnSignedDaemonHealthPurpose, ServiceIdentitySigner, + encode_cultnet_message_to_vec, open_service_identity_at, +}; +use serde::{Deserialize, Serialize}; +use std::{ + net::{SocketAddr, UdpSocket}, + path::Path, + time::{Duration, Instant}, +}; + +pub const GHOSTLIGHT_IDUNN_HEALTH_CONTRACT: &str = "ghostlight.cultnet-rudp-service-health"; +const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; +const IDUNN_HEALTH_RUDP_CONNECTION_ID: u32 = 0x1d0d_0001; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct IdunnSignedDaemonHealthRecord { + schema_version: String, + daemon_id: String, + health_contract: String, + source_runtime_id: String, + state: String, + detail: String, + signer_identity_id: String, + publisher_incarnation_id: String, + publisher_sequence: u64, + observed_at_unix_millis: u64, + release_id: Option, + release_witness_sha256: Option, + source_commit: Option, + deployment_id: Option, + signature_algorithm: String, + #[serde(with = "serde_bytes")] + signature: Vec, + private_state_exposed: bool, +} + +impl IdunnSignedDaemonHealthRecord { + const SCHEMA: &'static str = "idunn.signed_daemon_health.v1"; + + fn validate(&self) -> Result<()> { + for (value, label) in [ + (&self.daemon_id, "daemon id"), + (&self.health_contract, "health contract"), + (&self.source_runtime_id, "source runtime id"), + (&self.signer_identity_id, "signer identity id"), + (&self.publisher_incarnation_id, "publisher incarnation id"), + ] { + require_id(value, label)?; + } + if self.schema_version != Self::SCHEMA + || !matches!( + self.state.as_str(), + "active" | "warming" | "degraded" | "failed" + ) + || self.detail.len() > 512 + || self.detail.chars().any(char::is_control) + || self.publisher_sequence == 0 + || self.observed_at_unix_millis == 0 + || self.release_id.is_some() + || self.release_witness_sha256.is_some() + || self.source_commit.is_some() + || self.deployment_id.is_some() + || self.signature_algorithm != "ed25519" + || self.signature.len() != 64 + || self.private_state_exposed + { + bail!("signed daemon health shape is invalid"); + } + Ok(()) + } +} + +pub struct IdunnHealthPublisher { + endpoint: SocketAddr, + daemon_id: String, + source_runtime_id: String, + health_contract: String, + signer: ServiceIdentitySigner, + publisher_incarnation_id: String, + publisher_sequence: u64, +} + +impl IdunnHealthPublisher { + pub fn open( + endpoint: SocketAddr, + daemon_id: impl Into, + source_runtime_id: impl Into, + health_contract: impl Into, + identity_store: impl AsRef, + ) -> Result { + let daemon_id = daemon_id.into(); + let source_runtime_id = source_runtime_id.into(); + let health_contract = health_contract.into(); + require_id(&daemon_id, "Idunn daemon id")?; + require_id(&source_runtime_id, "health source runtime id")?; + require_id(&health_contract, "Idunn health contract")?; + Ok(Self { + endpoint, + daemon_id, + source_runtime_id, + health_contract, + signer: open_service_identity_at::( + identity_store.as_ref(), + )?, + publisher_incarnation_id: uuid::Uuid::new_v4().to_string(), + publisher_sequence: 0, + }) + } + + pub fn publish(&mut self, state: &str, detail: &str) -> Result<()> { + if !matches!(state, "active" | "warming" | "degraded" | "failed") { + bail!("unsupported Idunn daemon health state"); + } + if detail.len() > 512 || detail.chars().any(char::is_control) { + bail!("Idunn daemon health detail is oversized or contains control characters"); + } + self.publisher_sequence = self + .publisher_sequence + .checked_add(1) + .ok_or_else(|| anyhow!("Idunn health publisher sequence overflow"))?; + let mut record = IdunnSignedDaemonHealthRecord { + schema_version: IdunnSignedDaemonHealthRecord::SCHEMA.into(), + daemon_id: self.daemon_id.clone(), + health_contract: self.health_contract.clone(), + source_runtime_id: self.source_runtime_id.clone(), + state: state.into(), + detail: detail.into(), + signer_identity_id: self.signer.entry().identity_id.clone(), + publisher_incarnation_id: self.publisher_incarnation_id.clone(), + publisher_sequence: self.publisher_sequence, + observed_at_unix_millis: chrono::Utc::now().timestamp_millis().try_into()?, + release_id: None, + release_witness_sha256: None, + source_commit: None, + deployment_id: None, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + private_state_exposed: false, + }; + let unsigned = canonical_unsigned_record(&record)?; + let proof = self + .signer + .sign::(&unsigned); + if proof.identity_id != record.signer_identity_id { + bail!("provider-health signer identity disagrees with record"); + } + record.signature = proof.signature; + record.validate()?; + publish_signed_health(self.endpoint, &self.source_runtime_id, &record) + } + + pub fn public_key(&self) -> &[u8] { + &self.signer.entry().public_key + } +} + +fn publish_signed_health( + endpoint: SocketAddr, + source_runtime_id: &str, + signed: &IdunnSignedDaemonHealthRecord, +) -> Result<()> { + signed.validate()?; + let payload = rmp_serde::to_vec(signed).context("encoding canonical signed Idunn health")?; + let decoded: IdunnSignedDaemonHealthRecord = rmp_serde::from_slice(&payload)?; + if decoded != *signed || rmp_serde::to_vec(&decoded)? != payload { + bail!("signed Idunn health encoding is noncanonical"); + } + let message = CultNetMessage::DocumentPutRaw { + message_id: format!( + "ghostlight-signed-health:{}:{}:{}", + signed.daemon_id, signed.publisher_incarnation_id, signed.publisher_sequence + ), + document: CultNetRawDocumentRecord { + schema_id: IdunnSignedDaemonHealthRecord::SCHEMA.into(), + record_key: signed.daemon_id.clone(), + stored_at: chrono::DateTime::from_timestamp_millis( + signed.observed_at_unix_millis.try_into()?, + ) + .context("signed health observation time is invalid")? + .to_rfc3339(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: Some(source_runtime_id.into()), + source_agent_id: Some(signed.signer_identity_id.clone()), + source_role: Some("daemon-health-publisher".into()), + tags: Some(vec![CULTNET_RUDP_PROTOCOL_ID.into()]), + }, + }; + let bind = if endpoint.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let socket = UdpSocket::bind(bind) + .with_context(|| format!("binding Ghostlight Idunn RUDP sender at {bind}"))?; + socket.set_read_timeout(Some(Duration::from_millis(100)))?; + let mut transport = + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( + source_runtime_id, + socket, + endpoint, + IDUNN_HEALTH_RUDP_CONNECTION_ID, + ))?; + transport.connect(Vec::new())?; + let deadline = Instant::now() + Duration::from_millis(500); + while !transport.connected() { + let _ = transport.receive_once()?; + transport.poll_resends()?; + if Instant::now() >= deadline { + return Err(anyhow!( + "timed out connecting Ghostlight health publisher to {endpoint}" + )); + } + } + transport.send( + "schema", + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?, + )?; + Ok(()) +} + +fn canonical_unsigned_record(record: &IdunnSignedDaemonHealthRecord) -> Result> { + let mut unsigned = record.clone(); + unsigned.signature.clear(); + let mut signed_shape = unsigned.clone(); + signed_shape.signature = vec![0; 64]; + signed_shape.validate()?; + uuid::Uuid::parse_str(&unsigned.publisher_incarnation_id) + .context("publisher incarnation id must be UUID")?; + rmp_serde::to_vec(&unsigned).context("encoding canonical unsigned Idunn health") +} + +fn require_id(value: &str, label: &str) -> Result<()> { + if value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + bail!("{label} is empty, oversized, or contains control characters"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cultnet_rs::{ + GameCultProviderHealthIdentity, IdunnSignedDaemonHealthPurpose, enroll_service_identity_at, + verify_service_identity_signature, + }; + + #[test] + fn ghostlight_health_is_canonical_private_free_and_release_unbound() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("provider.cc"); + let signer = enroll_service_identity_at::(&path).unwrap(); + let mut publisher = IdunnHealthPublisher::open( + "127.0.0.1:17870".parse().unwrap(), + "yggdrasil-ghostlight", + "ghostlight-dungeon-yggdrasil", + GHOSTLIGHT_IDUNN_HEALTH_CONTRACT, + &path, + ) + .unwrap(); + publisher.publisher_sequence = 7; + let mut record = IdunnSignedDaemonHealthRecord { + schema_version: IdunnSignedDaemonHealthRecord::SCHEMA.into(), + daemon_id: publisher.daemon_id.clone(), + health_contract: publisher.health_contract.clone(), + source_runtime_id: publisher.source_runtime_id.clone(), + state: "active".into(), + detail: "world-kernel-serving".into(), + signer_identity_id: publisher.signer.entry().identity_id.clone(), + publisher_incarnation_id: publisher.publisher_incarnation_id.clone(), + publisher_sequence: publisher.publisher_sequence, + observed_at_unix_millis: 1_787_315_696_789, + release_id: None, + release_witness_sha256: None, + source_commit: None, + deployment_id: None, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + private_state_exposed: false, + }; + let unsigned = canonical_unsigned_record(&record).unwrap(); + let proof = publisher + .signer + .sign::(&unsigned); + record.signature = proof.signature.clone(); + record.validate().unwrap(); + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + IdunnSignedDaemonHealthPurpose, + >(&signer.trust_anchor().unwrap(), &unsigned, &proof) + .unwrap(); + assert!(record.release_id.is_none()); + assert!(!record.private_state_exposed); + assert_eq!(rmp_serde::to_vec(&record).unwrap()[..3], [0xdc, 0, 17]); + } +} diff --git a/crates/ghostlight-dungeon/src/lib.rs b/crates/ghostlight-dungeon/src/lib.rs index 7413576..6b1c45a 100644 --- a/crates/ghostlight-dungeon/src/lib.rs +++ b/crates/ghostlight-dungeon/src/lib.rs @@ -5,6 +5,7 @@ pub mod compiler; pub mod d20; pub mod domain; pub mod gestalt; +pub mod idunn_health; pub mod initiative; pub mod kernel; pub mod mesh; diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index dae92a5..794e689 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -20,8 +20,9 @@ use ghostlight_dungeon::{ RejectedProposalReceipt, WorldCommand, }, gestalt::GestaltPresencePlanner, + idunn_health::{GHOSTLIGHT_IDUNN_HEALTH_CONTRACT, IdunnHealthPublisher}, kernel::{CommandResult, KernelError}, - mesh::{CampaignMeshSnapshot, MeshPublisher, SessionZeroMeshSnapshot}, + mesh::{CampaignMeshSnapshot, MeshPublisher, MeshRuntimeIdentity, SessionZeroMeshSnapshot}, model::{DeepSeekPort, ModelPort, ModelStageRequest, run_validated_stage}, narrator::Narrator, persistence::CampaignStore, @@ -253,7 +254,7 @@ async fn main() -> anyhow::Result<()> { .init(); let runtime_root = std::env::var_os("GHOSTLIGHT_DUNGEON_ROOT") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"F:\GameCult\GhostlightDungeon")); + .unwrap_or_else(default_runtime_root); migrate_default_campaign(&runtime_root)?; let registry = CampaignRegistry::new(runtime_root.join("campaigns"))?; registry.load_existing().await?; @@ -282,10 +283,12 @@ async fn main() -> anyhow::Result<()> { } }; migrate_legacy_campaign_memberships(®istry, &auth_state).await?; - let secret_path = runtime_root.join("secrets/deepseek.dpapi"); + let secret_path = std::env::var_os("GHOSTLIGHT_DEEPSEEK_CREDENTIAL") + .map(PathBuf::from) + .unwrap_or_else(|| runtime_root.join("secrets/deepseek.dpapi")); let (deepseek_status, compiler, assessor, shared_model) = if secret_path.is_file() { let provider: Arc = - Arc::new(DeepSeekPort::from_machine_dpapi(&secret_path)?); + Arc::new(DeepSeekPort::from_runtime_secret(&secret_path)?); let probe = run_validated_stage( provider.as_ref(), &ModelStageRequest { @@ -299,11 +302,20 @@ async fn main() -> anyhow::Result<()> { max_output_tokens: Some(16), }, ) - .await?; + .await; + let deepseek_status = match probe { + Ok(probe) => format!("ready:{}", probe.receipt.output_hash), + Err(error) => { + tracing::warn!(%error, "DeepSeek startup probe failed; runtime remains available"); + "degraded:startup-probe-failed".into() + } + }; + let vault_endpoint = std::env::var("GHOSTLIGHT_VAULT_MCP_URL") + .unwrap_or_else(|_| "http://127.0.0.1:17875/mcp".into()); ( - format!("ready:{}", probe.receipt.output_hash), + deepseek_status, Some(Arc::new(WorldCompiler::new( - Arc::new(VoidBotMcpVault::starfire_loopback()), + Arc::new(VoidBotMcpVault::new(vault_endpoint)), provider.clone(), "deepseek-v4-flash", "deepseek-v4-pro", @@ -321,7 +333,39 @@ async fn main() -> anyhow::Result<()> { .ok() .map(|value| value.parse()) .transpose()?; - let mesh = MeshPublisher::open(runtime_root.join("service/mesh.cc"), mesh_target)?; + let mesh_identity = MeshRuntimeIdentity { + runtime_id: std::env::var("GHOSTLIGHT_RUNTIME_ID") + .unwrap_or_else(|_| "ghostlight-dungeon-starfire".into()), + service_id: std::env::var("GHOSTLIGHT_SERVICE_ID") + .unwrap_or_else(|_| "ghostlight-dungeon-starfire".into()), + located_service: std::env::var("GHOSTLIGHT_LOCATED_SERVICE") + .unwrap_or_else(|_| "starfire".into()), + }; + let mesh = MeshPublisher::open_with_identity( + runtime_root.join("service/mesh.cc"), + mesh_target, + mesh_identity.clone(), + )?; + let idunn_health = + std::env::var("GHOSTLIGHT_IDUNN_RUDP") + .ok() + .map(|endpoint| -> anyhow::Result { + let identity_store = std::env::var_os("GHOSTLIGHT_IDUNN_HEALTH_IDENTITY") + .map(PathBuf::from) + .ok_or_else(|| anyhow::anyhow!( + "GHOSTLIGHT_IDUNN_HEALTH_IDENTITY is required with GHOSTLIGHT_IDUNN_RUDP" + ))?; + Ok(IdunnHealthPublisher::open( + endpoint.parse()?, + std::env::var("GHOSTLIGHT_IDUNN_DAEMON") + .unwrap_or_else(|_| "yggdrasil-ghostlight".into()), + mesh_identity.runtime_id.clone(), + std::env::var("GHOSTLIGHT_IDUNN_HEALTH_CONTRACT") + .unwrap_or_else(|_| GHOSTLIGHT_IDUNN_HEALTH_CONTRACT.into()), + identity_store, + )?) + }) + .transpose()?; let session_zero_director = shared_model.as_ref().map(|model| { Arc::new(SessionZeroDirector::new( model.clone(), @@ -367,6 +411,16 @@ async fn main() -> anyhow::Result<()> { .parse()?; let listener = tokio::net::TcpListener::bind(address).await?; tracing::info!(%address, "GhostlightDungeon listening"); + if let Some(mut publisher) = idunn_health { + tokio::task::spawn_blocking(move || { + loop { + if let Err(error) = publisher.publish("active", "world-kernel-serving") { + tracing::warn!(%error, "signed Idunn health publication failed"); + } + std::thread::sleep(std::time::Duration::from_secs(10)); + } + }); + } axum::serve( listener, app.into_make_service_with_connect_info::(), @@ -375,6 +429,17 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn default_runtime_root() -> PathBuf { + #[cfg(windows)] + { + PathBuf::from(r"F:\GameCult\GhostlightDungeon") + } + #[cfg(not(windows))] + { + PathBuf::from("/var/lib/gamecult/ghostlight-dungeon") + } +} + fn app_router(state: AppState, web_root: PathBuf) -> Router { let protected_api = Router::new() .route("/api/surface", get(surface)) diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs index 6a0cbe4..a1eb79c 100644 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ b/crates/ghostlight-dungeon/src/mesh.rs @@ -40,6 +40,23 @@ pub const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon"; pub const HEALTH_KEY: &str = "ghostlight:dungeon:health"; pub const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MeshRuntimeIdentity { + pub runtime_id: String, + pub service_id: String, + pub located_service: String, +} + +impl Default for MeshRuntimeIdentity { + fn default() -> Self { + Self { + runtime_id: "ghostlight-dungeon-starfire".into(), + service_id: "ghostlight-dungeon-starfire".into(), + located_service: "starfire".into(), + } + } +} + #[derive(Clone)] pub struct CampaignMeshSnapshot { pub campaign: Campaign, @@ -131,16 +148,26 @@ cultmesh_rs::cultmesh_documents!(GhostlightDocuments { pub struct MeshPublisher { node: Arc>, remote: Option, + identity: MeshRuntimeIdentity, } #[derive(Clone)] struct RemoteReplication { target: SocketAddr, + runtime_id: String, pending: Arc<(Mutex>>, Condvar)>, } impl MeshPublisher { pub fn open(store_path: impl AsRef, rudp_target: Option) -> Result { + Self::open_with_identity(store_path, rudp_target, MeshRuntimeIdentity::default()) + } + + pub fn open_with_identity( + store_path: impl AsRef, + rudp_target: Option, + identity: MeshRuntimeIdentity, + ) -> Result { if let Some(parent) = store_path.as_ref().parent() { std::fs::create_dir_all(parent)?; } @@ -148,14 +175,16 @@ impl MeshPublisher { store_path, GhostlightDocuments, CultMeshNodeOptions { - runtime_id: "ghostlight-dungeon-starfire".into(), + runtime_id: identity.runtime_id.clone(), pull_on_start: true, }, )?; - let remote = rudp_target.map(RemoteReplication::start); + let remote = + rudp_target.map(|target| RemoteReplication::start(target, identity.runtime_id.clone())); Ok(Self { node: Arc::new(Mutex::new(node)), remote, + identity, }) } @@ -175,7 +204,7 @@ impl MeshPublisher { "deepseek":deepseek_status, "scheduler":{"live_turn_pressure":live_turn_pressure}, "updatedAtUtc":updated_at, - "runtime":"ghostlight-dungeon-starfire", + "runtime":self.identity.runtime_id.as_str(), "commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development") }); let schema_ids = vec![ @@ -269,11 +298,11 @@ impl MeshPublisher { let advertisement = json!({ "schema":"gamecult.eve.provider_advertisement.v1", "providerId":PROVIDER_ID, - "serviceId":"ghostlight-dungeon-starfire", + "serviceId":self.identity.service_id.as_str(), "verseId":"gamecult.private", "rootVerse":"gamecult", "canonicalService":"ghostlight-dungeon", - "locatedService":"starfire", + "locatedService":self.identity.located_service.as_str(), "cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon", "title":"GhostlightDungeon", "kind":"narrative.simulation", @@ -497,12 +526,13 @@ impl MeshPublisher { } impl RemoteReplication { - fn start(target: SocketAddr) -> Self { + fn start(target: SocketAddr, runtime_id: String) -> Self { let pending = Arc::new(( Mutex::new(None::>), Condvar::new(), )); let worker_pending = pending.clone(); + let worker_runtime_id = runtime_id.clone(); thread::Builder::new() .name("ghostlight-odin-rudp".into()) .spawn(move || loop { @@ -519,7 +549,7 @@ impl RemoteReplication { if messages.is_empty() { continue; } - let options = remote_options(target); + let options = remote_options(target, &worker_runtime_id); if let Err(error) = publish_cultnet_messages_to_rudp_catalog(&messages, options) { tracing::warn!( document_count = messages.len(), @@ -530,11 +560,15 @@ impl RemoteReplication { } }) .expect("Ghostlight RUDP replication worker must start"); - Self { target, pending } + Self { + target, + runtime_id, + pending, + } } fn options(&self) -> CultMeshRudpDocumentPublishOptions { - remote_options(self.target) + remote_options(self.target, &self.runtime_id) } fn enqueue(&self, messages: Vec) { @@ -545,9 +579,8 @@ impl RemoteReplication { } } -fn remote_options(target: SocketAddr) -> CultMeshRudpDocumentPublishOptions { - let mut options = - CultMeshRudpDocumentPublishOptions::odin(target, "ghostlight-dungeon-starfire"); +fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions { + let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id); options.source_agent_id = Some(PROVIDER_ID.into()); options.source_role = Some("narrative-simulation".into()); options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()]; diff --git a/crates/ghostlight-dungeon/src/model.rs b/crates/ghostlight-dungeon/src/model.rs index 4ad4d73..64719bc 100644 --- a/crates/ghostlight-dungeon/src/model.rs +++ b/crates/ghostlight-dungeon/src/model.rs @@ -511,6 +511,27 @@ impl DeepSeekPort { )?)) } + pub fn from_utf8_secret_file(path: impl AsRef) -> Result { + let bytes = Zeroizing::new(std::fs::read(path.as_ref())?); + let secret = std::str::from_utf8(bytes.as_slice())?.trim().to_owned(); + if secret.is_empty() { + anyhow::bail!("DeepSeek credential file is empty"); + } + Ok(Self::new(secret)) + } + + pub fn from_runtime_secret(path: impl AsRef) -> Result { + #[cfg(windows)] + if path + .as_ref() + .extension() + .is_some_and(|value| value == "dpapi") + { + return Self::from_machine_dpapi(path); + } + Self::from_utf8_secret_file(path) + } + async fn run_with_observation( &self, request: &ModelStageRequest, diff --git a/vendor/cultnet-rs/Cargo.toml b/vendor/cultnet-rs/Cargo.toml index 41d14b5..1334626 100644 --- a/vendor/cultnet-rs/Cargo.toml +++ b/vendor/cultnet-rs/Cargo.toml @@ -16,6 +16,7 @@ anyhow = "1" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } cultcache-rs = { path = "../cultcache-rs" } +ed25519-dalek = "2.2" hmac = "0.12" rand = "0.9" rmp-serde = "1.3" @@ -27,6 +28,9 @@ sha2 = "0.10" socket2 = "0.5" uuid = { version = "1", features = ["v4"] } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security_Cryptography"] } + [dev-dependencies] pretty_assertions = "1.4" tempfile = "3" diff --git a/vendor/cultnet-rs/src/lib.rs b/vendor/cultnet-rs/src/lib.rs index bc804d3..da2d171 100644 --- a/vendor/cultnet-rs/src/lib.rs +++ b/vendor/cultnet-rs/src/lib.rs @@ -4,6 +4,7 @@ mod replication; mod rudp; mod schema_discovery; mod security; +mod service_identity; mod transport; pub use contracts::*; @@ -12,4 +13,5 @@ pub use replication::*; pub use rudp::*; pub use schema_discovery::*; pub use security::*; +pub use service_identity::*; pub use transport::*; diff --git a/vendor/cultnet-rs/src/service_identity.rs b/vendor/cultnet-rs/src/service_identity.rs new file mode 100644 index 0000000..8420246 --- /dev/null +++ b/vendor/cultnet-rs/src/service_identity.rs @@ -0,0 +1,706 @@ +use anyhow::{Context, Result, anyhow, bail}; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, SingleFileMessagePackBackingStore}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::Write; +use std::marker::PhantomData; +use std::path::Path; + +pub const WINDOWS_SERVICE_IDENTITY_ASSURANCE: &str = "os_user_installation_bound_best_effort"; +pub const LINUX_SERVICE_IDENTITY_ASSURANCE: &str = "os_installation_file_bound_cloneable_baseline"; + +/// Fixes every authority-bearing name at the call site. A service cannot open +/// another service's identity by supplying a convenient runtime string. +pub trait ServiceIdentityProfile: Send + Sync + 'static { + const PRIVATE_TYPE: &'static str; + const PRIVATE_SCHEMA: &'static str; + const PRIVATE_KEY: &'static str; + const TRUST_ANCHOR_TYPE: &'static str; + const TRUST_ANCHOR_SCHEMA: &'static str; + const TRUST_ANCHOR_KEY: &'static str; + const ID_DOMAIN: &'static [u8]; + const SIGNATURE_DOMAIN: &'static [u8]; + const PROTECTOR_CONTEXT: &'static str; +} + +/// A purpose is a type, not caller-provided prose. Protocol implementations +/// define one zero-sized type per signed statement kind. +pub trait ServiceSignaturePurpose: Send + Sync + 'static { + const PURPOSE: &'static [u8]; +} + +pub enum IdunnServiceIdentity {} + +/// Root-operated identity dedicated to deployment-brake release grants. The +/// Idunn daemon receives only this profile's public trust anchor; opening the +/// private store is a provisioning operation, never daemon startup work. +pub enum IdunnDeploymentBrakeOperatorIdentity {} + +pub struct IdunnDeploymentBrakeReleasePurpose; + +impl ServiceSignaturePurpose + for IdunnDeploymentBrakeReleasePurpose +{ + const PURPOSE: &'static [u8] = b"idunn.deployment-release.v1"; +} + +impl ServiceIdentityProfile for IdunnDeploymentBrakeOperatorIdentity { + const PRIVATE_TYPE: &'static str = "idunn.deployment_brake_operator.private.v1"; + const PRIVATE_SCHEMA: &'static str = "idunn.deployment_brake_operator.private.v1"; + const PRIVATE_KEY: &'static str = "idunn-deployment-brake-operator"; + const TRUST_ANCHOR_TYPE: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.deployment_brake_operator.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "idunn-deployment-brake-operator-public"; + const ID_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.id.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.deployment-brake-operator.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "idunn-deployment-brake-operator-v1"; +} + +/// Dedicated provider-owned identity for generic daemon-health statements. +/// It is intentionally independent of any one provider runtime or repository. +pub enum GameCultProviderHealthIdentity {} + +/// The only generic health statement purpose accepted from provider-health +/// identities by Idunn admission. +pub struct IdunnSignedDaemonHealthPurpose; + +impl ServiceSignaturePurpose for IdunnSignedDaemonHealthPurpose { + const PURPOSE: &'static [u8] = b"idunn.signed_daemon_health.v1"; +} + +impl ServiceIdentityProfile for GameCultProviderHealthIdentity { + const PRIVATE_TYPE: &'static str = "gamecult.provider_health_identity.private.v1"; + const PRIVATE_SCHEMA: &'static str = "gamecult.provider_health_identity.private.v1"; + const PRIVATE_KEY: &'static str = "gamecult-provider-health-identity"; + const TRUST_ANCHOR_TYPE: &'static str = "gamecult.provider_health_identity.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "gamecult.provider_health_identity.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "gamecult-provider-health-identity-public"; + const ID_DOMAIN: &'static [u8] = b"gamecult.provider-health.identity.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"gamecult.provider-health.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "gamecult-provider-health-identity-v1"; +} + +/// The only signing purpose accepted for Idunn's public projection of a +/// provider-authenticated health admission. Keeping this profile here makes +/// purpose selection a compile-time protocol choice rather than caller text. +pub struct IdunnAuthenticatedProviderHealthProjectionPurpose; + +impl ServiceSignaturePurpose + for IdunnAuthenticatedProviderHealthProjectionPurpose +{ + const PURPOSE: &'static [u8] = b"idunn.authenticated-provider-health-projection.v1"; +} + +impl ServiceIdentityProfile for IdunnServiceIdentity { + const PRIVATE_TYPE: &'static str = "idunn.service_identity.private.v1"; + const PRIVATE_SCHEMA: &'static str = "idunn.service_identity.private.v1"; + const PRIVATE_KEY: &'static str = "idunn-service-identity"; + const TRUST_ANCHOR_TYPE: &'static str = "idunn.service_identity.trust_anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "idunn.service_identity.trust_anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "idunn-service-identity-public"; + const ID_DOMAIN: &'static [u8] = b"idunn.service-identity.id.v1\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"idunn.service-identity.signature.v1\0"; + const PROTECTOR_CONTEXT: &'static str = "idunn-service-identity-v1"; +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ServiceIdentityPrivateEntry { + pub schema_version: String, + pub identity_id: String, + pub public_key: Vec, + pub protected_private_seed: Vec, + pub protector_kind: String, + pub protector_binding: String, + pub protector_version: String, + pub assurance: String, + pub created_at: String, + pub enrollment_nonce: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceIdentityTrustAnchor { + pub schema_version: String, + pub identity_id: String, + pub public_key: Vec, + pub assurance: String, + pub identity_created_at: String, + pub source_identity_record_sha256: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceIdentitySignature { + pub identity_id: String, + pub signature: Vec, +} + +/// The only value holding unprotected key material. It exposes signing, never +/// seed export or access to the underlying signing key. +pub struct ServiceIdentitySigner { + entry: ServiceIdentityPrivateEntry, + signing_key: SigningKey, + profile: PhantomData

, +} + +impl ServiceIdentitySigner

{ + pub fn entry(&self) -> &ServiceIdentityPrivateEntry { + &self.entry + } + + pub fn trust_anchor(&self) -> Result { + Ok(ServiceIdentityTrustAnchor { + schema_version: P::TRUST_ANCHOR_SCHEMA.into(), + identity_id: self.entry.identity_id.clone(), + public_key: self.entry.public_key.clone(), + assurance: self.entry.assurance.clone(), + identity_created_at: self.entry.created_at.clone(), + source_identity_record_sha256: format!( + "sha256-{}", + hex(&Sha256::digest(rmp_serde::to_vec(&self.entry)?)) + ), + }) + } + + pub fn sign>(&self, payload: &[u8]) -> ServiceIdentitySignature { + ServiceIdentitySignature { + identity_id: self.entry.identity_id.clone(), + signature: self + .signing_key + .sign(&signing_message::(payload)) + .to_bytes() + .to_vec(), + } + } +} + +pub fn enroll_service_identity_at( + path: &Path, +) -> Result> { + if path.exists() { + bail!( + "service identity store {} already exists; enrollment is immutable", + path.display() + ); + } + prepare_parent(path)?; + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + let signing_key = SigningKey::from_bytes(&seed); + let mut nonce = [0u8; 32]; + rand::rng().fill_bytes(&mut nonce); + let public_key = signing_key.verifying_key().to_bytes(); + let binding = platform_binding::

()?; + let entry = ServiceIdentityPrivateEntry { + schema_version: P::PRIVATE_SCHEMA.into(), + identity_id: identity_id::

(&public_key), + public_key: public_key.to_vec(), + protected_private_seed: protect_seed::

(&seed, &binding)?, + protector_kind: platform_protector_kind().into(), + protector_binding: binding, + protector_version: "v1".into(), + assurance: platform_assurance().into(), + created_at: chrono::Utc::now().to_rfc3339(), + enrollment_nonce: nonce.to_vec(), + }; + validate_private::

(&entry)?; + let envelope = private_envelope::

(&entry)?; + atomic_create_private_store(path, &envelope)?; + harden_store_permissions(path)?; + open_service_identity_at::

(path) +} + +pub fn open_service_identity_at( + path: &Path, +) -> Result> { + if !path.is_file() { + bail!("service identity store {} does not exist", path.display()); + } + let entries = SingleFileMessagePackBackingStore::new(path).pull_all()?; + if entries.len() != 1 { + bail!("service identity store must contain exactly one immutable envelope"); + } + let envelope = &entries[0]; + if envelope.r#type != P::PRIVATE_TYPE + || envelope.key != P::PRIVATE_KEY + || envelope.schema_id.as_deref() != Some(P::PRIVATE_SCHEMA) + { + bail!("service identity store belongs to a different profile or schema"); + } + let entry: ServiceIdentityPrivateEntry = rmp_serde::from_slice(&envelope.payload) + .context("service identity private payload is malformed MessagePack")?; + validate_private::

(&entry)?; + if entry.protector_kind != platform_protector_kind() || entry.assurance != platform_assurance() + { + bail!("service identity protector does not belong to this platform implementation"); + } + let binding = platform_binding::

()?; + if entry.protector_binding != binding { + bail!("service identity protector binding does not match this OS installation or profile"); + } + let seed: [u8; 32] = unprotect_seed::

(&entry.protected_private_seed, &binding)? + .try_into() + .map_err(|_| anyhow!("unprotected service identity seed has invalid length"))?; + let signing_key = SigningKey::from_bytes(&seed); + if signing_key.verifying_key().to_bytes().as_slice() != entry.public_key.as_slice() { + bail!("service identity private seed does not match enrolled public key"); + } + Ok(ServiceIdentitySigner { + entry, + signing_key, + profile: PhantomData, + }) +} + +pub fn export_service_identity_trust_anchor( + signer: &ServiceIdentitySigner

, + path: &Path, +) -> Result { + let anchor = signer.trust_anchor()?; + prepare_parent(path)?; + let envelope = CultCacheEnvelope { + key: P::TRUST_ANCHOR_KEY.into(), + r#type: P::TRUST_ANCHOR_TYPE.into(), + payload: rmp_serde::to_vec(&anchor)?, + stored_at: anchor.identity_created_at.clone(), + schema_id: Some(P::TRUST_ANCHOR_SCHEMA.into()), + }; + let mut backing = SingleFileMessagePackBackingStore::new(path); + match backing.pull_all()?.as_slice() { + [] => backing.push(&envelope)?, + [current] if current == &envelope => {} + _ => bail!("service identity trust anchor output already contains different state"), + } + Ok(anchor) +} + +pub fn verify_service_identity_signature( + anchor: &ServiceIdentityTrustAnchor, + payload: &[u8], + proof: &ServiceIdentitySignature, +) -> Result<()> +where + P: ServiceIdentityProfile, + S: ServiceSignaturePurpose

, +{ + validate_anchor::

(anchor)?; + if proof.identity_id != anchor.identity_id { + bail!("service identity signature names a different identity"); + } + let key: [u8; 32] = anchor + .public_key + .as_slice() + .try_into() + .map_err(|_| anyhow!("service identity public key has invalid length"))?; + let signature = Signature::from_slice(&proof.signature) + .map_err(|_| anyhow!("service identity signature has invalid length"))?; + VerifyingKey::from_bytes(&key)? + .verify(&signing_message::(payload), &signature) + .map_err(|_| anyhow!("service identity signature verification failed")) +} + +/// Verify against a public key already admitted by an owning trust store. +/// The profile derives the identity, so callers cannot substitute a convenient +/// identity string while retaining the same signature bytes. +pub fn verify_service_identity_signature_with_public_key( + public_key: &[u8], + payload: &[u8], + proof: &ServiceIdentitySignature, +) -> Result<()> +where + P: ServiceIdentityProfile, + S: ServiceSignaturePurpose

, +{ + if proof.identity_id != derive_service_identity_id::

(public_key)? { + bail!("service identity signature names a different identity"); + } + let key: [u8; 32] = public_key + .try_into() + .map_err(|_| anyhow!("service identity public key has invalid length"))?; + let signature = Signature::from_slice(&proof.signature) + .map_err(|_| anyhow!("service identity signature has invalid length"))?; + VerifyingKey::from_bytes(&key)? + .verify(&signing_message::(payload), &signature) + .map_err(|_| anyhow!("service identity signature verification failed")) +} + +fn validate_private(entry: &ServiceIdentityPrivateEntry) -> Result<()> { + if entry.schema_version != P::PRIVATE_SCHEMA + || entry.public_key.len() != 32 + || entry.enrollment_nonce.len() != 32 + || entry.protected_private_seed.is_empty() + || entry.protector_version != "v1" + { + bail!("service identity private entry violates its profile schema"); + } + chrono::DateTime::parse_from_rfc3339(&entry.created_at) + .map_err(|_| anyhow!("service identity created_at is not RFC3339"))?; + if identity_id::

(&entry.public_key) != entry.identity_id { + bail!("service identity id does not match public key"); + } + Ok(()) +} + +fn validate_anchor(anchor: &ServiceIdentityTrustAnchor) -> Result<()> { + if anchor.schema_version != P::TRUST_ANCHOR_SCHEMA + || anchor.public_key.len() != 32 + || derive_service_identity_id::

(&anchor.public_key)? != anchor.identity_id + { + bail!("service identity trust anchor violates its profile schema"); + } + Ok(()) +} + +/// Derives the profile-bound identity id from an exact Ed25519 public key. +/// Consumers use this instead of accepting a caller-supplied identity string. +pub fn derive_service_identity_id(public_key: &[u8]) -> Result { + if public_key.len() != 32 { + bail!("service identity public key has invalid length"); + } + Ok(hex(&Sha256::digest([P::ID_DOMAIN, public_key].concat()))) +} + +fn identity_id(key: &[u8]) -> String { + derive_service_identity_id::

(key).expect("internal service identity key is Ed25519") +} + +fn signing_message>( + payload: &[u8], +) -> Vec { + let mut out = + Vec::with_capacity(P::SIGNATURE_DOMAIN.len() + S::PURPOSE.len() + payload.len() + 16); + out.extend_from_slice(P::SIGNATURE_DOMAIN); + out.extend_from_slice(&(S::PURPOSE.len() as u64).to_be_bytes()); + out.extend_from_slice(S::PURPOSE); + out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + out.extend_from_slice(payload); + out +} + +fn private_envelope( + entry: &ServiceIdentityPrivateEntry, +) -> Result { + Ok(CultCacheEnvelope { + key: P::PRIVATE_KEY.into(), + r#type: P::PRIVATE_TYPE.into(), + payload: rmp_serde::to_vec(entry)?, + stored_at: entry.created_at.clone(), + schema_id: Some(P::PRIVATE_SCHEMA.into()), + }) +} + +fn prepare_parent(path: &Path) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("service identity path has no parent"))?; + std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +/// Enrollment is a one-shot authority transition. `create_new` makes two +/// simultaneous enrollments unable to overwrite one another, while the encoded +/// envelope vector remains a supported CultCache single-file representation. +fn atomic_create_private_store(path: &Path, envelope: &CultCacheEnvelope) -> Result<()> { + let bytes = rmp_serde::to_vec(&vec![envelope.clone()]) + .context("failed to encode immutable service identity store")?; + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .with_context(|| { + format!( + "service identity store {} already exists or cannot be created; enrollment is immutable", + path.display() + ) + })?; + if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) { + drop(file); + let _ = std::fs::remove_file(path); + return Err(error).context("failed to persist immutable service identity store"); + } + Ok(()) +} +fn harden_store_permissions(_path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(windows)] +fn platform_protector_kind() -> &'static str { + "windows_dpapi_current_user" +} +#[cfg(target_os = "linux")] +fn platform_protector_kind() -> &'static str { + "linux_file_mode_machine_id_binding" +} +#[cfg(windows)] +fn platform_assurance() -> &'static str { + WINDOWS_SERVICE_IDENTITY_ASSURANCE +} +#[cfg(target_os = "linux")] +fn platform_assurance() -> &'static str { + LINUX_SERVICE_IDENTITY_ASSURANCE +} + +#[cfg(windows)] +fn platform_binding() -> Result { + Ok(format!("dpapi-current-user:{}", P::PROTECTOR_CONTEXT)) +} +#[cfg(target_os = "linux")] +fn platform_binding() -> Result { + let raw = std::fs::read_to_string("/etc/machine-id") + .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id")) + .context("Linux machine-id is unavailable")?; + let id = raw.trim(); + if id.is_empty() { + bail!("Linux machine-id is empty"); + } + Ok(format!( + "{}:machine-id-sha256:{}", + P::PROTECTOR_CONTEXT, + hex(&Sha256::digest(id.as_bytes())) + )) +} + +#[cfg(windows)] +fn protect_seed(seed: &[u8; 32], binding: &str) -> Result> { + dpapi::

(seed, binding, true) +} +#[cfg(windows)] +fn unprotect_seed(seed: &[u8], binding: &str) -> Result> { + dpapi::

(seed, binding, false) +} +#[cfg(windows)] +fn dpapi(input: &[u8], binding: &str, protect: bool) -> Result> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Security::Cryptography::{ + CRYPT_INTEGER_BLOB, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData, + }; + let mut input = input.to_vec(); + let mut entropy = Sha256::digest( + [ + b"gamecult-service-identity-dpapi-v1\0".as_slice(), + P::PROTECTOR_CONTEXT.as_bytes(), + binding.as_bytes(), + ] + .concat(), + ) + .to_vec(); + let ib = CRYPT_INTEGER_BLOB { + cbData: input.len() as u32, + pbData: input.as_mut_ptr(), + }; + let eb = CRYPT_INTEGER_BLOB { + cbData: entropy.len() as u32, + pbData: entropy.as_mut_ptr(), + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + let ok = unsafe { + if protect { + CryptProtectData( + &ib, + std::ptr::null(), + &eb, + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } else { + CryptUnprotectData( + &ib, + std::ptr::null_mut(), + &eb, + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut output, + ) + } + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()) + .context("DPAPI service identity operation failed"); + } + let bytes = + unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + unsafe { + LocalFree(output.pbData.cast()); + } + Ok(bytes) +} + +#[cfg(target_os = "linux")] +fn protect_seed(seed: &[u8; 32], binding: &str) -> Result> { + xor_linux::

(seed, binding) +} +#[cfg(target_os = "linux")] +fn unprotect_seed(seed: &[u8], binding: &str) -> Result> { + if seed.len() != 32 { + bail!("protected Linux service seed has invalid length"); + } + xor_linux::

(seed, binding) +} +#[cfg(target_os = "linux")] +fn xor_linux(seed: &[u8], binding: &str) -> Result> { + let mask = Sha256::digest( + [ + b"gamecult-linux-service-seed-v1\0".as_slice(), + P::PROTECTOR_CONTEXT.as_bytes(), + binding.as_bytes(), + ] + .concat(), + ); + Ok(seed.iter().zip(mask).map(|(a, b)| a ^ b).collect()) +} + +fn hex(bytes: &[u8]) -> String { + const D: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(D[(b >> 4) as usize] as char); + s.push(D[(b & 15) as usize] as char); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + enum OtherPurpose {} + impl ServiceSignaturePurpose for OtherPurpose { + const PURPOSE: &'static [u8] = b"idunn.other.v1"; + } + + #[test] + fn immutable_enrollment_reopens_and_is_purpose_and_payload_bound() -> Result<()> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("idunn.ccmp"); + let signer = enroll_service_identity_at::(&path)?; + let proof = signer.sign::(b"healthy"); + let anchor = signer.trust_anchor()?; + verify_service_identity_signature::< + IdunnServiceIdentity, + IdunnAuthenticatedProviderHealthProjectionPurpose, + >(&anchor, b"healthy", &proof)?; + assert!( + verify_service_identity_signature::< + IdunnServiceIdentity, + IdunnAuthenticatedProviderHealthProjectionPurpose, + >(&anchor, b"unhealthy", &proof) + .is_err() + ); + assert!( + verify_service_identity_signature::( + &anchor, b"healthy", &proof + ) + .is_err() + ); + assert!(enroll_service_identity_at::(&path).is_err()); + assert_eq!( + derive_service_identity_id::(&anchor.public_key)?, + anchor.identity_id + ); + assert!(derive_service_identity_id::(&[0; 31]).is_err()); + assert_eq!( + open_service_identity_at::(&path)?.entry(), + signer.entry() + ); + Ok(()) + } + + #[test] + fn malformed_or_profile_substituted_state_fails_closed_without_replacement() -> Result<()> { + enum Alien {} + impl ServiceIdentityProfile for Alien { + const PRIVATE_TYPE: &'static str = "alien.private.v1"; + const PRIVATE_SCHEMA: &'static str = "alien.private.v1"; + const PRIVATE_KEY: &'static str = "alien"; + const TRUST_ANCHOR_TYPE: &'static str = "alien.anchor.v1"; + const TRUST_ANCHOR_SCHEMA: &'static str = "alien.anchor.v1"; + const TRUST_ANCHOR_KEY: &'static str = "alien-public"; + const ID_DOMAIN: &'static [u8] = b"alien.id\0"; + const SIGNATURE_DOMAIN: &'static [u8] = b"alien.sig\0"; + const PROTECTOR_CONTEXT: &'static str = "alien-v1"; + } + let temp = tempfile::tempdir()?; + let malformed = temp.path().join("bad.ccmp"); + std::fs::write(&malformed, b"rot")?; + let before = std::fs::read(&malformed)?; + assert!(open_service_identity_at::(&malformed).is_err()); + assert!(enroll_service_identity_at::(&malformed).is_err()); + assert_eq!(before, std::fs::read(&malformed)?); + let good = temp.path().join("good.ccmp"); + enroll_service_identity_at::(&good)?; + assert!(open_service_identity_at::(&good).is_err()); + Ok(()) + } + + #[test] + fn exported_anchor_is_public_only_immutable_and_rejects_identity_substitution() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = + enroll_service_identity_at::(&temp.path().join("private.ccmp"))?; + let output = temp.path().join("public.ccmp"); + let anchor = export_service_identity_trust_anchor(&signer, &output)?; + let bytes = std::fs::read(&output)?; + assert!( + !bytes + .windows(signer.entry().protected_private_seed.len()) + .any(|w| w == signer.entry().protected_private_seed) + ); + export_service_identity_trust_anchor(&signer, &output)?; + assert_eq!(bytes, std::fs::read(&output)?); + let other = + enroll_service_identity_at::(&temp.path().join("other.ccmp"))?; + let proof = other.sign::(b"healthy"); + assert!( + verify_service_identity_signature::< + IdunnServiceIdentity, + IdunnAuthenticatedProviderHealthProjectionPurpose, + >(&anchor, b"healthy", &proof) + .is_err() + ); + Ok(()) + } + + #[test] + fn provider_health_profile_is_domain_separated_and_purpose_bound() -> Result<()> { + let temp = tempfile::tempdir()?; + let signer = enroll_service_identity_at::( + &temp.path().join("provider-health.cc"), + )?; + let proof = signer.sign::(b"signed-health-record"); + let anchor = signer.trust_anchor()?; + verify_service_identity_signature::< + GameCultProviderHealthIdentity, + IdunnSignedDaemonHealthPurpose, + >(&anchor, b"signed-health-record", &proof)?; + verify_service_identity_signature_with_public_key::< + GameCultProviderHealthIdentity, + IdunnSignedDaemonHealthPurpose, + >(&anchor.public_key, b"signed-health-record", &proof)?; + assert_eq!( + anchor.schema_version, + "gamecult.provider_health_identity.trust_anchor.v1" + ); + assert_ne!( + derive_service_identity_id::(&anchor.public_key)?, + derive_service_identity_id::(&anchor.public_key)? + ); + Ok(()) + } +} +