Add Yggdrasil runtime and signed health support

This commit is contained in:
Meta Vens 2026-08-21 20:15:21 +02:00
commit f12de09d00
19 changed files with 1322 additions and 28 deletions

126
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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<dyn ModelPort> = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?);
let model: Arc<dyn ModelPort> = 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();

View file

@ -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<dyn ModelPort> = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?);
let model: Arc<dyn ModelPort> = Arc::new(DeepSeekPort::from_runtime_secret(secret)?);
let compiler = WorldCompiler::new(
Arc::new(VoidBotMcpVault::starfire_loopback()),
model,

View file

@ -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<dyn ModelPort> = Arc::new(LiveFireModelRecorder {
inner: DeepSeekPort::from_machine_dpapi(secret)?,
inner: DeepSeekPort::from_runtime_secret(secret)?,
calls: model_calls.clone(),
});
let started = Instant::now();

View file

@ -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<dyn ModelPort> = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?);
let model: Arc<dyn ModelPort> = Arc::new(DeepSeekPort::from_runtime_secret(secret)?);
let started = Instant::now();
let output = propose_resolution_wave(
model,

View file

@ -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<dyn ModelPort> = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?);
let model: Arc<dyn ModelPort> = Arc::new(DeepSeekPort::from_runtime_secret(secret)?);
let engine = PersonaProjectionEngine {
model,
permit: Arc::new(SnapshotPermit::new(store.clone(), campaign.id, 0)),

View file

@ -31,7 +31,7 @@ async fn main() -> anyhow::Result<()> {
.load::<Campaign>("campaign.v1", &campaign_id)?
.map(|(_, value)| value)
.ok_or_else(|| anyhow::anyhow!("campaign disappeared"))?;
let model: Arc<dyn ModelPort> = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?);
let model: Arc<dyn ModelPort> = Arc::new(DeepSeekPort::from_runtime_secret(secret)?);
let narrator = Narrator {
model,
model_name: "deepseek-v4-pro".into(),

View file

@ -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<dyn ModelPort> = Arc::new(ObservedModel {
inner: DeepSeekPort::from_machine_dpapi(secret)?,
inner: DeepSeekPort::from_runtime_secret(secret)?,
calls: calls.clone(),
});
let started = Instant::now();

View file

@ -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 <private-store>");
}
let path = path.expect("validated above");
let signer = if action == "enroll" {
enroll_service_identity_at::<GameCultProviderHealthIdentity>(&path)?
} else {
open_service_identity_at::<GameCultProviderHealthIdentity>(&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
}

View file

@ -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<dyn ModelPort> = Arc::new(DeepSeekPort::from_machine_dpapi(secret)?);
let model: Arc<dyn ModelPort> = Arc::new(DeepSeekPort::from_runtime_secret(secret)?);
let started = Instant::now();
let output = propose_resolution_wave(
model,

View file

@ -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<String>,
release_witness_sha256: Option<String>,
source_commit: Option<String>,
deployment_id: Option<String>,
signature_algorithm: String,
#[serde(with = "serde_bytes")]
signature: Vec<u8>,
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<GameCultProviderHealthIdentity>,
publisher_incarnation_id: String,
publisher_sequence: u64,
}
impl IdunnHealthPublisher {
pub fn open(
endpoint: SocketAddr,
daemon_id: impl Into<String>,
source_runtime_id: impl Into<String>,
health_contract: impl Into<String>,
identity_store: impl AsRef<Path>,
) -> Result<Self> {
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::<GameCultProviderHealthIdentity>(
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::<IdunnSignedDaemonHealthPurpose>(&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<Vec<u8>> {
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::<GameCultProviderHealthIdentity>(&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::<IdunnSignedDaemonHealthPurpose>(&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]);
}
}

View file

@ -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;

View file

@ -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(&registry, &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<dyn ModelPort> =
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<IdunnHealthPublisher> {
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::<SocketAddr>(),
@ -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))

View file

@ -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<Mutex<CultMeshNode>>,
remote: Option<RemoteReplication>,
identity: MeshRuntimeIdentity,
}
#[derive(Clone)]
struct RemoteReplication {
target: SocketAddr,
runtime_id: String,
pending: Arc<(Mutex<Option<Vec<cultnet_rs::CultNetMessage>>>, Condvar)>,
}
impl MeshPublisher {
pub fn open(store_path: impl AsRef<Path>, rudp_target: Option<SocketAddr>) -> Result<Self> {
Self::open_with_identity(store_path, rudp_target, MeshRuntimeIdentity::default())
}
pub fn open_with_identity(
store_path: impl AsRef<Path>,
rudp_target: Option<SocketAddr>,
identity: MeshRuntimeIdentity,
) -> Result<Self> {
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::<Vec<cultnet_rs::CultNetMessage>>),
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<cultnet_rs::CultNetMessage>) {
@ -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()];

View file

@ -511,6 +511,27 @@ impl DeepSeekPort {
)?))
}
pub fn from_utf8_secret_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
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<std::path::Path>) -> Result<Self> {
#[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,

View file

@ -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"

View file

@ -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::*;

View file

@ -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<P: ServiceIdentityProfile>: 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<IdunnDeploymentBrakeOperatorIdentity>
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<GameCultProviderHealthIdentity> 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<IdunnServiceIdentity>
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<u8>,
pub protected_private_seed: Vec<u8>,
pub protector_kind: String,
pub protector_binding: String,
pub protector_version: String,
pub assurance: String,
pub created_at: String,
pub enrollment_nonce: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceIdentityTrustAnchor {
pub schema_version: String,
pub identity_id: String,
pub public_key: Vec<u8>,
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<u8>,
}
/// The only value holding unprotected key material. It exposes signing, never
/// seed export or access to the underlying signing key.
pub struct ServiceIdentitySigner<P: ServiceIdentityProfile> {
entry: ServiceIdentityPrivateEntry,
signing_key: SigningKey,
profile: PhantomData<P>,
}
impl<P: ServiceIdentityProfile> ServiceIdentitySigner<P> {
pub fn entry(&self) -> &ServiceIdentityPrivateEntry {
&self.entry
}
pub fn trust_anchor(&self) -> Result<ServiceIdentityTrustAnchor> {
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<S: ServiceSignaturePurpose<P>>(&self, payload: &[u8]) -> ServiceIdentitySignature {
ServiceIdentitySignature {
identity_id: self.entry.identity_id.clone(),
signature: self
.signing_key
.sign(&signing_message::<P, S>(payload))
.to_bytes()
.to_vec(),
}
}
}
pub fn enroll_service_identity_at<P: ServiceIdentityProfile>(
path: &Path,
) -> Result<ServiceIdentitySigner<P>> {
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::<P>()?;
let entry = ServiceIdentityPrivateEntry {
schema_version: P::PRIVATE_SCHEMA.into(),
identity_id: identity_id::<P>(&public_key),
public_key: public_key.to_vec(),
protected_private_seed: protect_seed::<P>(&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::<P>(&entry)?;
let envelope = private_envelope::<P>(&entry)?;
atomic_create_private_store(path, &envelope)?;
harden_store_permissions(path)?;
open_service_identity_at::<P>(path)
}
pub fn open_service_identity_at<P: ServiceIdentityProfile>(
path: &Path,
) -> Result<ServiceIdentitySigner<P>> {
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::<P>(&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::<P>()?;
if entry.protector_binding != binding {
bail!("service identity protector binding does not match this OS installation or profile");
}
let seed: [u8; 32] = unprotect_seed::<P>(&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<P: ServiceIdentityProfile>(
signer: &ServiceIdentitySigner<P>,
path: &Path,
) -> Result<ServiceIdentityTrustAnchor> {
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<P, S>(
anchor: &ServiceIdentityTrustAnchor,
payload: &[u8],
proof: &ServiceIdentitySignature,
) -> Result<()>
where
P: ServiceIdentityProfile,
S: ServiceSignaturePurpose<P>,
{
validate_anchor::<P>(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::<P, S>(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<P, S>(
public_key: &[u8],
payload: &[u8],
proof: &ServiceIdentitySignature,
) -> Result<()>
where
P: ServiceIdentityProfile,
S: ServiceSignaturePurpose<P>,
{
if proof.identity_id != derive_service_identity_id::<P>(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::<P, S>(payload), &signature)
.map_err(|_| anyhow!("service identity signature verification failed"))
}
fn validate_private<P: ServiceIdentityProfile>(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::<P>(&entry.public_key) != entry.identity_id {
bail!("service identity id does not match public key");
}
Ok(())
}
fn validate_anchor<P: ServiceIdentityProfile>(anchor: &ServiceIdentityTrustAnchor) -> Result<()> {
if anchor.schema_version != P::TRUST_ANCHOR_SCHEMA
|| anchor.public_key.len() != 32
|| derive_service_identity_id::<P>(&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<P: ServiceIdentityProfile>(public_key: &[u8]) -> Result<String> {
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<P: ServiceIdentityProfile>(key: &[u8]) -> String {
derive_service_identity_id::<P>(key).expect("internal service identity key is Ed25519")
}
fn signing_message<P: ServiceIdentityProfile, S: ServiceSignaturePurpose<P>>(
payload: &[u8],
) -> Vec<u8> {
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<P: ServiceIdentityProfile>(
entry: &ServiceIdentityPrivateEntry,
) -> Result<CultCacheEnvelope> {
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<P: ServiceIdentityProfile>() -> Result<String> {
Ok(format!("dpapi-current-user:{}", P::PROTECTOR_CONTEXT))
}
#[cfg(target_os = "linux")]
fn platform_binding<P: ServiceIdentityProfile>() -> Result<String> {
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<P: ServiceIdentityProfile>(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
dpapi::<P>(seed, binding, true)
}
#[cfg(windows)]
fn unprotect_seed<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
dpapi::<P>(seed, binding, false)
}
#[cfg(windows)]
fn dpapi<P: ServiceIdentityProfile>(input: &[u8], binding: &str, protect: bool) -> Result<Vec<u8>> {
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<P: ServiceIdentityProfile>(seed: &[u8; 32], binding: &str) -> Result<Vec<u8>> {
xor_linux::<P>(seed, binding)
}
#[cfg(target_os = "linux")]
fn unprotect_seed<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
if seed.len() != 32 {
bail!("protected Linux service seed has invalid length");
}
xor_linux::<P>(seed, binding)
}
#[cfg(target_os = "linux")]
fn xor_linux<P: ServiceIdentityProfile>(seed: &[u8], binding: &str) -> Result<Vec<u8>> {
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<IdunnServiceIdentity> 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::<IdunnServiceIdentity>(&path)?;
let proof = signer.sign::<IdunnAuthenticatedProviderHealthProjectionPurpose>(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::<IdunnServiceIdentity, OtherPurpose>(
&anchor, b"healthy", &proof
)
.is_err()
);
assert!(enroll_service_identity_at::<IdunnServiceIdentity>(&path).is_err());
assert_eq!(
derive_service_identity_id::<IdunnServiceIdentity>(&anchor.public_key)?,
anchor.identity_id
);
assert!(derive_service_identity_id::<IdunnServiceIdentity>(&[0; 31]).is_err());
assert_eq!(
open_service_identity_at::<IdunnServiceIdentity>(&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::<IdunnServiceIdentity>(&malformed).is_err());
assert!(enroll_service_identity_at::<IdunnServiceIdentity>(&malformed).is_err());
assert_eq!(before, std::fs::read(&malformed)?);
let good = temp.path().join("good.ccmp");
enroll_service_identity_at::<IdunnServiceIdentity>(&good)?;
assert!(open_service_identity_at::<Alien>(&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::<IdunnServiceIdentity>(&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::<IdunnServiceIdentity>(&temp.path().join("other.ccmp"))?;
let proof = other.sign::<IdunnAuthenticatedProviderHealthProjectionPurpose>(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::<GameCultProviderHealthIdentity>(
&temp.path().join("provider-health.cc"),
)?;
let proof = signer.sign::<IdunnSignedDaemonHealthPurpose>(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::<GameCultProviderHealthIdentity>(&anchor.public_key)?,
derive_service_identity_id::<IdunnServiceIdentity>(&anchor.public_key)?
);
Ok(())
}
}