Add Yggdrasil runtime and signed health support
This commit is contained in:
parent
88d81a0ec8
commit
f12de09d00
19 changed files with 1322 additions and 28 deletions
126
Cargo.lock
generated
126
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
300
crates/ghostlight-dungeon/src/idunn_health.rs
Normal file
300
crates/ghostlight-dungeon/src/idunn_health.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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))
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
4
vendor/cultnet-rs/Cargo.toml
vendored
4
vendor/cultnet-rs/Cargo.toml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
vendor/cultnet-rs/src/lib.rs
vendored
2
vendor/cultnet-rs/src/lib.rs
vendored
|
|
@ -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::*;
|
||||
|
|
|
|||
706
vendor/cultnet-rs/src/service_identity.rs
vendored
Normal file
706
vendor/cultnet-rs/src/service_identity.rs
vendored
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue