From 155fac6d2255ced8ccb51fb0d1c9597d6492aff1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 24 Aug 2026 21:04:54 +0200 Subject: [PATCH] Give Codex Connector its own Idunn health identity --- Cargo.lock | 322 +++++++++++++++++++- Cargo.toml | 17 +- README.md | 7 +- deployment/codex-linux-x64.manifest | 4 + src/daemon.rs | 54 ++++ src/idunn_health.rs | 449 ++++++++++++++++++++++++++++ src/lib.rs | 7 + src/main.rs | 25 +- 8 files changed, 872 insertions(+), 13 deletions(-) create mode 100644 deployment/codex-linux-x64.manifest create mode 100644 src/idunn_health.rs diff --git a/Cargo.lock b/Cargo.lock index ba66579..b872432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,12 +58,24 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "block-buffer" version = "0.10.4" @@ -130,18 +142,30 @@ name = "codex-connector" version = "0.1.0" dependencies = [ "aes-gcm", - "base64", - "cultcache-rs", - "rand_core", + "anyhow", + "base64 0.23.1", + "chrono", + "cultcache-rs 0.1.0 (git+https://github.com/GameCult/cultcache-rs.git?rev=fdbf3bf0a4b3f6b617c224d7439feb8e69d4474c)", + "cultnet-rs", + "ed25519-dalek", + "rand_core 0.6.4", "rmp-serde", "serde", + "serde_bytes", "serde_json", "sha2", "thiserror", "ureq", + "uuid", "zeroize", ] +[[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" @@ -164,7 +188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -177,6 +201,23 @@ dependencies = [ "cipher", ] +[[package]] +name = "cultcache-rs" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=75c180782aeba7cfd22d6412877397708a4ed28f#75c180782aeba7cfd22d6412877397708a4ed28f" +dependencies = [ + "anyhow", + "chrono", + "cultcache-rs-derive 0.1.0 (git+https://github.com/GameCult/CultLib.git?rev=75c180782aeba7cfd22d6412877397708a4ed28f)", + "fs2", + "rmp-serde", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "windows-sys 0.59.0", +] + [[package]] name = "cultcache-rs" version = "0.1.0" @@ -184,7 +225,7 @@ source = "git+https://github.com/GameCult/cultcache-rs.git?rev=fdbf3bf0a4b3f6b61 dependencies = [ "anyhow", "chrono", - "cultcache-rs-derive", + "cultcache-rs-derive 0.1.0 (git+https://github.com/GameCult/cultcache-rs.git?rev=fdbf3bf0a4b3f6b617c224d7439feb8e69d4474c)", "fs2", "redb", "rmp-serde", @@ -195,6 +236,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "cultcache-rs-derive" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=75c180782aeba7cfd22d6412877397708a4ed28f#75c180782aeba7cfd22d6412877397708a4ed28f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "cultcache-rs-derive" version = "0.1.0" @@ -205,6 +256,65 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "cultnet-rs" +version = "0.1.0" +source = "git+https://github.com/GameCult/CultLib.git?rev=75c180782aeba7cfd22d6412877397708a4ed28f#75c180782aeba7cfd22d6412877397708a4ed28f" +dependencies = [ + "aes-gcm", + "anyhow", + "base64 0.22.1", + "chrono", + "cultcache-rs 0.1.0 (git+https://github.com/GameCult/CultLib.git?rev=75c180782aeba7cfd22d6412877397708a4ed28f)", + "hmac", + "rand", + "rmp-serde", + "rmpv", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "socket2", + "uuid", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "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]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -213,8 +323,39 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] +[[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 = "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" @@ -276,6 +417,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -284,7 +437,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -297,6 +450,15 @@ dependencies = [ "polyval", ] +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.5.0" @@ -414,6 +576,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" @@ -426,6 +598,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -444,12 +625,38 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -459,6 +666,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redb" version = "4.2.0" @@ -501,6 +717,26 @@ dependencies = [ "serde", ] +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustls" version = "0.23.43" @@ -542,6 +778,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[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" @@ -612,12 +854,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[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 = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "subtle" version = "2.6.1" @@ -700,7 +971,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "log", "percent-encoding", "rustls", @@ -716,7 +987,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", @@ -751,6 +1022,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -968,6 +1248,32 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/Cargo.toml b/Cargo.toml index e99f3ab..ba6a00e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,17 +18,32 @@ required-features = ["daemon"] [features] default = [] -daemon = ["dep:cultcache-rs", "dep:ureq"] +daemon = [ + "dep:anyhow", + "dep:chrono", + "dep:cultcache-rs", + "dep:cultnet-rs", + "dep:ed25519-dalek", + "dep:serde_bytes", + "dep:ureq", + "dep:uuid", +] [dependencies] aes-gcm = "0.10" +anyhow = { version = "1", optional = true } base64 = "0.23" +chrono = { version = "0.4", features = ["serde"], optional = true } cultcache-rs = { git = "https://github.com/GameCult/cultcache-rs.git", rev = "fdbf3bf0a4b3f6b617c224d7439feb8e69d4474c", optional = true } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "75c180782aeba7cfd22d6412877397708a4ed28f", optional = true } +ed25519-dalek = { version = "2.2", optional = true } rand_core = { version = "0.6", features = ["getrandom"] } rmp-serde = "1" serde = { version = "1", features = ["derive"] } +serde_bytes = { version = "0.11", optional = true } serde_json = "1" sha2 = "0.10" thiserror = "2" ureq = { version = "3.4", default-features = false, features = ["rustls"], optional = true } +uuid = { version = "1", features = ["v4"], optional = true } zeroize = "1" diff --git a/README.md b/README.md index 425b54d..7410ed3 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,10 @@ cargo check --bin codex-connector --features daemon The same production binary can initialize a typed single-caller `.cc` configuration for first deployment, then serve only from that admitted state. -Redacted CultMesh/Odin publication and the independent Idunn target remain -open. The old daemon remains the sole live credential writer until that -deployment transaction lands. +The daemon publishes private-free signed health to Idunn from its own runtime +identity. Its source revision also owns the exact Codex Linux package and +binary hashes in `deployment/codex-linux-x64.manifest`, so changing the provider +toolchain necessarily changes the connector release selected by Idunn. ## Contract invariant diff --git a/deployment/codex-linux-x64.manifest b/deployment/codex-linux-x64.manifest new file mode 100644 index 0000000..53c3a0e --- /dev/null +++ b/deployment/codex-linux-x64.manifest @@ -0,0 +1,4 @@ +schema_version=gamecult.codex_connector.codex_toolchain.v1 +package_url=https://registry.npmjs.org/@openai/codex/-/codex-0.150.0-alpha.7-linux-x64.tgz +package_sha256=8aca3112aef60127d4ffaf4c6f08e524fa7315279a1d08e7aa9c5e7f1a6943e0 +binary_sha256=aca268dc02dcef8b1ea9f528a10173d5071ae76d659a03cce6458bbebe228bee diff --git a/src/daemon.rs b/src/daemon.rs index 8b402f6..e9dafef 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -190,6 +190,8 @@ pub fn serve(config_path: &Path) -> Result<(), CodexDaemonError> { config.callers.len(), readiness.auth_mode ); + #[cfg(target_os = "linux")] + start_idunn_health_publisher()?; for accepted in listener.incoming() { let stream = match accepted { @@ -224,6 +226,52 @@ pub fn serve(config_path: &Path) -> Result<(), CodexDaemonError> { Err(CodexDaemonError::ListenerClosed) } +#[cfg(target_os = "linux")] +fn start_idunn_health_publisher() -> Result<(), CodexDaemonError> { + let Some(endpoint) = std::env::var_os("CODEX_CONNECTOR_IDUNN_RUDP") else { + return Ok(()); + }; + let endpoint = endpoint + .to_string_lossy() + .parse::() + .map_err(|_| CodexDaemonError::InvalidConfig("Idunn health endpoint"))?; + let daemon_id = required_environment("CODEX_CONNECTOR_IDUNN_DAEMON")?; + let runtime_id = required_environment("CODEX_CONNECTOR_RUNTIME_ID")?; + let contract = std::env::var("CODEX_CONNECTOR_IDUNN_HEALTH_CONTRACT") + .unwrap_or_else(|_| crate::CODEX_CONNECTOR_IDUNN_HEALTH_CONTRACT.to_string()); + let identity = PathBuf::from(required_environment( + "CODEX_CONNECTOR_IDUNN_HEALTH_IDENTITY", + )?); + let mut publisher = + crate::ProviderHealthPublisher::open(endpoint, daemon_id, runtime_id, contract, &identity) + .map_err(CodexDaemonError::Health)?; + publisher + .publish("active", "credential-isolated-transport-ready") + .map_err(CodexDaemonError::Health)?; + thread::Builder::new() + .name("codex-connector-health".to_string()) + .spawn(move || { + loop { + thread::sleep(Duration::from_secs(10)); + if let Err(error) = + publisher.publish("active", "credential-isolated-transport-ready") + { + eprintln!("codex-connector health publication failed: {error}"); + } + } + }) + .map_err(CodexDaemonError::HealthThread)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn required_environment(name: &'static str) -> Result { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty() && value == value.trim()) + .ok_or(CodexDaemonError::InvalidConfig(name)) +} + fn load_admissions( config: &CodexDaemonConfig, ) -> Result, CodexDaemonError> { @@ -349,6 +397,12 @@ pub enum CodexDaemonError { Listen(#[source] std::io::Error), #[error("connector request thread failed")] Thread(#[source] std::io::Error), + #[cfg(target_os = "linux")] + #[error("connector health publication could not start")] + Health(#[source] anyhow::Error), + #[cfg(target_os = "linux")] + #[error("connector health thread could not start")] + HealthThread(#[source] std::io::Error), #[error("connector connection failed")] Connection(#[source] std::io::Error), #[error("connector frame exceeded its bound")] diff --git a/src/idunn_health.rs b/src/idunn_health.rs new file mode 100644 index 0000000..d03f8b5 --- /dev/null +++ b/src/idunn_health.rs @@ -0,0 +1,449 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::net::{SocketAddr, UdpSocket}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, SingleFileMessagePackBackingStore}; +use cultnet_rs::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, + encode_cultnet_message_to_vec, +}; +use ed25519_dalek::{Signer, SigningKey}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const CODEX_CONNECTOR_IDUNN_HEALTH_CONTRACT: &str = + "codex-connector.cultnet-rudp-service-health"; +const HEALTH_SCHEMA: &str = "idunn.signed_daemon_health.v1"; +const IDENTITY_SCHEMA: &str = "gamecult.provider_health_identity.private.v1"; +const IDENTITY_TYPE: &str = "gamecult.provider_health_identity.private.v1"; +const IDENTITY_KEY: &str = "gamecult-provider-health-identity"; +const ID_DOMAIN: &[u8] = b"gamecult.provider-health.identity.v1\0"; +const SIGNATURE_DOMAIN: &[u8] = b"gamecult.provider-health.signature.v1\0"; +const SIGNATURE_PURPOSE: &[u8] = b"idunn.signed_daemon_health.v1"; +const PROTECTOR_CONTEXT: &str = "gamecult-provider-health-identity-v1"; +const RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; +const RUDP_CONNECTION_ID: u32 = 0x1d0d_0004; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +struct ProviderHealthIdentity { + schema_version: String, + identity_id: String, + #[serde(with = "serde_bytes")] + public_key: Vec, + #[serde(with = "serde_bytes")] + protected_private_seed: Vec, + protector_kind: String, + protector_binding: String, + protector_version: String, + assurance: String, + created_at: String, + #[serde(with = "serde_bytes")] + enrollment_nonce: Vec, +} + +struct ProviderHealthSigner { + entry: ProviderHealthIdentity, + key: SigningKey, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct SignedDaemonHealth { + schema_version: String, + daemon_id: String, + health_contract: String, + source_runtime_id: String, + state: String, + detail: String, + signer_identity_id: String, + publisher_incarnation_id: String, + publisher_sequence: u64, + observed_at_unix_millis: u64, + release_id: Option, + release_witness_sha256: Option, + source_commit: Option, + deployment_id: Option, + signature_algorithm: String, + #[serde(with = "serde_bytes")] + signature: Vec, + private_state_exposed: bool, +} + +impl SignedDaemonHealth { + fn validate(&self) -> Result<()> { + for (value, label) in [ + (&self.daemon_id, "daemon id"), + (&self.health_contract, "health contract"), + (&self.source_runtime_id, "runtime id"), + (&self.signer_identity_id, "signer identity"), + (&self.publisher_incarnation_id, "publisher incarnation"), + ] { + require_id(value, label)?; + } + if self.schema_version != HEALTH_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 ProviderHealthPublisher { + endpoint: SocketAddr, + daemon_id: String, + runtime_id: String, + contract: String, + signer: ProviderHealthSigner, + incarnation: String, + sequence: u64, +} + +impl ProviderHealthPublisher { + pub fn open( + endpoint: SocketAddr, + daemon_id: impl Into, + runtime_id: impl Into, + contract: impl Into, + identity_store: &Path, + ) -> Result { + let daemon_id = daemon_id.into(); + let runtime_id = runtime_id.into(); + let contract = contract.into(); + require_id(&daemon_id, "daemon id")?; + require_id(&runtime_id, "runtime id")?; + require_id(&contract, "health contract")?; + Ok(Self { + endpoint, + daemon_id, + runtime_id, + contract, + signer: open_identity(identity_store)?, + incarnation: uuid::Uuid::new_v4().to_string(), + sequence: 0, + }) + } + + pub fn publish(&mut self, state: &str, detail: &str) -> Result<()> { + self.sequence = self + .sequence + .checked_add(1) + .ok_or_else(|| anyhow!("health publisher sequence overflow"))?; + let mut record = SignedDaemonHealth { + schema_version: HEALTH_SCHEMA.into(), + daemon_id: self.daemon_id.clone(), + health_contract: self.contract.clone(), + source_runtime_id: self.runtime_id.clone(), + state: state.into(), + detail: detail.into(), + signer_identity_id: self.signer.entry.identity_id.clone(), + publisher_incarnation_id: self.incarnation.clone(), + publisher_sequence: self.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 = unsigned_record(&record)?; + record.signature = self + .signer + .key + .sign(&signing_message(&unsigned)) + .to_bytes() + .to_vec(); + record.validate()?; + publish(self.endpoint, &self.runtime_id, &record) + } +} + +pub fn enroll_provider_health_identity(path: &Path) -> Result { + if path.exists() { + bail!("provider health identity already exists"); + } + let parent = path + .parent() + .ok_or_else(|| anyhow!("provider health identity path has no parent"))?; + std::fs::create_dir_all(parent)?; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + let mut seed = [0_u8; 32]; + OsRng.fill_bytes(&mut seed); + let key = SigningKey::from_bytes(&seed); + let public_key = key.verifying_key().to_bytes(); + let mut nonce = [0_u8; 32]; + OsRng.fill_bytes(&mut nonce); + let binding = machine_binding()?; + let entry = ProviderHealthIdentity { + schema_version: IDENTITY_SCHEMA.into(), + identity_id: identity_id(&public_key), + public_key: public_key.to_vec(), + protected_private_seed: mask_seed(&seed, &binding), + protector_kind: "linux_file_mode_machine_id_binding".into(), + protector_binding: binding, + protector_version: "v1".into(), + assurance: "os_installation_file_bound_cloneable_baseline".into(), + created_at: chrono::Utc::now().to_rfc3339(), + enrollment_nonce: nonce.to_vec(), + }; + validate_identity(&entry)?; + let envelope = CultCacheEnvelope { + key: IDENTITY_KEY.into(), + r#type: IDENTITY_TYPE.into(), + payload: rmp_serde::to_vec(&entry)?, + stored_at: entry.created_at.clone(), + schema_id: Some(IDENTITY_SCHEMA.into()), + }; + let bytes = rmp_serde::to_vec(&vec![envelope])?; + let mut file = OpenOptions::new().create_new(true).write(true).open(path)?; + if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) { + drop(file); + let _ = std::fs::remove_file(path); + return Err(error.into()); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + provider_health_public_key_hex(path) +} + +pub fn provider_health_public_key_hex(path: &Path) -> Result { + Ok(hex(&open_identity(path)?.entry.public_key)) +} + +fn open_identity(path: &Path) -> Result { + let entries = SingleFileMessagePackBackingStore::new(path).pull_all()?; + let [envelope] = entries.as_slice() else { + bail!("provider health identity store must contain exactly one record"); + }; + if envelope.key != IDENTITY_KEY + || envelope.r#type != IDENTITY_TYPE + || envelope.schema_id.as_deref() != Some(IDENTITY_SCHEMA) + { + bail!("provider health identity store has the wrong type"); + } + let entry: ProviderHealthIdentity = rmp_serde::from_slice(&envelope.payload)?; + validate_identity(&entry)?; + let binding = machine_binding()?; + if entry.protector_binding != binding { + bail!("provider health identity belongs to another machine"); + } + let seed: [u8; 32] = mask_seed(&entry.protected_private_seed, &binding) + .try_into() + .map_err(|_| anyhow!("provider health identity seed has invalid length"))?; + let key = SigningKey::from_bytes(&seed); + if key.verifying_key().to_bytes().as_slice() != entry.public_key.as_slice() { + bail!("provider health identity seed does not match its public key"); + } + Ok(ProviderHealthSigner { entry, key }) +} + +fn validate_identity(entry: &ProviderHealthIdentity) -> Result<()> { + if entry.schema_version != IDENTITY_SCHEMA + || entry.public_key.len() != 32 + || entry.protected_private_seed.len() != 32 + || entry.enrollment_nonce.len() != 32 + || entry.protector_kind != "linux_file_mode_machine_id_binding" + || entry.protector_version != "v1" + || entry.identity_id != identity_id(&entry.public_key) + { + bail!("provider health identity is invalid"); + } + chrono::DateTime::parse_from_rfc3339(&entry.created_at)?; + Ok(()) +} + +fn machine_binding() -> Result { + let raw = std::fs::read_to_string("/etc/machine-id") + .or_else(|_| std::fs::read_to_string("/var/lib/dbus/machine-id"))?; + let id = raw.trim(); + if id.is_empty() { + bail!("Linux machine-id is empty"); + } + Ok(format!( + "{PROTECTOR_CONTEXT}:machine-id-sha256:{}", + hex(&Sha256::digest(id.as_bytes())) + )) +} + +fn mask_seed(seed: &[u8], binding: &str) -> Vec { + let mask = Sha256::digest( + [ + b"gamecult-linux-service-seed-v1\0".as_slice(), + PROTECTOR_CONTEXT.as_bytes(), + binding.as_bytes(), + ] + .concat(), + ); + seed.iter() + .zip(mask) + .map(|(left, right)| left ^ right) + .collect() +} + +fn identity_id(public_key: &[u8]) -> String { + hex(&Sha256::digest([ID_DOMAIN, public_key].concat())) +} + +fn unsigned_record(record: &SignedDaemonHealth) -> Result> { + let mut unsigned = record.clone(); + unsigned.signature.clear(); + uuid::Uuid::parse_str(&unsigned.publisher_incarnation_id)?; + Ok(rmp_serde::to_vec(&unsigned)?) +} + +fn signing_message(payload: &[u8]) -> Vec { + let mut out = + Vec::with_capacity(SIGNATURE_DOMAIN.len() + SIGNATURE_PURPOSE.len() + payload.len() + 16); + out.extend_from_slice(SIGNATURE_DOMAIN); + out.extend_from_slice(&(SIGNATURE_PURPOSE.len() as u64).to_be_bytes()); + out.extend_from_slice(SIGNATURE_PURPOSE); + out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + out.extend_from_slice(payload); + out +} + +fn publish(endpoint: SocketAddr, runtime_id: &str, record: &SignedDaemonHealth) -> Result<()> { + let payload = rmp_serde::to_vec(record)?; + if rmp_serde::from_slice::(&payload)? != *record { + bail!("signed health MessagePack did not round trip"); + } + let message = CultNetMessage::DocumentPutRaw { + message_id: format!( + "codex-connector-health:{}:{}:{}", + record.daemon_id, record.publisher_incarnation_id, record.publisher_sequence + ), + document: CultNetRawDocumentRecord { + schema_id: HEALTH_SCHEMA.into(), + record_key: record.daemon_id.clone(), + stored_at: chrono::DateTime::from_timestamp_millis( + record.observed_at_unix_millis.try_into()?, + ) + .context("health observation time is invalid")? + .to_rfc3339(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload, + source_runtime_id: Some(runtime_id.into()), + source_agent_id: Some(record.signer_identity_id.clone()), + source_role: Some("daemon-health-publisher".into()), + tags: Some(vec![RUDP_PROTOCOL_ID.into()]), + }, + }; + let socket = UdpSocket::bind(if endpoint.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + })?; + socket.set_read_timeout(Some(Duration::from_millis(100)))?; + let mut transport = CultNetRudpSocketTransportConnection::new( + CultNetRudpSocketTransportOptions::client(runtime_id, socket, endpoint, 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 { + bail!("timed out connecting provider health to {endpoint}"); + } + } + transport.send( + "schema", + encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?, + )?; + Ok(()) +} + +fn require_id(value: &str, label: &str) -> Result<()> { + if value.is_empty() + || value != value.trim() + || value.len() > 256 + || value.chars().any(char::is_control) + { + bail!("{label} is invalid"); + } + 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 & 15) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + + #[test] + fn identity_round_trips_and_signed_health_is_private_free() { + let root = std::env::temp_dir().join(format!( + "codex-connector-health-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("identity.cc"); + let public_key = enroll_provider_health_identity(&path).unwrap(); + assert_eq!(provider_health_public_key_hex(&path).unwrap(), public_key); + let signer = open_identity(&path).unwrap(); + let mut record = SignedDaemonHealth { + schema_version: HEALTH_SCHEMA.into(), + daemon_id: "yggdrasil-codex-connector".into(), + health_contract: CODEX_CONNECTOR_IDUNN_HEALTH_CONTRACT.into(), + source_runtime_id: "codex-connector-yggdrasil".into(), + state: "active".into(), + detail: "credential-isolated-transport-ready".into(), + signer_identity_id: signer.entry.identity_id.clone(), + publisher_incarnation_id: uuid::Uuid::new_v4().to_string(), + publisher_sequence: 1, + 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 = unsigned_record(&record).unwrap(); + record.signature = signer + .key + .sign(&signing_message(&unsigned)) + .to_bytes() + .to_vec(); + record.validate().unwrap(); + let public: [u8; 32] = signer.entry.public_key.try_into().unwrap(); + let signature = Signature::from_slice(&record.signature).unwrap(); + VerifyingKey::from_bytes(&public) + .unwrap() + .verify(&signing_message(&unsigned), &signature) + .unwrap(); + assert!(!record.private_state_exposed); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 477f620..5cd7b68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ use zeroize::{Zeroize, Zeroizing}; #[cfg(feature = "daemon")] mod daemon; +#[cfg(all(feature = "daemon", target_os = "linux"))] +mod idunn_health; #[cfg(feature = "daemon")] mod provider_backend; @@ -35,6 +37,11 @@ pub use daemon::load_daemon_config; pub use daemon::serve; #[cfg(feature = "daemon")] pub use daemon::write_daemon_config; +#[cfg(all(feature = "daemon", target_os = "linux"))] +pub use idunn_health::{ + CODEX_CONNECTOR_IDUNN_HEALTH_CONTRACT, ProviderHealthPublisher, + enroll_provider_health_identity, provider_health_public_key_hex, +}; #[cfg(feature = "daemon")] pub use provider_backend::CodexAppServerConfig; #[cfg(feature = "daemon")] diff --git a/src/main.rs b/src/main.rs index e3a9a11..65ca37a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,10 +3,33 @@ use std::path::PathBuf; use codex_connector::{CodexCallerConfig, CodexDaemonConfig}; -const USAGE: &str = "usage:\n codex-connector --config PATH.cc\n codex-connector --initialize-single-caller-config PATH.cc BIND CODEX_EXECUTABLE CODEX_SHA256 CODEX_HOME REPLAY_STORE CALLER_RUNTIME_ID CONNECTION_KEY_FILE CONNECTION_KEY_EPOCH ALLOWED_MODEL MAX_CONCURRENT_REQUESTS MAX_PAYLOAD_BYTES MAX_OUTPUT_TOKENS"; +const USAGE: &str = "usage:\n codex-connector --config PATH.cc\n codex-connector --initialize-single-caller-config PATH.cc BIND CODEX_EXECUTABLE CODEX_SHA256 CODEX_HOME REPLAY_STORE CALLER_RUNTIME_ID CONNECTION_KEY_FILE CONNECTION_KEY_EPOCH ALLOWED_MODEL MAX_CONCURRENT_REQUESTS MAX_PAYLOAD_BYTES MAX_OUTPUT_TOKENS\n codex-connector --enroll-provider-health-identity PATH.cc\n codex-connector --provider-health-public-key PATH.cc"; fn main() { let args = std::env::args_os().skip(1).collect::>(); + #[cfg(target_os = "linux")] + if let [flag, path] = args.as_slice() { + if flag == "--enroll-provider-health-identity" { + match codex_connector::enroll_provider_health_identity(&PathBuf::from(path)) { + Ok(public_key) => println!("{public_key}"), + Err(error) => { + eprintln!("codex-connector health identity enrollment failed: {error}"); + std::process::exit(2); + } + } + return; + } + if flag == "--provider-health-public-key" { + match codex_connector::provider_health_public_key_hex(&PathBuf::from(path)) { + Ok(public_key) => println!("{public_key}"), + Err(error) => { + eprintln!("codex-connector health identity read failed: {error}"); + std::process::exit(2); + } + } + return; + } + } if args .first() .is_some_and(|value| value == "--initialize-single-caller-config")