From 0e7d2204cd105ecc8dcde01cfe16d1394d49aff1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 01:32:57 +0200 Subject: [PATCH 01/12] cultnet-rs: example generating Rust-owned Idunn runtime fixtures Real enrolled identities, a validated Expected, an Activation issued by IdunnRuntimeActivationLaunch, bundle files through the CultCache store, and runtime-presence vectors signed by the Rust signers and run through authenticate/correlate before they are written. cultnet-ts tests will commit the output. Co-Authored-By: Claude Sonnet 5 --- .../examples/idunn_runtime_fixture.rs | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 packages/cultnet-rs/examples/idunn_runtime_fixture.rs diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs new file mode 100644 index 00000000..e97eb47b --- /dev/null +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -0,0 +1,276 @@ +//! Generate the Idunn runtime fixtures that cultnet-ts is tested against. +//! +//! The Rust owner builds every input: real enrolled identities, an Expected that +//! passes `validate`, an Activation issued by `IdunnRuntimeActivationLaunch`, the +//! bundle files written through `SingleFileMessagePackBackingStore`, and +//! runtime-presence vectors signed by the Rust signers. Each vector is also run +//! through `authenticate_runtime_presence_claim` and +//! `correlate_runtime_presence_claim` before it is written, so the bytes +//! committed to the TypeScript tests are ones this verifier accepted (or, for +//! the low-capacity vector, ones it correlates to the typed capacity +//! disagreement). Ed25519 is deterministic, so the TypeScript signer must +//! reproduce each vector byte for byte from the same fields. +//! +//! Linux only: the provider identity is bound to `/etc/machine-id`, and the +//! generated `machine-id` file records the value the identities were bound to. +//! +//! Usage: idunn_runtime_fixture + +use std::fs; +use std::path::Path; + +use anyhow::{Result, bail, ensure}; +use cultcache_rs::{ + CacheBackingStore, CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore, +}; +use cultnet_rs::{ + GameCultProviderHealthIdentity, GameCultRuntimeCapability, + GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_RUNTIME_ACTIVATION_SCHEMA, + IdunnExpectedCapability, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, + IdunnExpectedRoute, IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, + IdunnRuntimeActivationSigner, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, + authenticate_runtime_presence_claim, correlate_runtime_presence_claim, + enroll_service_identity_at, verify_runtime_authority, +}; + +/// The TypeScript tests pin the same values; drift shows up as a byte mismatch. +const OBSERVED_AT: u64 = 1_790_000_000_000; +const ISSUED_AT: u64 = OBSERVED_AT - 5_000; +const SEQUENCE: u64 = 1; +const DETAIL: &str = "route-observation:route-challenge-1"; +const STORED_AT: &str = "2026-01-01T00:00:00.000Z"; + +struct Set { + dir: &'static str, + target: &'static str, + transport: &'static str, + stable: &'static str, + candidate: &'static str, + minimum_capacity: u32, + odin_dependency: bool, +} + +const SETS: [Set; 3] = [ + Set { + dir: "web", + target: "streampixels-web", + transport: "http", + stable: "http://127.0.0.1:8830", + candidate: "http://127.0.0.1:18830", + minimum_capacity: 1, + odin_dependency: false, + }, + Set { + dir: "service", + target: "streampixels-service", + transport: "tcp", + stable: "tcp://127.0.0.1:8831", + candidate: "tcp://127.0.0.1:18831", + minimum_capacity: 2, + odin_dependency: true, + }, + Set { + dir: "rudp-route", + target: "streampixels-rudp", + transport: "rudp", + stable: "rudp://127.0.0.1:8832", + candidate: "rudp://127.0.0.1:18832", + minimum_capacity: 1, + odin_dependency: false, + }, +]; + +fn main() -> Result<()> { + let Some(out) = std::env::args().nth(1) else { + bail!("usage: idunn_runtime_fixture "); + }; + let machine_id = fs::read_to_string("/etc/machine-id")?; + fs::create_dir_all(&out)?; + fs::write(Path::new(&out).join("machine-id"), machine_id.trim())?; + let idunn = enroll_service_identity_at::( + &Path::new(&out).join("idunn-identity.private"), + )?; + let idunn_anchor = idunn.trust_anchor()?; + fs::remove_file(Path::new(&out).join("idunn-identity.private"))?; + let _ = fs::remove_file(Path::new(&out).join("idunn-identity.private.lock")); + for set in &SETS { + build(Path::new(&out).join(set.dir).as_path(), set, &idunn, &idunn_anchor)?; + } + Ok(()) +} + +fn digest(byte: char) -> String { + format!("sha256-{}", byte.to_string().repeat(64)) +} + +fn build( + dir: &Path, + set: &Set, + idunn: &cultnet_rs::ServiceIdentitySigner, + idunn_anchor: &cultnet_rs::ServiceIdentityTrustAnchor, +) -> Result<()> { + fs::create_dir_all(dir)?; + let provider_path = dir.join("provider-identity.credential"); + let provider = enroll_service_identity_at::(&provider_path)?; + let provider_anchor = provider.trust_anchor()?; + let _ = fs::remove_file(dir.join("provider-identity.credential.lock")); + + let capability = format!("{}.api", set.target); + let capability_schema = format!("{}.api.v1", set.target); + let expected = IdunnExpectedIncarnationRecord { + schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), + target: set.target.into(), + plan_id: digest('1'), + incarnation_id: format!("{}/incarnation-1", set.target), + sealed_release_id: digest('2'), + source_repository: "github.com/GameCult/StreamPixels".into(), + source_revision: "a".repeat(40), + recipe_sha256: digest('3'), + runtime_id: format!("{}-yggdrasil", set.target), + expected_signer_identity_id: provider_anchor.identity_id.clone(), + health_contract: format!("{}.runtime-health", set.target), + artifact_sha256: digest('4'), + state_schema_generation: None, + state_contract_sha256: None, + write_lease_required: false, + route: Some(IdunnExpectedRoute { + route_id: format!("{}-route", set.target), + transport: set.transport.into(), + stable_endpoint: set.stable.into(), + candidate_endpoint: set.candidate.into(), + }), + capabilities: vec![IdunnExpectedCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + minimum_capacity: set.minimum_capacity, + }], + dependencies: if set.odin_dependency { + vec![IdunnExpectedDependency { + kind: "shared-infrastructure".into(), + capability: "odin.verse-rendezvous".into(), + schema: "odin.verse-topology.v1".into(), + compatibility: "v1".into(), + minimum_capacity: 1, + startup: "before-promotion".into(), + provider_id: Some("odin".into()), + provider_authority: Some("managed-incarnation".into()), + provider_expected_projection_sha256: Some(digest('5')), + provider_endpoint: Some("rudp://127.0.0.1:17871".into()), + }] + } else { + Vec::new() + }, + }; + expected.validate()?; + + let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('6'), ISSUED_AT, idunn)?; + let mut credential = Vec::new(); + let activation = launch.write_credential(&mut credential)?; + ensure!(credential.len() == 32, "activation credential is a 32-byte seed"); + fs::write(dir.join("activation-key.seed"), &credential)?; + let activation_signer = IdunnRuntimeActivationSigner::from_credential_reader(&credential[..])?; + + write_record( + &dir.join("expected.cc"), + &expected.target, + IdunnExpectedIncarnationRecord::TYPE, + IDUNN_EXPECTED_INCARNATION_SCHEMA, + expected.canonical_bytes()?, + )?; + write_record( + &dir.join("activation.cc"), + &expected.target, + IdunnRuntimeActivationRecord::TYPE, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, + activation.canonical_bytes()?, + )?; + + let authority = + verify_runtime_authority(&expected, &activation, idunn_anchor, &provider_anchor.public_key)?; + let vectors = [ + ("presence-active.bin", "active", set.minimum_capacity, None), + ("presence-warming.bin", "warming", set.minimum_capacity, None), + ( + "presence-active-below-minimum.bin", + "active", + set.minimum_capacity.saturating_sub(1), + Some("expected-capability-000-capacity"), + ), + ]; + for (name, state, capacity, disagreement) in vectors { + if capacity == 0 { + continue; + } + let mut presence = GameCultRuntimePresenceHealthRecord { + schema_version: cultnet_rs::GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), + target: expected.target.clone(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation.canonical_sha256()?, + state_schema_generation: None, + state_contract_sha256: None, + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + bound_endpoint: Some(set.candidate.into()), + capabilities: vec![GameCultRuntimeCapability { + capability: capability.clone(), + schema: capability_schema.clone(), + compatibility: "v1".into(), + capacity, + }], + health_contract: expected.health_contract.clone(), + state: state.into(), + detail: DETAIL.into(), + write_lease_sha256: None, + signer_identity_id: provider_anchor.identity_id.clone(), + publisher_sequence: SEQUENCE, + observed_at_unix_millis: OBSERVED_AT, + signature_algorithm: "ed25519".into(), + signature: Vec::new(), + activation_signer_identity_id: activation_signer.identity_id(), + activation_signature: Vec::new(), + }; + let proof = presence.canonical_proof_payload()?; + presence.signature = provider + .sign::(&proof) + .signature; + presence.activation_signature = activation_signer.sign_presence_proof(&presence)?; + let bytes = rmp_serde::to_vec(&presence)?; + + let claim = authenticate_runtime_presence_claim( + &bytes, + &authority, + RuntimePresenceAuthenticationContext { + trusted_received_at_unix_millis: OBSERVED_AT + 1_000, + maximum_age_millis: 30_000, + maximum_future_skew_millis: 0, + }, + )?; + let correlation = correlate_runtime_presence_claim(claim, &authority)?; + let codes: Vec<&str> = correlation + .disagreements() + .iter() + .map(|entry| entry.code.as_str()) + .collect(); + match disagreement { + None => ensure!(codes.is_empty(), "{name}: unexpected disagreements {codes:?}"), + Some(code) => ensure!(codes == [code], "{name}: expected only {code}, got {codes:?}"), + } + fs::write(dir.join(name), bytes)?; + } + Ok(()) +} + +fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec) -> Result<()> { + SingleFileMessagePackBackingStore::new(path).push(&CultCacheEnvelope { + key: key.into(), + r#type: kind.into(), + payload, + stored_at: STORED_AT.into(), + schema_id: Some(schema.into()), + }) +} From f5c597a81dd23a313c3d31374603dead7f6be500 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 01:40:30 +0200 Subject: [PATCH 02/12] cultnet-rs fixture example: remove the store lock files it leaves Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-rs/examples/idunn_runtime_fixture.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index e97eb47b..185f9a4e 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -272,5 +272,9 @@ fn write_record(path: &Path, key: &str, kind: &str, schema: &str, payload: Vec Date: Tue, 29 Sep 2026 01:44:04 +0200 Subject: [PATCH 03/12] cultnet-rs fixture example: name an Odin port a dev machine does not run Co-Authored-By: Claude Sonnet 5 --- .../cultnet-rs/examples/idunn_runtime_fixture.rs | 2 +- .../test/fixtures/idunn-runtime/.gitattributes | 1 + .../test/fixtures/idunn-runtime/README.md | 15 +++++++++++++++ .../test/fixtures/idunn-runtime/machine-id | 1 + .../idunn-runtime/rudp-route/activation-key.seed | 2 ++ .../idunn-runtime/rudp-route/activation.cc | Bin 0 -> 800 bytes .../idunn-runtime/rudp-route/expected.cc | Bin 0 -> 1076 bytes .../idunn-runtime/rudp-route/presence-active.bin | Bin 0 -> 922 bytes .../rudp-route/presence-warming.bin | Bin 0 -> 923 bytes .../rudp-route/provider-identity.credential | Bin 0 -> 680 bytes .../idunn-runtime/service/activation-key.seed | 1 + .../fixtures/idunn-runtime/service/activation.cc | Bin 0 -> 806 bytes .../fixtures/idunn-runtime/service/expected.cc | Bin 0 -> 1308 bytes .../service/presence-active-below-minimum.bin | Bin 0 -> 940 bytes .../idunn-runtime/service/presence-active.bin | Bin 0 -> 940 bytes .../idunn-runtime/service/presence-warming.bin | Bin 0 -> 941 bytes .../service/provider-identity.credential | Bin 0 -> 685 bytes .../idunn-runtime/web/activation-key.seed | 1 + .../fixtures/idunn-runtime/web/activation.cc | Bin 0 -> 798 bytes .../test/fixtures/idunn-runtime/web/expected.cc | Bin 0 -> 1067 bytes .../idunn-runtime/web/presence-active.bin | Bin 0 -> 915 bytes .../idunn-runtime/web/presence-warming.bin | Bin 0 -> 916 bytes .../web/provider-identity.credential | Bin 0 -> 683 bytes 23 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/README.md create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index 185f9a4e..d6b432a6 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -157,7 +157,7 @@ fn build( provider_id: Some("odin".into()), provider_authority: Some("managed-incarnation".into()), provider_expected_projection_sha256: Some(digest('5')), - provider_endpoint: Some("rudp://127.0.0.1:17871".into()), + provider_endpoint: Some("rudp://127.0.0.1:47871".into()), }] } else { Vec::new() diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes new file mode 100644 index 00000000..fa1385d9 --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md new file mode 100644 index 00000000..99e7b07f --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -0,0 +1,15 @@ +# Idunn runtime fixtures + +Written by the Rust owner, not by the TypeScript tests. Regenerate with +`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `f5c597a`; it uses +cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's). +It needs Linux and a machine-id equal to the `machine-id` file here, because the +provider identity is bound to it. On Yggdrasil: + + DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' + +then copy `/tmp/out` over this directory. Every `presence-*.bin` was accepted by +`authenticate_runtime_presence_claim` and correlated by +`correlate_runtime_presence_claim` before being written; the +`below-minimum` vector correlates to exactly `expected-capability-000-capacity`. +Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for byte. diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id new file mode 100644 index 00000000..a71716fc --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/machine-id @@ -0,0 +1 @@ +c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1 \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed new file mode 100644 index 00000000..ddaffdda --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -0,0 +1,2 @@ + +²ÊVÖH¾\IJ½ôðŸíȈ{’^ª+l£Z¼}k† \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc new file mode 100644 index 0000000000000000000000000000000000000000..556e8a173ce1d8648b70b1435402d0bdd183fff8 GIT binary patch literal 800 zcmbu5J!lj`6vvZB5J9v^P_WP|#3FaTnVpZF1Iej{R+4B4Q8978_Ofz&7xv>wOtgw3 zRxSn<5wtRXfT&d<5EM>GBZ4Bhm4&D&1$%K2v5{=!@E8W(|Nno#c{g9^td(l1r)@1s zJF%^GQGRPRG+Db{i?enbMs`Z-H0;VW?6hlLys}wuZL@|mxzw`E;=+6-aR`w-AtPI< zS4yHPLw}^>HclK;x!MhOhR8P-Rxn^z z1>|ZBp#HmR0C47YfA3D2u{f4YOH5hS5QP-O0Rdz&mPAr!jj+t)RQr?*g(b&9Krt6U zvD6l*;6iJ`DPdOf)qi-abIqoSWfHdj4q^X!iU!6^3ZzZoBLon*r=mm=3D#&26VTiv z)=2HUUl0hCFAWAK=gb&i5wExR%f98OS9+aqPX@Q$-v5KP*I&Iqv=N^!&KC#nKG@p4 z__nXF|9)>|Vzy`y7AOQp3kn7?!a;fju)C?}R)CD}f$uh=J%Krs2CNbSdloqaMKGXc zYcQoq^cu$(M}|H>-h;*_4j#U@dw5`6Y+w2?`tj()s|%m5y}7C99JK~Cm20>uxXN}UE7o3VK)&J4~j^dW^18ewCR|L@37*)yZ8bM)1in% z5TBvo!jLR}U1OUU8>@%w+~>Yf#$V3heBQ*Q&;hYlFM8ox<@0fI7%a(vi)s*#Is!H_Lsd3FI^%Yxzb*7 zPEIe&=5&VT=q?u-r%@UVc$lkM;ib(k6AZDbLL~1YgwFf|2;uSj#>eN{X0vlWSk=jW ziVagmE8?|QhxTKtDp~}>E23)k0^t`fU`eEP

-1f@ufyF7D5}SZ&dNVv&@-BA#p2 ztLMF*m(e^3s~yzVyB@X>GMHsyn^>-H6NYTWbnfYd7+q*$k8u|x(=;p>Q{rLba^0pj zBal?B8a>F0SOgJQeNIE+FA5~S;=;WmKsAjh(F{7{pk XnYbK{Yg~Y)!Q@w|{605*v_AX-A<<{3 literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-active.bin new file mode 100644 index 0000000000000000000000000000000000000000..13a93f5562abac952f33314dc17202825e216e8d GIT binary patch literal 922 zcmbu7O=uHA7=}~4iO_?HUaUXGqLfW%zMYxf2?{w>L{Fg?L9AtV=G$atn}pp>G#AB_ zh#-hPi?+~$AYwuB4~!TOidY1#gjR}L{5c8=J&3wm$tB+U&Sm&!_}&-ZXEC#--CtDW zsB2-8jcVm27{{?{gfW&ij+Ib_Nhw>0n{g7M8m|T?F^sLKW~%K2aY+FemSG?aypCX8uhR%<4{o6Dk`fasFW?* zF@iO!vxuQW1=7{7Ef}~Cm0TDgL{g$C8fuK^D&F z6pJQOaS(QF#2R(`PhYvAAqq~Gq=)9H>)LQ};#QY?R#k&L|D9&*bh1u++S|Kd|6yaT z(lV`d64bHXA604zS`{zGsQ$Nt-0DP^R2X8ph!$gGoL;_SeW*B>j1JQ%;-@n~R>u54TQQY*a4k9_kk?OoT- z0|=EyFn$`05I?j}!+RH=92tJ_ ztg%VIIyU<2ZqT>0_5AXU!snl}@0Uh)&n-@BJe29(xb^As#LIW^;f$D2#D*AK63 J=>OE$`y2izGi?9> literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..1211a6059e5c63d55e7ba915d9f89e5dd5ca2b66 GIT binary patch literal 923 zcmbu7KWG#|6vpq+fTjNm5hTHAxtE!@GqXE~;)w*Chzd%y3bQkB?-uszZo{Jk%Jt*T)HO3l78iz`#!g?v!ga@^HjH+A>&SF@z;)bavhifGTTv&#A)Dz${ zLJ>eQ#RVFP!gjcJxg$ML4){I?M-mQ7BjM7KT1$sBfy!wwqU+R5N5;M&(khNrJ*Y$$ zZ5zQl)n3F<+m$mnE;drnn@C;y%1nzYrV*`%lQ^&d9Y z8ZF~zR9p_CV$vH|8g;ZPUJc{s-wyI?6J1hah*1$OI-3-zWBUeNN>f~1kj(XL$uyNz z+h}aYxK9KajUG@TxSQg{3@{IEsibCvWnW}6*|GU!VP+f+a8BiX;cMyH_islFLG+Z| zu8$5x*MGd;vwdao*T>V-b8kMMKe@PT=Z(tM{nwVa+#0?xvNCbx=Dq%hn=-8{hc1V`M$&Xew_OY?Y87@u(qZ*Su6 zG*)))!FT+^*PU4Rrq5r)8$3VKc@B=KjiU&Q+AHQCmaHp|=cW3cwqUoBo-hl=$ zV!XsU*3PvPxhqCj;>_hXo-Na^Xftqy?I^^FD^gd?w3o-I{z=noNc7j(5%#KqrXi1r z4h`pmTdt)RwhNgWooFf~QIxPGlc@?ss4`(g!81-QQ!FDHH7X+_lusJcdSw;6qN;VC z>)MsY=A}tFD$>~GWnp#FU7qO5c0p{rpZb)0zVfI%LxU)gQ7HOCh>%i0AcuTE^8G(x xU-j@Fw@*#s8@}Kneq7A3iY1AASjHXvCC=}ZL8TVr>$llCwNwwJZ`Hz9&eW0baA8G(nbeoa;Zg`y>pkm%poM|m`c#A zdIeG5{y)-_m}U;CSnVD=LxPERYOi|w%;ioI$V}(vvn)?hCFkP_Y-FVj({^mI$g@a+ ziV_glCjhFyy9@v$uliT!ij2KO*_fifC`BkZpjCk)#wsE?53IH*t!6e1Ad(P*LZp_9 zkZB19j8R$&Akyb5vP=I$E=|_!HdR^N`1?ovXZ30ZsAY+^=*3a?ZEfS z@bJ67zW({{@lzAM8W)i!U`^&(S&fG{!ZA;$4$*%;%;-><*_ zaplHz@@Tz&zs{)Z2Z+o>Z;froxpcmW48`1;=yy_~vnH{bES!CFo_Ebva~^E;yuZ=2 zzeWEOLsQ;Nd>s=29i=H(v=(`P2PoMkkr!~ldo1+4#P>PS#7!dC3!=nNJU4+HsYL-O1Dfk>*v{@uTwHuxo{tqoN;VqaX;z(>KXx4A zhBh`IiKECPtG|t8ZS9t4V_Y_^#OG}e^QhjoXdUhsu-12R2ofJ zTxgroZK{1p7_^!j2+;aS2(yGS3j}pQg|gacWt21)w3OBdt|fBHP;uZMrJAwj<)u!s z-SfqwO;jz4+fM#A^&~abk>O#WB2QWcBOoP%EAvk~`R7$NdiK9Tz7BWm(3RynF8l+@ z-QwrIGHF!DESF5rr07oTKm7dt#>iI3lvEsJrHC#pX9ZN?fn>f#5*Wdl=G>yvR7=Wy zskvrc`ymM>q7a1G7FX5+=Mt0*EG1p5j?;CgF&;Y)&{EDJXcUf4eDMzs4UFIDUHta; zd1Ldy&XtKv&f(eF@8ZMy^`CREUp#!gcUAQ?XK!~$Hy^F{Z+@Mhe6w+OY>R?{qW6e7`WC)y)1hTT~-x z#>z>qIu<422o6@N7-M8`u!O3dlyWutI8LglMk?V1mSZQzYArOF_QWMcx#tAvxdg3} z3XD}CTvu9cQCQ8aFG(PXPZT1xuIDo?h#|%(Ej$8(yDG5FL$F1q9j$Wa!^o&=q>^wn za-eBOx2WbJVbE%Bz(?yLAJ*nM)k^5sD$ zEf_*AQ-FfH9t&Ja17sdB20mpJ1D}Iwv`9dyFQjV~bp_GNg3u_i0mpLT4quqN_^HgtSrDc;+8qW9QZ-_hdRJ=bRYFTYzo-+A-w LjrE^>r?&qF!IL?E literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..62b7ff86cceb9219a5d8c0e059e2473baff42e0a GIT binary patch literal 941 zcmbu7J!lkR5QZf<-_L%3sm{uXjYSH@B78rycME%a=kDIc6e1#u zpiP2Zf=CDk#L}P;Fb4@~)l@DwdAlscO|F zIt`&^ Cr8(08 literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential new file mode 100644 index 0000000000000000000000000000000000000000..45db9617f2fcfb960f3afda9b8ec42fdc1a6b2d1 GIT binary patch literal 685 zcmb7?KWG#|6vjm@v=?kt5(Nv*9y7Bye|9S^1gjuoBM8gP&TMvNZ#UlUT@nHT8(TFZ zij5!y4WgaI&LBuczruD^)FNn;#$M5pc%@)?FudV?-{YGfbMtmPIiq_n9hOZn+pkB3 zb^RcBx@_{m_f=Cg3+>cc(iuyVZAmhg_(3t!jq8l)tKEC=?aa}ha+-Fj3WaqfN@W-t z&aKmi8O1`O3^yS+A|sh{PD(_dx^>cWKr}%jQ8~gU)(Gs>M z4)(Ep7GLnKJz5ska6ZY3(j~px`a`FAQ8~{cF-2vIYNnkV!}=%BU_|4;M$b4HI?zo@c@YSB^P2/W•ËW;ÆsÖSÅ2�µ4 \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc new file mode 100644 index 0000000000000000000000000000000000000000..6127df727f18aed025681ae2377af563f9d11ed6 GIT binary patch literal 798 zcmbtPKWGzS6mK^R;-cu{Acs0=65sd#zJMBVa4Qz1h*ISHckPv2TJA0}w&i)csD$?7E54-p5UxA=Ix<@fi^bxpIL8)YV3CCjH$S81Wn zt)Y1?%ouVSwJ2xI>dFoU$9k*RQE1z?^h`)#d^D-d6CjqLz zw*mmCUkt9^E)w>SXA=rDUS?7OMFJ@+z(ioe2rH^}6k5&&5rD9V85W_jm|8_b%)HDZ z8-f%X^wWRqmd}rm+f-$7{ci^U&*|4dA*2OoLQ1ND8xV%0P-$zJW>RY~Is&9MRfdGr z84OkfR}nh{S>`3r`sO);SR&SJ)p?A2z} zrENPFmb+IDBpZ8o?p~MsHm)66LETC5@XpAOk>5E8WdT3+}}Oh-j69 zwg?^~;9;B=DU#YK^qa>l-p}{`X09K2+LWAeA5bD&F`y3V-@FDQjUoiyHgE~zEQmOZ zBPN4ZggWMExiu>-x2Z@)UQC@ums+A~3E3VCL36c2I&J&g`3T}f)%KUaO0Pmf2E;fMaM+RA;#`Z5XSiUV`Kcewpkp!kSnVt zJ~PV|ovetrd>6)#mFi@k$6OIvsS!v&ts~wFjhb5Fu6kX@!EA*4vk_)n?4OtyMOVZq z-@Ja&Xo#34K{MOon2S2G9M7?;94KJR0{{Vz&8h8Km{FjZS8%l~EMeNB>X;s7)ONt~ zge~YePmba=l0gW%53DKuc^B4-m`vUW!+bJ5Zlx0Ld@0*c)5|psV%i9+LtMw9c(|Y? XQ+0pvn5ROl9X$W0RKMo7L)Lp=Z0uzi literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin new file mode 100644 index 0000000000000000000000000000000000000000..15ae2e8c9912a0fa89482131e573b2a8f3a72105 GIT binary patch literal 915 zcmbtSO-NKx7##^(7((qTf-INuX72yJZx%7TA_&^F47oqwo%ixNGrf5;Wr0xyfe<2P zEsdzGorx6`q@`esuvOGh&?a}e(M2t^8Zr#5o#)$qhwprc!}*lk-Rddpez1)s%cqS> z7WYGMErmK%Ec8a8lPtX9Z~1U%G;VTrYq$a-XzCz=`K zl@mfzi7;u?oJeIIVNO#;c>By@r%)&j`YVLDh!>kJ7UGK4X+>vowG!YB!(@xt5Mma9 z35GlakF5vJ5z^Xm&O8^yS}M5LL~zRpSIR0WfPz=XU{9H+;mpjl&6b0Ka@nQ29w!@L z#1`=+%CcIqP{2gyQG0L^D@D=Bjj7Gg^STy4{ogX*#CMvwWBMQNPhW!dn%vhmi<{8u zNvn+vf~u)Q+Wehj;&3fTHV(*G|}}7TJtl| literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..1d106eb5ff53a16de8ad39b93facaf8125c67181 GIT binary patch literal 916 zcmbtSO-NKx7@fhS76r8kY?G1LqIl!|zxT}|XHi%SNz$ZNa(})%^J4yb^Bl`f3$xaO z1wnxn-Dd0hfKTud_Y za5;%eFjS4924xFF1<=K$kg1cIS`vdURin#LtOX-r8a=gwCR_wO)QDk;wUfwtWR)kH z8RL}`LQ;t^Y0{iXWgTHoQ$=|5%weZcC=L26gtv+3TP=1)Wvk<|PNGUVz?+82HnAzh zEC3S>c?KR^51b>Uwd0(5E{L^MaIcBrmJzO$RZ;*2uZ+Q-GEc*~x!J9jgDd&Gi*+q3 zZhjHl#N$GeRI^=On8*xj4$fkwD4LjjwDoyLSEDEYTW0F`ULB`j{KNg}OR!Os=@A{5 zqH?~`5m$y22r8xqas7Av*^P}Y=wcDdc?j@aBWICy1~cUq3vDwR#5@2vP|{NA8PXIe zvI2$NF-N2e74cM4sepMW9ZkPYrP5RLeZ|x?IKnv&=W`#D9p77L7vB%|Ex+mi{5pOA z!TvpqSKl36-uv*_)WV7Gb^rDBC6ZsA{B&)1|6q6T#i!@TU-qt~1{%-cK&HC0kwY3| zBL$aX$TSibNh`3@##wH7XbiH(BWw)QmMR98AZL)!9(fL?S%~?ZUmIvF)Y`_+9X>fa zu)fx^{-yoZk>fu*?~X04T)%OvW&HNO(XsYUTDj2kBYX2sX4iyGeLPFPojEjfs#;3CV5$`#dIw=@*?Pp45!)4##s zIqLTeOGHXdVug?bgi%II1%HjfSkV0`SbmHTaQ(m%zL*`yFNdkdA5-ant=-)(`3)Eu B6f*z- literal 0 HcmV?d00001 From 6b107c1adc2c083b922caf4139859ca4f5cacac8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:05:14 +0200 Subject: [PATCH 04/12] cultnet-ts: Rust-generated Idunn runtime fixtures Expected, Activation, identities and signed presence vectors from the cultnet-rs example; the README says how to regenerate them. Co-Authored-By: Claude Sonnet 5 --- .../test/fixtures/idunn-runtime/README.md | 2 +- .../rudp-route/activation-key.seed | 3 +-- .../idunn-runtime/rudp-route/activation.cc | Bin 800 -> 800 bytes .../idunn-runtime/rudp-route/expected.cc | Bin 1076 -> 1076 bytes .../rudp-route/presence-active.bin | Bin 922 -> 922 bytes .../rudp-route/presence-warming.bin | Bin 923 -> 923 bytes .../rudp-route/provider-identity.credential | Bin 680 -> 688 bytes .../idunn-runtime/service/activation-key.seed | 2 +- .../idunn-runtime/service/activation.cc | Bin 806 -> 806 bytes .../idunn-runtime/service/expected.cc | Bin 1308 -> 1308 bytes .../service/presence-active-below-minimum.bin | Bin 940 -> 940 bytes .../idunn-runtime/service/presence-active.bin | Bin 940 -> 940 bytes .../service/presence-warming.bin | Bin 941 -> 941 bytes .../service/provider-identity.credential | Bin 685 -> 673 bytes .../idunn-runtime/web/activation-key.seed | 3 ++- .../fixtures/idunn-runtime/web/activation.cc | Bin 798 -> 798 bytes .../fixtures/idunn-runtime/web/expected.cc | Bin 1067 -> 1067 bytes .../idunn-runtime/web/presence-active.bin | Bin 915 -> 915 bytes .../idunn-runtime/web/presence-warming.bin | Bin 916 -> 916 bytes .../web/provider-identity.credential | Bin 683 -> 676 bytes 20 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index 99e7b07f..de1ccea4 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -1,7 +1,7 @@ # Idunn runtime fixtures Written by the Rust owner, not by the TypeScript tests. Regenerate with -`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `f5c597a`; it uses +`packages/cultnet-rs/examples/idunn_runtime_fixture.rs` (CultLib `9ba6ac4`; it uses cultnet-rs only, and Idunn's `IdunnRuntimeActivationLaunch` is that same crate's). It needs Linux and a machine-id equal to the `machine-id` file here, because the provider identity is bound to it. On Yggdrasil: diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed index ddaffdda..1ae250bc 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -1,2 +1 @@ - -²ÊVÖH¾\IJ½ôðŸíȈ{’^ª+l£Z¼}k† \ No newline at end of file +v$ñ´ØÃ/Š ¼Èá|Æuö«g4FÜK%k±€~qû \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc index 556e8a173ce1d8648b70b1435402d0bdd183fff8..3676c985fb95c7105989d04f20473d4644699db5 100644 GIT binary patch delta 325 zcmWN}JuE{}7=Yn+(8U#t5GE6Dkeu(_^Xo4iCDMkaVQHN2+;a>>V(TU;Q$s{71W7kG zO*&AEkTwm5&I|^@Y>`+b?)DB(-fE_rsg=`N4gi)2D=9O?06K?}5+=nMjluh)jB;gi3-tsW6hsg=uX?0u&G~EdvuL zZ;P75zBzm_TIp^L%??}#h25}z`02lM`HABGb9v={YAfvR>ZDTXYIAY9^pYi!avX&= z(mUb_Qd3e8fr>b1+9Riydc}3@0Tl{x)L82x&%m*mAVYfLV#+z_X14F|ZR9FE@0kxS z&z6G8o7Laq&tShRwJLWruaEWd!q>@1qup6wc+YRR;OTaxF*mk1eYz8F*4nvamih;r C+-_k2 delta 325 zcmWO0zbnLX9LMpyWLX!Ywl(|I!V%U4==&tu`1_+vR8ViXFC7PHee(p_Db3%8fejlb>EPr6WP GrTzhj)NV%r diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc index e546f849e2b62d9874000d6a21ad4bb9c741651b..040ea0f69b70a5dcd3de806d7bc62e3d2000f294 100644 GIT binary patch delta 76 zcmV~$yAgmO3;@uA^Nw*sh>&zP4nlqlxrhaOxq|QQy6bzCD=-F6x9G6g8;Y}bnA!w^ aI8D5FR0zs(8o^u5nL~a`)De0d3JcnEixkwjA1O;K6`#6^tMPOhr$iiq5Sq(u8 zv78`^Aead^f}5CT(Mv3(K&TdhOQRrHh4gRr`xd{&$2Z+I-F0_x*Z@EjHae-j@DP}T zLhF@8LMl*1&VVS`C6banfrhAL@Dgkwr&kW0JLRRZ{q_2RdSgrd{a7su$;4o@lRhys zgs8H?kTrTHNNh7gmYB$^9DFe;>HwM9VE~=xu9P>ICs?vYmm(|SF@dCnK?xUtq|lfn zB~@q$*4dh=bs;Irc~Ycdthm8a>%Z-Zq3RR=nIBA@nOk0c)moh2d1vfRWu<1= zQ#8(KTLj5A1nsg%iv^hf{>l}>G*Pt00YMlSHTYCyjDbYTom$`I)@{%BzwN)j*xUSc zu+_3JdKXqNZ@<|yyy<84?2q>Cj~kWdpPud$zh*|iT(~zo`*QCi{5rU?dpuMc*DhV} F{s*O)pYH$w delta 442 zcmWO2O^D4=7zXfrLsL?-kd!h)CYsba?|IM1JE(sdX{>IuQJPWb<2|9NDVKCNbDNc< zp6Q?uL7Rh}&Y{lCrq1H|3fZ#l^EBX;n_(q$~~xgf1w;C`m@e zNg?Vi45Y{bq%qnRCnmtaiabRWSYN8u>NDNOcx{doXf3H*OE`ZhqMPtbahR?^^wFvGJ}N`4w&)-mp>u z7+(x27nu;b#Go;t7o4I36wxZ=&}HQH(!6;JbNz>oO`-9fB)qAmc8Hl GdjA7pE1M_) diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin index 1211a6059e5c63d55e7ba915d9f89e5dd5ca2b66..ad4c0d9f1d15e506031aba710fe9223d3f672f0a 100644 GIT binary patch delta 442 zcmWlVKSNHt8uZ|OpZ9qYF|dC_4GIpo)Zn2l zx*RTP&{PnK2*QDR22FuNQA0yRoJ2?k4gH$FpW!=v_^b@93@p^I7yuw)$w?hVfXEz` zw?Rpd9!nI&8N3(l5=w~yK?5lng9IDV>4VGnCWAC~q}{&Q?o`|F?$$_1Mh2Um42hW` zlFG)%tbJgF#5Q}#(kBcm$52ip9U(J2A3>*=vqiO-W69RLM65*c2_z;Tm2eSI3hgst zQl*Aqovk4)TwvBQCq&dDlceH(rBZad*QYDbI7wpwyR}ERf1l^x$rm!7pw9f^*$eCY z%f}-Q4V~)^|5=^cSsY&5d(ynqYOGGp-Q1WO9DV%dQ)8$3c31AI=I4iv;-F?YAQ@-0 zErVnmqjouX>q{{I{go?&X+*N+!Gkc4G=x-Uq=+KrZf$Ytet+hO&ir&w)wS`zSpT*) zzkDOV9y>Mu^~1>I#OT=7V;g&eM@y$)ZMOKq_mBO4uYPOc`K8vI?fuzbKdS!#kwBg{ delta 442 zcmWO2O^A(A90u_BjoVoIT1{yt%`B(R`Jey)eE7(#X_6YD=(0SYMln;^D9yFNW{P=B zQj^^70#g=hSgDcb)?^|JDYsBFDTO9kpd`-jSv-s1^UU?l_05hR4CuI%kdW~RV9}C{ z!9*z(c}>bVunJNlP=*m$9~l{Cyf=zaMyq-U&QDk%Ia1fV>vp4ld9K1}qXnbA77{@# zZb$}T%y4FT3_Jv#JMtv@W*lwfRH0AC4_-DmvFjH#a&PuQqxW zfV9~VQkF56a&&|aEsMbmK<1Ue7_8U%PGl)B&C_#T$q=+d(XEERoqGJ_<6v{^rLjGq zmfz3s+pzOT8QXu~EFT*@dhgNGf#&*t`?kM#<@3seMft3=K7Mu^j4Z9bqRS@+zU=bF Ef9koK*Z=?k diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential index be91ad0caedb4a48db32cb012ca9745c8ec3a654..8e1849bed2cbbf500423c8ae70cbb54bb1d8aff1 100644 GIT binary patch delta 323 zcmZ{cyGz4R7)P~IbaZsA#NyDTe!00#bGs-w_`0Sh>b7%PFY(@?)aV%f|Ktu_qivbE{x5OU>z;Kg!2@o@Qp8Q zV{IBA7wJF@-v4pVGpoVZzQ483dTutIZ!v*SS51~D`yja2+?(vDnEbefN z5I?vaMm11-n`MzF$`Q|H%(P+Z7H7Iisji~#bPHE#pn_@=2Y5x47{CdRig@YRJ?!J! lK?@uB&Ew6}W)w8>g!f2c#7vEZ#{R!YYQZi_H>ku${{WO5kc|KU delta 315 zcmZ{e%`1a(0LN{$93KbHWO2aO@9lY>-?JKpl=~)4(epNWI}F=lV!2QnVM#bTcs;l& zIk>oe@AGJ(9c1SzqRjjOZlBBN)8{I18rY8ceYKqtnxi^TI!sck+EOw@NQ)c7rUtdS z>u@2|B+-~-yB4FGrb>&M!ePR4xnUZ%AfnXJ1`pR!(bxxX8c#UE5ni#1+ZAjiP-$=Jq;k28O2qAwHmkJ)RHWbGP-ms6anIy`%m)JrP>-g4C!vTsd kDLlthgYzgyy&xk@3lSs4{x5qyljfXjr)L+#g-w6(7j8(1CIA2c diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed index 06b4b38e..96376850 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation-key.seed @@ -1 +1 @@ -ÿWýck1òT ÷²¹ú[Á^ka©$}R%.\Ú–—¨k2 \ No newline at end of file +>�Õ8ÑÇ£rÆyÜà�&—ž@‚â± ç�ÜÌqM}‰ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc index 9ab8bf43efb594ecaf705082f7c8077721c1a83c..b633e64c21e43688fba5f0946933b20b0da03d1c 100644 GIT binary patch delta 325 zcmWlTze|Ea9EWug9HeloDZ#Pi?%w;}dj|~?($Ex$R8!oK?+p$SM&J_B;A%rO1`VPa z4$%}SEg8~KER5V#VQ84AKEqCBDmrKM+<;mn{ zgwkPD?1^EzDrrDv0Fm4qV3zsF>O3{l2#QQwi-G49xg&-f)(K^fFd)*OUfe_xT3+ch%iPpWz+{Pi0dW@lxnFs8p{y>O&C!Ot?e%ZtL^RmeWY<-3{w$>D|jvDNQN_96X~$>$#viDb1oH(z{?c@!)G zbXowCs4JNw<3PbM!2~q~6$2Fnbxczk83b>2_rnv$z2y|hkw+>7(am^yt24XtKHjc= z{ox7xzPZE2`t(aWGuVOl@oO$LZgz4WHw1TN5+G@q-RwAFy~cy$I5oW^Z@?+RCV#&%s$pl0Pw06ZF* pOX5X$j>u%Dnjq~|n2RHnB8slAWHq8}l;xYxp_|^Ly_=py1%4MO7fApB delta 89 zcmV~$!3{uA3;@swE^Z_yut58(zjd|AuU29nS2KvjG`8{H)m_~))N2%+Q7L=CvI&tf odlXBiiLx3SvE?0hjSvCwNbZizibWSs@J(mmjPFt3Rg0p6KV}*jmH+?% diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active-below-minimum.bin index ed6f48b9bb48dc5dadc90d1d4454676ca49d764b..441e0bf465768df545ba951e9fe70b01a27e10e3 100644 GIT binary patch delta 444 zcmWlUJ808U6o(U9=pa4@2OoQvBHY)x_nbu|Qmu4Qs%wS&I2T{IC^k!pLs0~wMzJ^u zx`>L!K?zRLf&mRg+6o0D5)4@CP!ybe7XSX%?*|{>{LuW+^@E3Ri88Zt7DxfB? zEYVsY*+f>-11ph2445<8g0O+Cm_b2+?xFK1y#l+ZUf)}PTRp%+r-WKL3C=_ifjsz# z=mmKVkdlXFNfizkIAswM7eD36qq9org@qUO^la$?Cj}s>RhhZ^^Hpc9apuUzt-nv! zx4f^8O+0;heBjZ?xz96S+p9O$?k|tL+j)6Y?@yz)GQ8vU!{)Q;^~S}!dH$TAuCmoU;_!t%m_tBR?+xS;P8Jk7!VqqMh1wPQf^gfwAY`V8-KpBbg8{G zS)01@ZG5n|^>P1N|4sGw%J&OXAG(vxQ@c9N(zcP)i~D9j4eUNQoKJKczb1}###Y;d F{{X|HrojLJ delta 444 zcmWO2KSv#I_U8pWp@0>baiq#+-eZjU?mRJP|NqDHSQ3xw>HO2)9nM9Gt~ zKAEIUvL%#W#5n-&A(_z_kI@GvsH_!5<#}uTa`94_jKR>VAD{SX4<8&Fe^gug{rPQc zYhdT<#I53Jqw&Xm-FUb?|LNVUH+$A%f4A{$uJ6+0*BgUdzwS@2Z(bYQb>-sVi5*3! zo`G0J6i3>sQhO>zSyT!GDUvc6%)JC9QE*;!B<&zYMwi4%?@X)yv^}|Zb7pn={Os_z z?t}Mt#}@se_R!hCUlwO;_+sSW#~bHvc7GfgJ=5m?eSPysuyf(|eqUXlrrk^5Yr|8; Ef6}*|IRF3v diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin index f9bb5d1887b9ab4524b31b33838ce55994ac48f3..1f13b519925596e9b77619edbbd991d2321f5b30 100644 GIT binary patch delta 444 zcmWlUIcSto6h>#TNa2bM;?@MkM%1_6`|dLsCIliP5v@cE!&~kn30g>+6d5Lk*hTUy zVqppqi3q90CLlTk!=T{c0wO^REyczns4#K){o3=v;j9d-3@nbFvVzbUlQLx%3REEN z09g{Ek8C0<>4B9D`ghWxNHFxF$#^w zIU&1zRf;5{B&$_6%QRB zslM2BvD@C*xZXe5|NCkD#_~jMaOS~<^`CD(ylie6i6`do_WEC!?$7O7*qZKqJ~mld ztCw9YoM6Xb97+`=U~s}^W`Z&!t7v>Eari$O37z$ zjObE9q2Q9mx`pMNX?ZNioy)$Ps8wE_k1xyssD&;5= zTZ)prgKWV=U>2l|m(fCkltQ!y&}tPjt&Pr11)z zb(A`q4B0VT3qXA&5o+x)I8Oq~tEE!;Nq6je=^2yI8hy96bsP7OA8D?vcV1rmI~x}4 zRIk^o37J$9m3KYsPo(EZl9Q|D6t)+I(eu=2P=tY-kEpi9TZsztPX*YP*DW)=ba)X3K24b1?#4e zA0CymMW`Sz9rTn4t#lBn2&p9s=@0~UD5R)E^!2+wKX`Z+I~O~r2Txi-Mr)liWfp)C zNI5ht$rvA5M+WJUL8K4^-x?aJJmdt3(zWV4E-ZCC-v4N0;r@?_ z=U3j&f4dDsxm$YtMLih}@A1XH0PA01$Yz2TAh**K7 zfG%>%Ddy-itK>afWs(mxLSSksuuPyOtiK>JbQI)=F{$<=LY?wp?UJ!g}ciW8}$sx z6qPvCmWuXRgsdV`0STo9qw*+0K@84oj;I}^$jT*gGCI?%Pj?&3dxuvJ9oT2=YYg5r2``QP2SkCL Apa1{> diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/provider-identity.credential index 45db9617f2fcfb960f3afda9b8ec42fdc1a6b2d1..0b9ae91fe0544f8f1b0d3f6074e252b4e2d62efb 100644 GIT binary patch delta 316 zcmZ{c&nts*0EfNPike)w%wc}C)4Xrr_t*Ew)@n&sk{WNxL3!V8uhc{ua?mSonl;Tq zlgvd$C?c*Y~UL2jY+{n@P2RJ@YPtQfL7OYt9((iHA3n-qS(sZU~ZBBDcRb3(s z;WCqIJWq*E4Iwx;RWolmlsc}iIu7Hy%Uy1WEYVrcu-SWCe<*=ks$+fQo8|uBd#aEZ*^`Sh1 zhJ*@E@l;i$S5H`k#EB6%siK)oBf7~%tfffn`GWcu_R?t?2PokOnA(UYr(JxGVn35E q-~rco42N+P!d=E6(uhW7B4q9Vt3SM8FXgjqg`%>uy1ZU32O@udkbXM= delta 317 zcmZ{eze@s90LRM`Y7bf}j6nLK^6ov~-MiZwf+mTOAZWSsJ(EBx;V2N4me$N0YKsOb z3ERT9q{XQ3(0UftAho!(7j^vs@twYW`F!}Eh8y8ZIusf_T+zzP){>fFx>Ur>7>qfb zOJzIM5tQk|;SS@Do+VkKlwlAe6_rwIoD0nsrljP#FM`&vhWa)-ljv?P;{{I%+@p>z z+Bkzp0MxKIzEDJOD~D!Kn*lWzo5wz;?^biJxr-}_R~#(i5tsNLL3?Nb=Oy$aqXkqK z@rDm?roU4bY04}nEN&!pLkK}d(wHN}B7Zkh_`(B@@q?<1#sqHhiR%qq;JpxNZ*YRz lPBd66LHyZ#3P)>b`BRioJx/W•ËW;ÆsÖSÅ2�µ4 \ No newline at end of file +c$…Jp[vcϽ|½Ô@ ž×í†`JÛûd +Í_z3Í3¢ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc index 6127df727f18aed025681ae2377af563f9d11ed6..00c3c07fe4835b043b3eb018a11aa82dc52faabf 100644 GIT binary patch delta 325 zcmWO0zbnLX9LMoH>c}Mw5*EX4bba2RU*FG1UA`29>Cz>|aG#%7%vWa;16?VDn=i$s z7_eE@cc#>lB0m@93>Jf~eE)#gZ$Y1WGI0MXPn^30)P#)#atR;loiH12@(^~ z&IpW|p;T$Cz)SDF`&I2Mjlb3A zRx%@7mkYPwg~liucwMixm+#vjx%X%%`#jB#8kwG<=3-Ykx%P7SG?ncb9$f9a-uo^0 F{sSiua76$B delta 325 zcmWO0JtzcF902gU<#Htpu^0>(xblDB?+v)J%Pq1P-v50RW${rLMIsi9E~CqI1}UA+ zLS2`Ol#!T$8yi`&CB-s^+O_&JgQI4ulJ9c;?nfoWcnZHT5~4= diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc index 0b02a2e82b37bd2acf684a71abdc3196199b0264..748cc83dc11de7479ac6e80fcb4d8d49f79eb59a 100644 GIT binary patch delta 76 zcmWN{u@S%^2mrv#**Fdg3UtXRfJ~GzY%*7-NWT7EcYTg>+GrvkX`KZPAf}Gw6i;G; Z=wT*EBbu^n$Wol}Pc)w1(fPj0`vEWg6-odA delta 76 zcmV~$!4ZHk2n4`!@<}-uP{>b9N#JP6GV-x5t8jPKRo|^#P9Ad?OOx(kkR*VBsk5fg YU@-Yf{RoMs(XAy9R)b_{@N<{%2U>L&bN~PV diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin index 15ae2e8c9912a0fa89482131e573b2a8f3a72105..cefcf1d0f05aedf619ed6d6bb5b67de23d286e83 100644 GIT binary patch delta 440 zcmWNNO^D4=7{>oMG8UtdWU!)HSZL09&wD;z)ZAvHnUqec_|BkO+O@Y@^RsfV+V)Rl?hFL$2| z&rfbNzXlh&{rhvfc8{&Tf4ust@u4}`99bKwTttEv9Wf4@Y62LO|-D zP#s%JrMhX+$w4%M2uhvA76%u**`*nDNUd4F-}beJTfg%u%LAO>LbBu=Hp3w%Fh8F0Re|4aMH62V+mi zPj574N7j4qCJ$YCG4bul)>7;HMq_!dHvjs^$ceMN&5N7;FQ4Y`e)w3~u0aqYT7%Ym z9l#J+GQx$J2qXF!fg*#5Q3p~^XOfU2n83i8<6ujt2Hob{pR?}H^X2xv_H`Kjeen2F xufHh%J?qx1Lx)e^+Uy-Y_G`5MXkzDOdv3w(y*X1F+o?|PzrWf)UTMCn{0GnGpGW`z diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin index 1d106eb5ff53a16de8ad39b93facaf8125c67181..375131d00f282c71e7b67679f28c03c0bc167f71 100644 GIT binary patch delta 440 zcmWNNO-Phc7=^z<@FUt7h)_@x{K?<{S5xn=k_hK1_3H^W}5kw-z zd@x4PLP2CA+&Bv*h=iDM5mC5vR}?`D!A0%6`@=csc{*jMY&WkuE^MS|H4B{cV0-}W z$PxxiDLU>za39G6D3TIAAWtr(`FzEG^(M0Q`G?Ebz zq7_mRcgE+t!3oWBQlQZugg3RXMNwPpPuwoLF@RFs^&8vwMu%@L|N6eV^RczR^x<`V zcI^7G=THnhYZgntM_#>rxAJ1|esB2kPJ8j(=@ajF&m3K9ws*Vq_QB8D-uRD!g9fP} zECdxPyONs4q>OPwYRR*pY%R;umI2Fzg6Y7SWQZzyff+x_eq*ET*3Yum`N^@Tbmv<& zKC}O3`N{OZ`QvnQZezQ*l^)K_9gdv3_UX~hM(0oa%TQTgaW}%=)@JK(H8nI?{s()C Bm6iYi delta 440 zcmWO2J!q3r6oBFHqt-Ym=pd+r6`>A_<=mfhe>#{TO6^cAHWnT4J@+0(6o2YegbX#h z`au)~g+M_U2WNE=q7)qz9AZ#9NwqE_NO04v@3Xy~TBmmJ_$>MWT38X11!5q_GUyoD zC1b3zAmoTqT1QFh6qJ)F(Ik-^cpD)OW|l67Q8`*w(^YqC_5Mbka@Ixz>&${UBLakw z5H+KbM9LrsX^^x;=g3);Rc05XV5M34)mvrRBA^t7cI>pLk%Q)(bIQ(=vaBJftbh!7 zOgc%MypybgvMy^*HmUMMQIy@@{N>_lP7+c0UgPu4Eq}&e^gk`mufJRP{P3D&SyuuYbWYM{xy4DUii`ZcDuEGVX;1U>E*fh>$#1hIe3-#PF<@F8W4nt)}ZxX z2QUPdjBp_)!iYXbpvWL%)Pa=KnIxnLCNMDOINH){@Md$cwzQ{x_Tb6o=5TXt_;cdT zp=19hAFq7fxO4ZxSo`6w<&}wjlj`dEnZMKbA5CqmsQ7Y5{yu%+`Kc@Y!{h$}_;i># diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential index baff802e3960ec5acb009a91fd33d54d7a4b471f..52b1b8e809059949d9b7af89dab19973f0b1e529 100644 GIT binary patch delta 316 zcmZ3@x`cH?2s7jFS(9s-^y+UqB$^spSR^HZ^Gn1X!t@IZJBwDEucV-)#R4Q6nh?puUDJ%p* zNuw6p2@wRr@&+snJ`k}IMG|b%sD*29cZ+imr)rm@G*cb)Nz4tG*QAi z8cVSv&J>z94yVx=#tq82o$88iDO@LW>4>^Q8}Haa4ZCP1dgmiW6{q-IMQw66HiXT- zZoJOnE>juld|Xs{#(l2*tQ>bqamfT@V?;jDU&=G?Q+frhiM42!@Dn%jDD5RGI>rI& eJ Date: Tue, 29 Sep 2026 02:05:14 +0200 Subject: [PATCH 05/12] cultnet-ts: split Idunn authority from the Odin publisher; answer route challenges locally idunn-runtime-authority.ts now loads and signs only: no Odin endpoint, no Odin dependency check, no RUDP. createIdunnRuntimeSigner signs presence and answers Idunn's route challenge with answerRouteObservation(request, state), a synchronous local signature in the state the app reports. The RUDP publisher moves to idunn-odin-presence-publisher.ts for services that declare odin.verse-rendezvous, and keeps the Expected-endpoint check. Deleted: the TS capability-minimum refusal (the Rust correlation records the typed disagreement), latestPresenceDocument, the forced "active", and the publisher's redundant health-contract and bind checks. Route validation now accepts http, tcp and rudp as Rust does; the machine-id falls back to /var/lib/dbus/machine-id. Tests run against the Rust-written fixtures. Co-Authored-By: Claude Sonnet 5 --- .../src/idunn-odin-presence-publisher.ts | 240 +++++++++++ .../cultnet-ts/src/idunn-runtime-authority.ts | 344 ++++----------- packages/cultnet-ts/src/index.ts | 10 +- .../test/idunn-runtime-authority.test.ts | 398 +++++++++--------- 4 files changed, 538 insertions(+), 454 deletions(-) create mode 100644 packages/cultnet-ts/src/idunn-odin-presence-publisher.ts diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts new file mode 100644 index 00000000..8e13acb4 --- /dev/null +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -0,0 +1,240 @@ +import dgram from "node:dgram"; +import { decode, encode } from "@msgpack/msgpack"; + +import { encodeCultNetMessageForWire, parseCultNetMessage } from "./contracts"; +import type { IdunnRuntimeSigner } from "./idunn-runtime-authority"; +import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; +import type { CultNetRudpPacket } from "./rudp"; +import type { RuntimePresenceHealth } from "./runtime-presence-health"; + +const ODIN_CAPABILITY = "odin.verse-rendezvous"; +const MAX_LEASE_WAIT_ROUNDS = 720; + +export type IdunnRuntimePresencePublisher = { + readonly runtimeId: string; + readonly runtimeInstanceId: string; + readonly requiresWriteLease: boolean; + publish(state: RuntimePresenceHealth["state"], detail: string): Promise; + waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; + assertWriteLease(): Promise; +}; + +export type IdunnRuntimePresencePublisherOptions = { + signer: IdunnRuntimeSigner; + endpoint: string; +}; + +/** + * Publishes the signer's presence to Odin over RUDP, for services that are + * CultMesh-aware and declare `odin.verse-rendezvous` in Expected. The route + * proof never passes through here: `signer.answerRouteObservation` answers + * Idunn's challenge locally and needs no Odin. Capability shortfalls are not + * judged here either; the correlation owner records a typed disagreement. + */ +export function createIdunnRuntimePresencePublisher( + options: IdunnRuntimePresencePublisherOptions, +): IdunnRuntimePresencePublisher { + const { signer } = options; + const endpoint = parseRudpEndpoint(options.endpoint); + const odinDependency = signer.authority.expected.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" + && dependency.capability === ODIN_CAPABILITY); + if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(options.endpoint)) { + throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); + } + const connectionId = 0x0d1d0002; + let messageSequence = 0; + + const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { + const signed = signer.sign(state, detail); + messageSequence += 1; + await publishDocument(endpoint, connectionId, { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, + document: signed.document, + }); + if (state === "warming") signer.recordPublishedWarming(signed.canonicalSha256); + return signed.canonicalSha256; + }; + + return { + runtimeId: signer.runtimeId, + runtimeInstanceId: signer.runtimeInstanceId, + requiresWriteLease: signer.requiresWriteLease, + publish, + async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { + if (!signer.requiresWriteLease) return ""; + let warmingSequence = 0; + while (!signal?.aborted) { + await publish("warming", "waiting-for-process-write-lease"); + warmingSequence += 1; + try { + return signer.assertWriteLease(); + } catch (error) { + if (warmingSequence > MAX_LEASE_WAIT_ROUNDS) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); + } + await abortableDelay(pollIntervalMs, signal); + } + throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + }, + async assertWriteLease() { + return signer.assertWriteLease(); + }, + }; +} + +async function publishDocument( + endpoint: { host: string; port: number }, + connectionId: number, + message: Record, +): Promise { + const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { + socket.off("error", reject); + resolve(); + }); + }); + const receiver = receivePackets(socket); + const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); + try { + await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); + await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); + const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); + const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); + for (const packet of packets) await sendPacket(socket, endpoint, packet); + await receiveUntil( + receiver, + session, + endpoint, + () => session.outstandingReliablePacketCount === 0, + 2000, + (frame) => { + if (frame.channelId !== "schema") return; + const response = parseCultNetMessage(decode(frame.payload)); + if (response.schemaVersion === "cultnet.error.v0") { + throw new Error(`Odin rejected runtime presence: ${response.error}`); + } + }, + ); + } finally { + receiver.close(); + socket.close(); + } +} + +type PacketReceiver = { + socket: dgram.Socket; + next(timeoutMs: number): Promise; + close(): void; +}; + +function receivePackets(socket: dgram.Socket): PacketReceiver { + const packets: CultNetRudpPacket[] = []; + const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; + const errors: Error[] = []; + const drain = () => { + while (waiters.length && (packets.length || errors.length)) { + const waiter = waiters.shift()!; + clearTimeout(waiter.timer); + if (errors.length) waiter.reject(errors.shift()!); + else waiter.resolve(packets.shift()!); + } + }; + const onMessage = (wire: Buffer) => { + try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } + drain(); + }; + const onError = (error: Error) => { errors.push(error); drain(); }; + socket.on("message", onMessage); + socket.on("error", onError); + return { + socket, + next(timeoutMs) { + if (packets.length) return Promise.resolve(packets.shift()!); + if (errors.length) return Promise.reject(errors.shift()!); + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timer: setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index >= 0) waiters.splice(index, 1); + const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; + error.code = "ETIMEDOUT"; + reject(error); + }, Math.max(1, timeoutMs)), + }; + waiters.push(waiter); + }); + }, + close() { + socket.off("message", onMessage); + socket.off("error", onError); + for (const waiter of waiters.splice(0)) { + clearTimeout(waiter.timer); + waiter.reject(new Error("CultNet RUDP publisher closed.")); + } + }, + }; +} + +async function receiveUntil( + receiver: PacketReceiver, + session: CultNetRudpSession, + endpoint: { host: string; port: number }, + predicate: (packet: CultNetRudpPacket) => boolean, + timeoutMs: number, + onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const packet = await receiver.next(Math.min(100, deadline - Date.now())); + const received = session.receive(packet, Date.now()); + if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); + for (const frame of received.delivered) onDelivered?.(frame); + if (predicate(packet)) return; + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; + } + for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); + } + throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); +} + +async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { + const wire = encodeRudpPacket(packet); + await new Promise((resolve, reject) => { + socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); + }); +} + +function parseRudpEndpoint(value: string): { host: string; port: number } { + const text = value.replace(/^rudp:\/\//i, ""); + const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); + if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); + const port = Number(match[2]); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); + return { host: match[1]!, port }; +} + +function normalizeRudpEndpoint(value: string): string { + const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); + const parsed = parseRudpEndpoint(normalized); + return `${parsed.host.toLowerCase()}:${parsed.port}`; +} + +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + reject(signal?.reason ?? new Error("Aborted.")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 5ea30fff..47e132b3 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -15,10 +15,7 @@ import { type RuntimePresenceHealth, } from "./runtime-presence-health"; import { truncateUtf8Bytes } from "./utf8-bound"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetRawDocumentRecord, type CultNetSnapshotRequestMessage } from "./contracts"; -import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; -import type { CultNetRudpPacket } from "./rudp"; -import dgram from "node:dgram"; +import type { CultNetRawDocumentRecord, CultNetSnapshotRequestMessage } from "./contracts"; export const IDUNN_EXPECTED_INCARNATION_SCHEMA = "idunn.expected_incarnation.v2"; export const IDUNN_RUNTIME_ACTIVATION_SCHEMA = "idunn.runtime_activation.v2"; @@ -77,33 +74,50 @@ export type IdunnRuntimeAuthority = { processWriteLeasePath?: string; }; -export type IdunnRuntimePresencePublisher = { +export type SignedRuntimePresence = { + document: CultNetRawDocumentRecord; + canonicalSha256: string; +}; + +/** + * Signs this launch's runtime presence and answers Idunn's route challenge. It + * owns no network: Odin publication, if a service wants it, is a separate + * consumer of `sign`. + */ +export type IdunnRuntimeSigner = { + readonly authority: IdunnRuntimeAuthority; readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - latestPresenceDocument(): CultNetRawDocumentRecord | null; - publishRouteObservation(request: CultNetSnapshotRequestMessage): Promise; - publish(state: RuntimePresenceHealth["state"], detail: string): Promise; - waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; - assertWriteLease(): Promise; + /** Signs one presence document. Every call takes the next publisher sequence. */ + sign(state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence; + /** + * Answers an Idunn route challenge from local authority alone: signs a fresh + * presence in the given state, with no network. The caller supplies the + * app's current health (`warming` until first healthy). + */ + answerRouteObservation(request: CultNetSnapshotRequestMessage, state: RuntimePresenceHealth["state"]): CultNetRawDocumentRecord; + /** Reads Idunn's write lease and checks it names this incarnation and a warming proof this process published. */ + assertWriteLease(): string; + /** Marks a warming presence as accepted by its consumer, making it a valid lease target. */ + recordPublishedWarming(canonicalSha256: string): void; }; -export type IdunnRuntimePresencePublisherOptions = { +export type IdunnRuntimeSignerOptions = { authority: IdunnRuntimeAuthority; - endpoint: string; - healthContract: string; capabilities: RuntimePresenceHealth["capabilities"]; }; /** * Loads the root-provisioned Idunn bundle and parent-only signing descriptors. * The expected record and activation witness are accepted only when their - * exact positional contracts and mutual incarnation binding agree. + * exact positional contracts and mutual incarnation binding agree. Nothing + * here reads Odin: whether a target talks to Odin is decided by the + * publisher a service chooses to create. */ export function loadIdunnRuntimeAuthorityFromEnvironment( target: string, healthContract: string, - odinEndpoint: string, ): IdunnRuntimeAuthority { const bundle = requiredEnvironment(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT); const expected = readAuthorityRecord(path.join(bundle, "expected.cc"), "idunn.expected_incarnation", IDUNN_EXPECTED_INCARNATION_SCHEMA); @@ -113,11 +127,6 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( if (expectedValue.target !== target || expectedValue.healthContract !== healthContract) { throw new Error("Idunn Expected target or health contract does not match this runtime."); } - const odinDependency = expectedValue.dependencies.find((dependency) => dependency.kind === "shared-infrastructure" - && dependency.capability === "odin.verse-rendezvous"); - if (!odinDependency || normalizeRudpEndpoint(odinDependency.providerEndpoint ?? "") !== normalizeRudpEndpoint(odinEndpoint)) { - throw new Error("Configured Odin endpoint does not match Idunn Expected dependency authority."); - } if (activationValue.expectedProjectionSha256 !== expectedValue.canonicalSha256 || activationValue.runtimeId !== expectedValue.runtimeId) { throw new Error("Idunn Activation does not bind this exact Expected incarnation."); @@ -167,6 +176,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( return authority; } + /** * Signs the fixed Idunn v2 positional runtime-presence contract with the * provider identity and this launch's Idunn-issued activation key. @@ -205,42 +215,26 @@ export function signIdunnRuntimePresence( }; } -export function createIdunnRuntimePresencePublisher( - options: IdunnRuntimePresencePublisherOptions, -): IdunnRuntimePresencePublisher { - const endpoint = parseRudpEndpoint(options.endpoint); - const expected = options.authority.expected; - if (options.healthContract !== expected.healthContract) throw new Error("Runtime presence contract differs from Expected."); - if (options.authority.boundEndpoint !== expected.route?.candidateEndpoint) throw new Error("Runtime presence bind differs from Expected."); - for (const required of expected.capabilities) { - if (!options.capabilities.some((actual) => actual.capability === required.capability - && actual.schema === required.schema - && actual.compatibility === required.compatibility - && actual.capacity >= required.minimumCapacity)) { - throw new Error(`Runtime does not provide Expected capability ${required.capability}/${required.schema}.`); - } - } - const connectionId = 0x0d1d0002; +export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner { + const { authority } = options; + const expected = authority.expected; let sequence = 0; - let latestLeaseSha256: string | null = null; - let latestPresenceDocument: CultNetRawDocumentRecord | null = null; const recentWarmingProofs: string[] = []; - const assertWriteLease = async () => { + const assertWriteLease = () => { if (!expected.writeLeaseRequired) return ""; - const leasePath = options.authority.processWriteLeasePath; + const leasePath = authority.processWriteLeasePath; if (!leasePath) throw new Error("Idunn process write lease is required but no lease path is configured."); const record = readAuthorityRecord(leasePath, "idunn.process_write_lease", IDUNN_PROCESS_WRITE_LEASE_SCHEMA); const lease = decodeArray(record.payload, 14, "Idunn process write lease"); - assertLeaseMatches(lease, options.authority, recentWarmingProofs); - latestLeaseSha256 = prefixedSha256(Buffer.from(encode(lease))); - return latestLeaseSha256; + assertLeaseMatches(lease, authority, recentWarmingProofs); + return prefixedSha256(Buffer.from(encode(lease))); }; - const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { - const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : await assertWriteLease(); + const sign = (state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence => { + const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : assertWriteLease(); sequence += 1; - const signed = signIdunnRuntimePresence(options.authority, { + const signed = signIdunnRuntimePresence(authority, { capabilities: options.capabilities, state, detail: truncateUtf8Bytes(detail, RUNTIME_PRESENCE_DETAIL_MAX_BYTES), @@ -248,44 +242,28 @@ export function createIdunnRuntimePresencePublisher( publisherSequence: sequence, observedAtUnixMillis: Date.now(), }); - const document: CultNetRawDocumentRecord = { - schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - recordKey: expected.target, - storedAt: new Date().toISOString(), - payloadEncoding: "messagepack", - payload: new Uint8Array(signed.payload), - sourceRuntimeId: expected.runtimeId, - sourceRole: "runtime-presence-health-publisher", - tags: ["cultnet.transport.rudp.v0", "runtime-presence"], - }; - await publishDocument(endpoint, connectionId, { - schemaVersion: "cultnet.document_put_raw.v0", - messageId: `runtime-presence:${expected.target}:${options.authority.activation.runtimeInstanceId}:${sequence}`, - document, - }); - latestPresenceDocument = document; - if (state === "warming") { - recentWarmingProofs.push(signed.canonicalSha256); - if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); - } - return signed.canonicalSha256; - }; - - const copyLatestPresenceDocument = (): CultNetRawDocumentRecord | null => { - if (!latestPresenceDocument) return null; return { - ...latestPresenceDocument, - payload: new Uint8Array(latestPresenceDocument.payload), - ...(latestPresenceDocument.tags ? { tags: [...latestPresenceDocument.tags] } : {}), + canonicalSha256: signed.canonicalSha256, + document: { + schemaId: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, + recordKey: expected.target, + storedAt: new Date().toISOString(), + payloadEncoding: "messagepack", + payload: new Uint8Array(signed.payload), + sourceRuntimeId: expected.runtimeId, + sourceRole: "runtime-presence-health-publisher", + tags: ["cultnet.transport.rudp.v0", "runtime-presence"], + }, }; }; return { + authority, runtimeId: expected.runtimeId, - runtimeInstanceId: options.authority.activation.runtimeInstanceId, + runtimeInstanceId: authority.activation.runtimeInstanceId, requiresWriteLease: expected.writeLeaseRequired, - latestPresenceDocument: copyLatestPresenceDocument, - async publishRouteObservation(request) { + sign, + answerRouteObservation(request, state) { if (!request.messageId || request.messageId.trim() !== request.messageId) throw new Error("Idunn route observation requires a message id."); if ((request.schemaIds && !request.schemaIds.includes(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) || (request.recordKeys && !request.recordKeys.includes(expected.target)) @@ -293,159 +271,16 @@ export function createIdunnRuntimePresencePublisher( || request.shardEpoch !== undefined) { throw new Error("Idunn route observation does not request this runtime presence document."); } - await publish("active", `route-observation:${request.messageId}`); - const document = copyLatestPresenceDocument(); - if (!document) throw new Error("Idunn route observation was published without a presence document."); - return document; - }, - publish, - async waitForWriteLease({ pollIntervalMs = 5000, signal } = {}) { - if (!expected.writeLeaseRequired) return ""; - let warmingSequence = 0; - while (!signal?.aborted) { - await publish("warming", "waiting-for-process-write-lease"); - warmingSequence += 1; - try { - return await assertWriteLease(); - } catch (error) { - if (warmingSequence > 720) throw new Error("Timed out waiting for Idunn process write lease.", { cause: error }); - } - await abortableDelay(pollIntervalMs, signal); - } - throw signal?.reason ?? new Error("Aborted while waiting for Idunn process write lease."); + return sign(state, `route-observation:${request.messageId}`).document; }, assertWriteLease, - }; -} - -async function publishDocument( - endpoint: { host: string; port: number }, - connectionId: number, - message: Record, -): Promise { - const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); - await new Promise((resolve, reject) => { - socket.once("error", reject); - socket.bind(0, endpoint.host.includes(":") ? "::" : "0.0.0.0", () => { - socket.off("error", reject); - resolve(); - }); - }); - const receiver = receivePackets(socket); - const session = new CultNetRudpSession({ connectionId, initialSequence: 1, resendDelayMs: 100 }); - try { - await sendPacket(socket, endpoint, session.createConnect(Date.now(), new Uint8Array())); - await receiveUntil(receiver, session, endpoint, (packet) => packet.packetType === "accept", 5000); - const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); - const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); - for (const packet of packets) await sendPacket(socket, endpoint, packet); - await receiveUntil( - receiver, - session, - endpoint, - () => session.outstandingReliablePacketCount === 0, - 2000, - (frame) => { - if (frame.channelId !== "schema") return; - const response = parseCultNetMessage(decode(frame.payload)); - if (response.schemaVersion === "cultnet.error.v0") { - throw new Error(`Odin rejected runtime presence: ${response.error}`); - } - }, - ); - } finally { - receiver.close(); - socket.close(); - } -} - -type PacketReceiver = { - socket: dgram.Socket; - next(timeoutMs: number): Promise; - close(): void; -}; - -function receivePackets(socket: dgram.Socket): PacketReceiver { - const packets: CultNetRudpPacket[] = []; - const waiters: Array<{ resolve: (packet: CultNetRudpPacket) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }> = []; - const errors: Error[] = []; - const drain = () => { - while (waiters.length && (packets.length || errors.length)) { - const waiter = waiters.shift()!; - clearTimeout(waiter.timer); - if (errors.length) waiter.reject(errors.shift()!); - else waiter.resolve(packets.shift()!); - } - }; - const onMessage = (wire: Buffer) => { - try { packets.push(decodeRudpPacket(wire)); } catch (error) { errors.push(error as Error); } - drain(); - }; - const onError = (error: Error) => { errors.push(error); drain(); }; - socket.on("message", onMessage); - socket.on("error", onError); - return { - socket, - next(timeoutMs) { - if (packets.length) return Promise.resolve(packets.shift()!); - if (errors.length) return Promise.reject(errors.shift()!); - return new Promise((resolve, reject) => { - const waiter = { - resolve, - reject, - timer: setTimeout(() => { - const index = waiters.indexOf(waiter); - if (index >= 0) waiters.splice(index, 1); - const error = new Error("Timed out waiting for CultNet RUDP packet.") as NodeJS.ErrnoException; - error.code = "ETIMEDOUT"; - reject(error); - }, Math.max(1, timeoutMs)), - }; - waiters.push(waiter); - }); - }, - close() { - socket.off("message", onMessage); - socket.off("error", onError); - for (const waiter of waiters.splice(0)) { - clearTimeout(waiter.timer); - waiter.reject(new Error("CultNet RUDP publisher closed.")); - } + recordPublishedWarming(canonicalSha256) { + recentWarmingProofs.push(canonicalSha256); + if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); }, }; } -async function receiveUntil( - receiver: PacketReceiver, - session: CultNetRudpSession, - endpoint: { host: string; port: number }, - predicate: (packet: CultNetRudpPacket) => boolean, - timeoutMs: number, - onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const packet = await receiver.next(Math.min(100, deadline - Date.now())); - const received = session.receive(packet, Date.now()); - if (received.reply) throw new Error("Runtime presence received an unexpected reply-required packet."); - for (const frame of received.delivered) onDelivered?.(frame); - if (predicate(packet)) return; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code !== "ETIMEDOUT") throw error; - } - for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); - } - throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); -} - -async function sendPacket(socket: dgram.Socket, endpoint: { host: string; port: number }, packet: CultNetRudpPacket): Promise { - const wire = encodeRudpPacket(packet); - await new Promise((resolve, reject) => { - socket.send(wire, endpoint.port, endpoint.host, (error) => error ? reject(error) : resolve()); - }); -} - function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { const decoded = decode(fs.readFileSync(filePath)); if (!Array.isArray(decoded) || decoded[0] !== CULTCACHE_STORE_FORMAT || !Array.isArray(decoded[1]) || !Array.isArray(decoded[2])) { @@ -495,9 +330,7 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { if (!/^sha256-[0-9a-f]{64}$/u.test(hash)) throw new Error(`Expected ${name} hash is malformed.`); } if (!/^[0-9a-f]{40}$/u.test(sourceRevision)) throw new Error("Expected source revision is malformed."); - if (!route || route.transport !== "http" || !route.candidateEndpoint.startsWith("http://")) { - throw new Error("Idunn Expected must include an HTTP candidate route."); - } + if (route) validateRoute(route); return { schemaVersion: string(values[0], "Expected schema"), target, @@ -575,6 +408,24 @@ function decodeExpectedDependencies(value: unknown): ExpectedIncarnation["depend }); } +// The Rust owner (IdunnExpectedRoute::validate) accepts http, tcp and rudp +// routes; a runtime is not entitled to a narrower admission than Idunn's. +const ROUTE_SCHEMES: Record = { + http: { stable: ["http://", "https://"], candidate: ["http://"] }, + tcp: { stable: ["tcp://"], candidate: ["tcp://"] }, + rudp: { stable: ["rudp://"], candidate: ["rudp://"] }, +}; + +function validateRoute(route: NonNullable): void { + const schemes = ROUTE_SCHEMES[route.transport]; + if (!schemes) throw new Error("Idunn Expected route transport is unsupported."); + if (!schemes.stable.some((scheme) => route.stableEndpoint.startsWith(scheme)) + || !schemes.candidate.some((scheme) => route.candidateEndpoint.startsWith(scheme))) { + throw new Error(`Idunn Expected ${route.transport} route endpoints use an unsupported scheme.`); + } + if (route.stableEndpoint === route.candidateEndpoint) throw new Error("Idunn Expected stable and candidate route endpoints are identical."); +} + function decodeRoute(value: unknown): ExpectedIncarnation["route"] { if (value === null) return null; const route = array(value, 4, "Expected route"); @@ -595,7 +446,7 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { if (enrollmentNonce.length !== 32) throw new Error("Provider-health enrollment nonce has an invalid length."); const publicKey = Buffer.from(providerIdentityByteVector(publicKeyValue, "Provider-health public key")); const protectedSeed = Buffer.from(providerIdentityByteVector(protectedSeedValue, "Provider-health protected seed")); - const machineId = fs.readFileSync("/etc/machine-id", "utf8").trim(); + const machineId = readMachineId(); if (!machineId) throw new Error("Linux machine-id is unavailable for provider-health identity."); const expectedBinding = `${PROVIDER_PROTECTOR_CONTEXT}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; if (protectorKind !== "linux_file_mode_machine_id_binding" || binding !== expectedBinding || assurance !== "os_installation_file_bound_cloneable_baseline") { @@ -616,6 +467,15 @@ function openProviderHealthIdentity(credentialBytes: Buffer): crypto.KeyObject { return key; } +// Same sources, same order as the Rust identity protector. +function readMachineId(): string { + try { + return fs.readFileSync("/etc/machine-id", "utf8").trim(); + } catch { + return fs.readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); + } +} + function readProviderHealthIdentityCredential(bytes: Buffer): Uint8Array { const decoded = decode(bytes); if (!Array.isArray(decoded) || decoded.length !== 1) { @@ -693,40 +553,12 @@ function assertLeaseMatches(values: unknown[], authority: IdunnRuntimeAuthority, if (!matches) throw new Error("Idunn process write lease does not match the current Expected incarnation."); } -function parseRudpEndpoint(value: string): { host: string; port: number } { - const text = value.replace(/^rudp:\/\//i, ""); - const match = text.match(/^\[([^\]]+)\]:(\d+)$/) ?? text.match(/^([^:]+):(\d+)$/); - if (!match) throw new Error(`Invalid Odin RUDP endpoint: ${value}`); - const port = Number(match[2]); - if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid Odin RUDP port: ${match[2]}`); - return { host: match[1]!, port }; -} - -function normalizeRudpEndpoint(value: string): string { - const normalized = value.trim().toLowerCase().replace(/^rudp:\/\//, ""); - const parsed = parseRudpEndpoint(normalized); - return `${parsed.host.toLowerCase()}:${parsed.port}`; -} - function requiredEnvironment(name: string): string { const value = process.env[name]?.trim(); if (!value) throw new Error(`${name} is required for Idunn runtime presence.`); return value; } -function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }, milliseconds); - const onAbort = () => { - clearTimeout(timer); - reject(signal?.reason ?? new Error("Aborted.")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - }); -} function array(value: unknown, length: number, label: string): unknown[] { if (!Array.isArray(value) || value.length !== length) throw new Error(`${label} is not a ${length}-field positional contract.`); diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 3db890ef..8950f638 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -205,9 +205,15 @@ export { IDUNN_RUNTIME_BUNDLE_ENVIRONMENT, IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IDUNN_RUNTIME_ACTIVATION_SCHEMA, - createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, type IdunnRuntimeAuthority, + type IdunnRuntimeSigner, + type IdunnRuntimeSignerOptions, + type SignedRuntimePresence, +} from "./idunn-runtime-authority"; +export { + createIdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, -} from "./idunn-runtime-authority"; +} from "./idunn-odin-presence-publisher"; diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 7969fde2..1639b9aa 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -1,30 +1,92 @@ -import { mock, test } from "node:test"; +import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; -import crypto from "node:crypto"; import dgram from "node:dgram"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { decode, encode } from "@msgpack/msgpack"; import { createIdunnRuntimePresencePublisher, + createIdunnRuntimeSigner, loadIdunnRuntimeAuthorityFromEnvironment, - signIdunnRuntimePresence, - systemdListenPidMatches, -} from "../src/idunn-runtime-authority"; + type IdunnRuntimeAuthority, +} from "../src"; +import { systemdListenPidMatches } from "../src/idunn-runtime-authority"; +import type { CultNetSnapshotRequestMessage } from "../src/contracts"; import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; -import { - runtimePresenceActivationSigningMessage, - runtimePresenceProofPayload, - runtimePresenceProviderSigningMessage, - RUNTIME_PRESENCE_SLOT, -} from "../src/runtime-presence-health"; +import { RUNTIME_PRESENCE_SLOT } from "../src/runtime-presence-health"; -const providerIdentityContext = "gamecult-provider-health-identity-v1"; -const providerIdDomain = Buffer.from("gamecult.provider-health.identity.v1\0", "utf8"); -const activationIdDomain = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); +// Every input below was written by the Rust owner; see fixtures/idunn-runtime/README.md. +// The only values this file pins are the ones the generator also pins. +const FIXTURES = path.join(__dirname, "..", "..", "test", "fixtures", "idunn-runtime"); +const OBSERVED_AT = 1_790_000_000_000; +const CHALLENGE: CultNetSnapshotRequestMessage = { + schemaVersion: "cultnet.snapshot_request.v0", + messageId: "route-challenge-1", +}; + +type FixtureSet = "web" | "service" | "rudp-route"; + +function fixtureFile(set: FixtureSet, name: string): Buffer { + return fs.readFileSync(path.join(FIXTURES, set, name)); +} + +/** The Expected fields the tests need, read from the Rust-written file rather than re-spelled. */ +function expectedFacts(set: FixtureSet) { + const store = decode(fixtureFile(set, "expected.cc")) as unknown[][][]; + const values = decode(store[2]![0]![3] as Uint8Array) as unknown[]; + const route = values[15] as string[]; + const capabilities = (values[16] as Array<[string, string, string, number]>).map(([capability, schema, compatibility, minimum]) => ({ + capability, schema, compatibility, capacity: minimum, + })); + const dependencies = values[17] as unknown[][]; + return { + target: values[1] as string, + healthContract: values[10] as string, + candidate: route[3]!, + capabilities, + odinEndpoint: dependencies.map((dependency) => dependency[9] as string)[0], + }; +} + +/** Points the process at one Rust-written bundle and its inherited descriptors, as Idunn's systemd unit would. */ +function openAuthority(context: TestContext, set: FixtureSet, options: { machineId?: "etc" | "dbus" } = {}): IdunnRuntimeAuthority { + const facts = expectedFacts(set); + const originalEnvironment = { ...process.env }; + context.after(() => { + for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; + Object.assign(process.env, originalEnvironment); + }); + process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); + process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; + delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; + process.env.LISTEN_PID = String(process.pid); + process.env.LISTEN_FDS = "2"; + process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; + const machineId = fs.readFileSync(path.join(FIXTURES, "machine-id"), "utf8"); + const realReadFileSync = fs.readFileSync.bind(fs); + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { + if (filePath === 3) return fixtureFile(set, "activation-key.seed"); + if (filePath === 4) return fixtureFile(set, "provider-identity.credential"); + if (filePath === "/etc/machine-id") { + if (options.machineId === "dbus") throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + return machineId; + } + if (filePath === "/var/lib/dbus/machine-id") return machineId; + return realReadFileSync(filePath as never, ...args as never[]); + }); + return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); +} + +function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0) { + const authority = openAuthority(context, set); + const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); + return createIdunnRuntimeSigner({ authority, capabilities }); +} + +function freezeClock(context: TestContext): void { + context.mock.method(Date, "now", () => OBSERVED_AT); +} test("matches systemd's host PID for the private PID namespace init only", () => { assert.equal(systemdListenPidMatches(String(process.pid), process.pid), true); @@ -34,216 +96,160 @@ test("matches systemd's host PID for the private PID namespace init only", () => assert.equal(systemdListenPidMatches(undefined, 1), false); }); -test("opens Idunn identity and publishes valid CultNet data while surfacing piggybacked application errors", async (context) => { - const directory = mkdtempSync(path.join(os.tmpdir(), "cultnet-runtime-authority-")); - context.after(() => rmSync(directory, { recursive: true, force: true })); +for (const set of ["web", "service", "rudp-route"] as const) { + test(`opens the Rust-written ${set} bundle with no Odin endpoint, whatever route transport Rust admits`, (context) => { + const authority = openAuthority(context, set); + assert.equal(authority.expected.target, expectedFacts(set).target); + assert.equal(authority.boundEndpoint, expectedFacts(set).candidate); + }); +} - const machineId = "1234567890abcdef1234567890abcdef"; - const providerSeed = Buffer.alloc(32, 0x31); - const activationSeed = Buffer.alloc(32, 0x72); - const providerKey = privateKeyFromSeed(providerSeed); - const activationKey = privateKeyFromSeed(activationSeed); - const providerPublic = rawPublic(crypto.createPublicKey(providerKey)); - const activationPublic = rawPublic(crypto.createPublicKey(activationKey)); - const providerIdentity = sha256Hex(Buffer.concat([providerIdDomain, providerPublic])); - const activationIdentity = sha256Hex(Buffer.concat([activationIdDomain, activationPublic])); - const target = "streampixels-service"; - const contract = "streampixels-service.cultnet-rudp-service-health"; - const candidate = "http://127.0.0.1:18831"; - const expectedValues = [ - "idunn.expected_incarnation.v2", target, `sha256-${"1".repeat(64)}`, "incarnation-1", - `sha256-${"2".repeat(64)}`, "https://github.com/GameCult/StreamPixels.git", "a".repeat(40), - `sha256-${"3".repeat(64)}`, "streampixels-service-yggdrasil", providerIdentity, contract, - `sha256-${"4".repeat(64)}`, "service-boundary-v1", `sha256-${"5".repeat(64)}`, false, - ["streampixels-service-http", "http", "http://127.0.0.1:8831", candidate], - [["streampixels.service.api", "streampixels.api.v1", "v1", 1]], - [["shared-infrastructure", "odin.verse-rendezvous", "odin.verse-topology.v1", "v1", 1, "before-promotion", null, null, null, "rudp://127.0.0.1:17871"]], +test("a target that declares no Odin dependency opens and answers Idunn's challenge without one", (context) => { + assert.equal(expectedFacts("web").odinEndpoint, undefined); + const signer = signerFor(context, "web"); + assert.ok(signer.answerRouteObservation(CHALLENGE, "warming").payload.byteLength > 0); +}); + +test("finds the machine-id at the dbus path when /etc/machine-id is absent, as the Rust protector does", (context) => { + assert.ok(openAuthority(context, "web", { machineId: "dbus" })); +}); + +test("rejects a credential file holding more than one envelope", (context) => { + openAuthority(context, "web"); + const credential = decode(fixtureFile("web", "provider-identity.credential")) as unknown[][]; + const doubled = Buffer.from(encode([...credential, ...credential])); + const realReadFileSync = fs.readFileSync; + context.mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => + filePath === 4 ? doubled : (realReadFileSync as (...all: unknown[]) => unknown)(filePath, ...args)); + assert.throws( + () => loadIdunnRuntimeAuthorityFromEnvironment(expectedFacts("web").target, expectedFacts("web").healthContract), + /exactly one envelope/, + ); +}); + +test("answers a route challenge byte for byte as the Rust-verified vectors, in the state the app reports", (context) => { + freezeClock(context); + const signer = signerFor(context, "web"); + const warming = signer.answerRouteObservation(CHALLENGE, "warming"); + assert.deepEqual(Buffer.from(warming.payload), fixtureFile("web", "presence-warming.bin")); + assert.equal((decode(warming.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.state], "warming"); + const second = signer.answerRouteObservation(CHALLENGE, "active"); + assert.equal((decode(second.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.publisherSequence], 2); + // Sequence 2 cannot match a vector written at sequence 1; sign the same fields from a fresh signer. + const fresh = signerFor(context, "web"); + assert.deepEqual(Buffer.from(fresh.answerRouteObservation(CHALLENGE, "active").payload), fixtureFile("web", "presence-active.bin")); +}); + +test("a service with an Odin dependency signs the Rust-verified vectors, including a shortfall Rust correlates to a capacity disagreement", (context) => { + freezeClock(context); + assert.deepEqual( + Buffer.from(signerFor(context, "service").answerRouteObservation(CHALLENGE, "active").payload), + fixtureFile("service", "presence-active.bin"), + ); + assert.deepEqual( + Buffer.from(signerFor(context, "service", -1).answerRouteObservation(CHALLENGE, "active").payload), + fixtureFile("service", "presence-active-below-minimum.bin"), + ); +}); + +test("answers locally: synchronously, with no socket opened", (context) => { + const sockets = context.mock.method(dgram, "createSocket"); + const signer = signerFor(context, "web"); + const answer = signer.answerRouteObservation(CHALLENGE, "warming"); + assert.equal(typeof (answer as unknown as { then?: unknown }).then, "undefined"); + assert.equal(sockets.mock.callCount(), 0); +}); + +test("refuses a challenge that does not ask for this runtime's presence", (context) => { + const signer = signerFor(context, "web"); + const cases: CultNetSnapshotRequestMessage[] = [ + { ...CHALLENGE, messageId: "" }, + { ...CHALLENGE, messageId: " padded" }, + { ...CHALLENGE, recordKeys: ["another-target"] }, + { ...CHALLENGE, schemaIds: ["another.schema"] }, + { ...CHALLENGE, shardId: "shard-1" }, ]; - const expectedBytes = Buffer.from(encode(expectedValues)); - const expectedSha = `sha256-${sha256Hex(expectedBytes)}`; - const activationValues = [ - "idunn.runtime_activation.v2", expectedSha, "streampixels-service-yggdrasil", - `sha256-${"6".repeat(64)}`, activationIdentity, activationPublic, Date.now(), - "7".repeat(64), "ed25519", new Uint8Array(64).fill(0x17), - ]; - const bundle = path.join(directory, "bundle"); - fs.mkdirSync(bundle); - writeFileSync(path.join(bundle, "expected.cc"), cultCacheStore( - "idunn.expected_incarnation", "idunn.expected_incarnation.v2", "expected", expectedBytes, - )); - writeFileSync(path.join(bundle, "activation.cc"), cultCacheStore( - "idunn.runtime_activation", "idunn.runtime_activation.v2", "activation", Buffer.from(encode(activationValues)), - )); - - const binding = `${providerIdentityContext}:machine-id-sha256:${sha256Hex(Buffer.from(machineId))}`; - const mask = crypto.createHash("sha256").update(Buffer.concat([ - Buffer.from("gamecult-linux-service-seed-v1\0", "utf8"), - Buffer.from(providerIdentityContext, "utf8"), - Buffer.from(binding, "utf8"), - ])).digest(); - const protectedSeed = Buffer.from(providerSeed.map((value, index) => value ^ mask[index]!)); - const providerCredential = Buffer.from(encode([[ - "gamecult-provider-health-identity", - "gamecult.provider_health_identity.private.v1", - Buffer.from(encode([ - "gamecult.provider_health_identity.private.v1", providerIdentity, Array.from(providerPublic), Array.from(protectedSeed), - "linux_file_mode_machine_id_binding", binding, "v1", "os_installation_file_bound_cloneable_baseline", - "2026-01-01T00:00:00Z", Array(32).fill(0x2a), - ])), - "2026-01-01T00:00:00Z", - "gamecult.provider_health_identity.private.v1", - ]])); - let descriptorProviderCredential = providerCredential; - - const originalEnvironment = { ...process.env }; - context.after(() => { - for (const key of Object.keys(process.env)) if (!(key in originalEnvironment)) delete process.env[key]; - Object.assign(process.env, originalEnvironment); - }); - process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = bundle; - process.env.GAMECULT_IDUNN_CANDIDATE_BIND = candidate; - delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; - process.env.LISTEN_PID = String(process.pid); - process.env.LISTEN_FDS = "2"; - process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; - const realReadFileSync = fs.readFileSync.bind(fs); - mock.method(fs, "readFileSync", (filePath: fs.PathOrFileDescriptor, ...args: unknown[]) => { - if (filePath === 3) return activationSeed; - if (filePath === 4) return descriptorProviderCredential; - if (filePath === "/etc/machine-id") return machineId; - return realReadFileSync(filePath as never, ...args as never[]); - }); - - const authority = loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"); - const signed = signIdunnRuntimePresence(authority, { - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - state: "warming", - detail: "starting", - writeLeaseSha256: null, - publisherSequence: 1, - observedAtUnixMillis: Date.now(), - }); - const proof = runtimePresenceProofPayload(signed.presence); - assert.equal((decode(expectedBytes) as unknown[]).length, 18); - assert.equal(signed.presence.expectedProjectionSha256, expectedSha); - const signedRecord = decode(signed.payload) as unknown[]; - assert.equal(crypto.verify(null, runtimePresenceProviderSigningMessage(proof), crypto.createPublicKey(providerKey), Buffer.from(signedRecord[21] as Uint8Array)), true); - assert.equal(crypto.verify(null, runtimePresenceActivationSigningMessage(proof), crypto.createPublicKey(activationKey), Buffer.from(signedRecord[23] as Uint8Array)), true); + for (const request of cases) assert.throws(() => signer.answerRouteObservation(request, "active"), /route observation/); +}); +async function startOdinPeer(context: TestContext, endpoint: string) { const peer = dgram.createSocket("udp4"); + const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1)); await new Promise((resolve, reject) => { peer.once("error", reject); - peer.bind(0, "127.0.0.1", resolve); + peer.bind(port, "127.0.0.1", resolve); }); context.after(() => peer.close()); - const address = peer.address(); - assert.equal(typeof address, "object"); - const peerPort = (address as { port: number }).port; + const state = { + reject: false, + received: [] as Array<{ schemaVersion?: string; document: { payload: Uint8Array } }>, + responseAck: undefined as number | undefined, + failure: undefined as Error | undefined, + }; let serverSession: CultNetRudpSession | undefined; - let receivedMessage: unknown; - let responseAck: number | undefined; - let peerFailure: Error | undefined; - let rejectOdinAdmission = true; peer.on("message", (wire, remote) => { try { const packet = decodeRudpPacket(wire); if (packet.packetType === "connect") { serverSession = new CultNetRudpSession({ connectionId: packet.connectionId, initialSequence: 100 }); - const accept = serverSession.acceptConnect(packet, Date.now()); - peer.send(encodeRudpPacket(accept), remote.port, remote.address); + peer.send(encodeRudpPacket(serverSession.acceptConnect(packet, Date.now())), remote.port, remote.address); return; } if (!serverSession) throw new Error("Publisher sent data before establishing RUDP."); - const result = serverSession.receive(packet, Date.now()); - const frame = result.delivered.find((candidate) => candidate.channelId === "schema"); + const frame = serverSession.receive(packet, Date.now()).delivered.find((candidate) => candidate.channelId === "schema"); if (!frame) return; - receivedMessage = decode(frame.payload); - const responsePayload = encode(rejectOdinAdmission ? { - schemaVersion: "cultnet.error.v0", - error: "test admission denied", - routingHint: null, - code: null, - details: null, - } : { - schemaVersion: "cultnet.snapshot_response.v0", - messageId: "ack", - documents: [], - }); - const [response] = serverSession.sendMany("schema", responsePayload, { + state.received.push(decode(frame.payload) as never); + const [response] = serverSession.sendMany("schema", encode(state.reject + ? { schemaVersion: "cultnet.error.v0", error: "test admission denied", routingHint: null, code: null, details: null } + : { schemaVersion: "cultnet.snapshot_response.v0", messageId: "ack", documents: [] }), { reliable: true, ordered: true, nowMs: Date.now(), }); assert.ok(response); - responseAck = response.ack; + state.responseAck = response.ack; peer.send(encodeRudpPacket(response), remote.port, remote.address); } catch (error) { - peerFailure = error as Error; + state.failure = error as Error; } }); - const publisher = createIdunnRuntimePresencePublisher({ - authority, - endpoint: `rudp://127.0.0.1:${peerPort}`, - healthContract: contract, - capabilities: [{ capability: "streampixels.service.api", schema: "streampixels.api.v1", compatibility: "v1", capacity: 1 }], - }); - await assert.rejects(publisher.publish("warming", "local RUDP integration"), /Odin rejected runtime presence: test admission denied/); - assert.deepEqual(receivedMessage && (receivedMessage as { schemaVersion?: string }).schemaVersion, "cultnet.document_put_raw.v0"); - assert.ok(Buffer.from(encode(receivedMessage)).byteLength > 0); - assert.equal(responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); - assert.equal(peerFailure, undefined, peerFailure?.message); - assert.equal(publisher.latestPresenceDocument(), null, "a rejected publication must not become the snapshot source"); + return state; +} + +test("the Odin publisher publishes the signer's document to the endpoint Expected names and surfaces Odin's rejection", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - rejectOdinAdmission = false; await publisher.publish("warming", "accepted presence"); - const snapshotDocument = await publisher.publishRouteObservation({ - schemaVersion: "cultnet.snapshot_request.v0", - messageId: "route-challenge-1", - schemaIds: ["gamecult.runtime_presence_health.v2"], - recordKeys: [target], - }); - const publishedDocument = (receivedMessage as { document: { payload: Uint8Array; schemaId: string; recordKey: string } }).document; - assert.ok(snapshotDocument); - assert.equal(snapshotDocument.schemaId, publishedDocument.schemaId); - assert.equal(snapshotDocument.recordKey, publishedDocument.recordKey); - assert.deepEqual(Buffer.from(snapshotDocument.payload), Buffer.from(publishedDocument.payload)); - const routePresence = decode(snapshotDocument.payload) as unknown[]; - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.state], "active"); - assert.equal(routePresence[RUNTIME_PRESENCE_SLOT.detail], "route-observation:route-challenge-1"); - snapshotDocument.payload[0] = snapshotDocument.payload[0]! ^ 0xff; - assert.deepEqual(Buffer.from(publisher.latestPresenceDocument()?.payload ?? []), Buffer.from(publishedDocument.payload), "snapshot readers cannot mutate publisher state"); + assert.equal(peer.received[0]?.schemaVersion, "cultnet.document_put_raw.v0"); + const published = decode(peer.received[0]!.document.payload) as unknown[]; + assert.equal(published[RUNTIME_PRESENCE_SLOT.state], "warming"); + assert.equal(published[RUNTIME_PRESENCE_SLOT.publisherSequence], 1); + assert.equal(peer.responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); - descriptorProviderCredential = Buffer.from(encode([ - ...(decode(providerCredential) as unknown[][]), - ...(decode(providerCredential) as unknown[][]), - ])); - assert.throws( - () => loadIdunnRuntimeAuthorityFromEnvironment(target, contract, "127.0.0.1:17871"), - /exactly one envelope/, - ); + peer.reject = true; + await assert.rejects(publisher.publish("active", "denied presence"), /Odin rejected runtime presence: test admission denied/); + assert.equal(peer.failure, undefined, peer.failure?.message); }); -function cultCacheStore(type: string, schemaId: string, key: string, payload: Uint8Array): Buffer { - return Buffer.from(encode([ - "cultcache.store.v1", - [[schemaId, type, `${schemaId}.v1`, schemaId, "{}", [schemaId], []]], - [[key, schemaId, "2026-01-01T00:00:00Z", payload]], - ])); -} +test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const publisher = createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service", -1), endpoint }); + await publisher.publish("active", "below minimum"); + const capabilities = (decode(peer.received[0]!.document.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.capabilities] as unknown[][]; + assert.equal(capabilities[0]![3], expectedFacts("service").capabilities[0]!.capacity - 1); +}); -function privateKeyFromSeed(seed: Uint8Array): crypto.KeyObject { - return crypto.createPrivateKey({ - key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), Buffer.from(seed)]), - format: "der", - type: "pkcs8", - }); -} - -function rawPublic(key: crypto.KeyObject): Buffer { - const der = key.export({ type: "spki", format: "der" }) as Buffer; - return Buffer.from(der.subarray(der.length - 32)); -} - -function sha256Hex(bytes: Uint8Array): string { - return crypto.createHash("sha256").update(bytes).digest("hex"); -} +test("the Odin publisher takes only the endpoint Expected names", (context) => { + assert.throws( + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service"), endpoint: "rudp://127.0.0.1:1" }), + /does not match Idunn Expected dependency authority/, + ); + assert.throws( + () => createIdunnRuntimePresencePublisher({ signer: signerFor(context, "web"), endpoint: expectedFacts("service").odinEndpoint! }), + /does not match Idunn Expected dependency authority/, + ); +}); From 3c9ce3823ce7039db84947ae3c3fecfd52ba9fa0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:05:12 +0200 Subject: [PATCH 06/12] cultnet-rs fixture example: add a lease-bound stateful Idunn set Adds `service-stateful` (streampixels-service shape, state lineage set, write_lease_required) with a Rust-issued lease.cc, warming/active/degraded vectors bound to it, and two wrong leases (other warming digest, other target). Each vector still passes authenticate + correlate before it is written. Co-Authored-By: Claude Sonnet 5 --- .../examples/idunn_runtime_fixture.rs | 129 ++++++++++++++---- .../test/fixtures/idunn-runtime/README.md | 8 +- 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs index d6b432a6..57364c10 100644 --- a/packages/cultnet-rs/examples/idunn_runtime_fixture.rs +++ b/packages/cultnet-rs/examples/idunn_runtime_fixture.rs @@ -11,6 +11,13 @@ //! disagreement). Ed25519 is deterministic, so the TypeScript signer must //! reproduce each vector byte for byte from the same fields. //! +//! The `service-stateful` set is the lease-bound shape: Expected carries a state +//! lineage and requires a write lease, and Rust issues `lease.cc` naming the +//! warming vector's digest. Its warming vector is sequence 1; the active and +//! degraded vectors are sequence 2 and bind to that lease. Two further leases are +//! deliberately wrong (`lease-other-warming.cc` names a different warming digest, +//! `lease-other-target.cc` names another target) for the TypeScript refusal tests. +//! //! Linux only: the provider identity is bound to `/etc/machine-id`, and the //! generated `machine-id` file records the value the identities were bound to. //! @@ -26,9 +33,10 @@ use cultcache_rs::{ use cultnet_rs::{ GameCultProviderHealthIdentity, GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, GameCultRuntimePresenceHealthRecord, - IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_RUNTIME_ACTIVATION_SCHEMA, - IdunnExpectedCapability, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, - IdunnExpectedRoute, IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, + IDUNN_EXPECTED_INCARNATION_SCHEMA, IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedCapability, IdunnExpectedDependency, + IdunnExpectedIncarnationRecord, IdunnExpectedRoute, IdunnProcessWriteLeaseRecord, + IdunnRuntimeActivationLaunch, IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, authenticate_runtime_presence_claim, correlate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, @@ -49,9 +57,10 @@ struct Set { candidate: &'static str, minimum_capacity: u32, odin_dependency: bool, + stateful: bool, } -const SETS: [Set; 3] = [ +const SETS: [Set; 4] = [ Set { dir: "web", target: "streampixels-web", @@ -60,6 +69,7 @@ const SETS: [Set; 3] = [ candidate: "http://127.0.0.1:18830", minimum_capacity: 1, odin_dependency: false, + stateful: false, }, Set { dir: "service", @@ -69,6 +79,17 @@ const SETS: [Set; 3] = [ candidate: "tcp://127.0.0.1:18831", minimum_capacity: 2, odin_dependency: true, + stateful: false, + }, + Set { + dir: "service-stateful", + target: "streampixels-service", + transport: "tcp", + stable: "tcp://127.0.0.1:8833", + candidate: "tcp://127.0.0.1:18833", + minimum_capacity: 2, + odin_dependency: true, + stateful: true, }, Set { dir: "rudp-route", @@ -78,6 +99,7 @@ const SETS: [Set; 3] = [ candidate: "rudp://127.0.0.1:18832", minimum_capacity: 1, odin_dependency: false, + stateful: false, }, ]; @@ -131,9 +153,9 @@ fn build( expected_signer_identity_id: provider_anchor.identity_id.clone(), health_contract: format!("{}.runtime-health", set.target), artifact_sha256: digest('4'), - state_schema_generation: None, - state_contract_sha256: None, - write_lease_required: false, + state_schema_generation: set.stateful.then(|| "streampixels-state-v1".to_string()), + state_contract_sha256: set.stateful.then(|| digest('7')), + write_lease_required: set.stateful, route: Some(IdunnExpectedRoute { route_id: format!("{}-route", set.target), transport: set.transport.into(), @@ -189,20 +211,13 @@ fn build( let authority = verify_runtime_authority(&expected, &activation, idunn_anchor, &provider_anchor.public_key)?; - let vectors = [ - ("presence-active.bin", "active", set.minimum_capacity, None), - ("presence-warming.bin", "warming", set.minimum_capacity, None), - ( - "presence-active-below-minimum.bin", - "active", - set.minimum_capacity.saturating_sub(1), - Some("expected-capability-000-capacity"), - ), - ]; - for (name, state, capacity, disagreement) in vectors { - if capacity == 0 { - continue; - } + let sign_vector = |name: &str, + state: &str, + capacity: u32, + sequence: u64, + write_lease_sha256: Option, + disagreement: Option<&str>| + -> Result { let mut presence = GameCultRuntimePresenceHealthRecord { schema_version: cultnet_rs::GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), target: expected.target.clone(), @@ -211,8 +226,8 @@ fn build( incarnation_id: expected.incarnation_id.clone(), sealed_release_id: expected.sealed_release_id.clone(), activation_witness_sha256: activation.canonical_sha256()?, - state_schema_generation: None, - state_contract_sha256: None, + state_schema_generation: expected.state_schema_generation.clone(), + state_contract_sha256: expected.state_contract_sha256.clone(), runtime_id: expected.runtime_id.clone(), runtime_instance_id: activation.runtime_instance_id.clone(), bound_endpoint: Some(set.candidate.into()), @@ -225,9 +240,9 @@ fn build( health_contract: expected.health_contract.clone(), state: state.into(), detail: DETAIL.into(), - write_lease_sha256: None, + write_lease_sha256, signer_identity_id: provider_anchor.identity_id.clone(), - publisher_sequence: SEQUENCE, + publisher_sequence: sequence, observed_at_unix_millis: OBSERVED_AT, signature_algorithm: "ed25519".into(), signature: Vec::new(), @@ -250,6 +265,7 @@ fn build( maximum_future_skew_millis: 0, }, )?; + let presence_sha256 = claim.signed_presence_sha256().to_string(); let correlation = correlate_runtime_presence_claim(claim, &authority)?; let codes: Vec<&str> = correlation .disagreements() @@ -261,6 +277,69 @@ fn build( Some(code) => ensure!(codes == [code], "{name}: expected only {code}, got {codes:?}"), } fs::write(dir.join(name), bytes)?; + Ok(presence_sha256) + }; + + if !set.stateful { + sign_vector("presence-active.bin", "active", set.minimum_capacity, SEQUENCE, None, None)?; + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + if set.minimum_capacity > 1 { + sign_vector( + "presence-active-below-minimum.bin", + "active", + set.minimum_capacity - 1, + SEQUENCE, + None, + Some("expected-capability-000-capacity"), + )?; + } + return Ok(()); + } + + // Lease-bound set. The warming vector comes first because the lease names it. + let warming_sha256 = + sign_vector("presence-warming.bin", "warming", set.minimum_capacity, SEQUENCE, None, None)?; + let activation_sha256 = activation.canonical_sha256()?; + let lease_for = |target: &str, warming_presence_sha256: &str| IdunnProcessWriteLeaseRecord { + schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), + target: target.into(), + expected_projection_sha256: activation.expected_projection_sha256.clone(), + plan_id: expected.plan_id.clone(), + incarnation_id: expected.incarnation_id.clone(), + sealed_release_id: expected.sealed_release_id.clone(), + activation_witness_sha256: activation_sha256.clone(), + state_schema_generation: expected.state_schema_generation.clone().unwrap_or_default(), + state_contract_sha256: expected.state_contract_sha256.clone().unwrap_or_default(), + runtime_id: expected.runtime_id.clone(), + runtime_instance_id: activation.runtime_instance_id.clone(), + warming_presence_sha256: warming_presence_sha256.into(), + lease_epoch: 1, + issued_at_unix_millis: OBSERVED_AT - 1_000, + }; + let lease = lease_for(set.target, &warming_sha256); + let other_warming = lease_for(set.target, &digest('d')); + let other_target = lease_for("streampixels-web", &warming_sha256); + for (name, record) in [ + ("lease.cc", &lease), + ("lease-other-warming.cc", &other_warming), + ("lease-other-target.cc", &other_target), + ] { + let bytes = record.canonical_bytes()?; + ensure!( + IdunnProcessWriteLeaseRecord::decode_canonical(&bytes)? == *record, + "{name}: lease does not round-trip" + ); + write_record( + &dir.join(name), + &record.target, + IdunnProcessWriteLeaseRecord::TYPE, + IDUNN_PROCESS_WRITE_LEASE_SCHEMA, + bytes, + )?; + } + let lease_sha256 = lease.canonical_sha256()?; + for (name, state) in [("presence-active.bin", "active"), ("presence-degraded.bin", "degraded")] { + sign_vector(name, state, set.minimum_capacity, SEQUENCE + 1, Some(lease_sha256.clone()), None)?; } Ok(()) } diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md index de1ccea4..dd683a16 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/README.md @@ -8,8 +8,14 @@ provider identity is bound to it. On Yggdrasil: DOCKER_ARGS='--cap-add SYS_ADMIN --security-opt apparmor=unconfined' bash ygg-verify.sh HEAD rust 'cd packages/cultnet-rs && cp /tmp/mid && mount --bind /tmp/mid /etc/machine-id && cargo run -q --example idunn_runtime_fixture /tmp/out' -then copy `/tmp/out` over this directory. Every `presence-*.bin` was accepted by +then copy `/tmp/out` over this directory. `--cap-add SYS_ADMIN` exists only so the bind mount can present the committed `machine-id`, which keeps Yggdrasil's real machine-id out of the repo. Every `presence-*.bin` was accepted by `authenticate_runtime_presence_claim` and correlated by `correlate_runtime_presence_claim` before being written; the `below-minimum` vector correlates to exactly `expected-capability-000-capacity`. Ed25519 is deterministic, so the TypeScript signer must reproduce them byte for byte. + +`service-stateful` is the lease-bound shape (`streampixels-service`, state lineage set, +`write_lease_required: true`). Rust issues `lease.cc`, which names the digest of its +`presence-warming.bin`; `presence-active.bin` and `presence-degraded.bin` are sequence 2 and +carry that lease's digest. `lease-other-warming.cc` names a different warming digest and +`lease-other-target.cc` names another target; the TypeScript lease check must refuse both. From 4ec75eec18976f57a08082cb633f7088cb5a05c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:10:55 +0200 Subject: [PATCH 07/12] cultnet-ts: regenerate Idunn runtime fixtures with the stateful lease-bound set Output of the Rust generator (3c9ce38) on Yggdrasil. Every set is re-enrolled, so all bytes change; adds service-stateful with lease.cc, the two wrong leases and the warming/active/degraded vectors. Co-Authored-By: Claude Sonnet 5 --- .../rudp-route/activation-key.seed | 2 +- .../idunn-runtime/rudp-route/activation.cc | Bin 800 -> 800 bytes .../idunn-runtime/rudp-route/expected.cc | Bin 1076 -> 1076 bytes .../rudp-route/presence-active.bin | Bin 922 -> 922 bytes .../rudp-route/presence-warming.bin | Bin 923 -> 923 bytes .../rudp-route/provider-identity.credential | Bin 688 -> 671 bytes .../service-stateful/activation-key.seed | 1 + .../service-stateful/activation.cc | Bin 0 -> 806 bytes .../service-stateful/expected.cc | Bin 0 -> 1401 bytes .../service-stateful/lease-other-target.cc | Bin 0 -> 1012 bytes .../service-stateful/lease-other-warming.cc | Bin 0 -> 1020 bytes .../idunn-runtime/service-stateful/lease.cc | Bin 0 -> 1020 bytes .../service-stateful/presence-active.bin | Bin 0 -> 1105 bytes .../service-stateful/presence-degraded.bin | Bin 0 -> 1107 bytes .../service-stateful/presence-warming.bin | Bin 0 -> 1034 bytes .../provider-identity.credential | Bin 0 -> 680 bytes .../idunn-runtime/service/activation-key.seed | 2 +- .../idunn-runtime/service/activation.cc | Bin 806 -> 806 bytes .../idunn-runtime/service/expected.cc | Bin 1308 -> 1308 bytes .../service/presence-active-below-minimum.bin | Bin 940 -> 940 bytes .../idunn-runtime/service/presence-active.bin | Bin 940 -> 940 bytes .../service/presence-warming.bin | Bin 941 -> 941 bytes .../service/provider-identity.credential | Bin 673 -> 672 bytes .../idunn-runtime/web/activation-key.seed | 3 +-- .../fixtures/idunn-runtime/web/activation.cc | Bin 798 -> 798 bytes .../fixtures/idunn-runtime/web/expected.cc | Bin 1067 -> 1067 bytes .../idunn-runtime/web/presence-active.bin | Bin 915 -> 915 bytes .../idunn-runtime/web/presence-warming.bin | Bin 916 -> 916 bytes .../web/provider-identity.credential | Bin 676 -> 682 bytes 29 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-degraded.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin create mode 100644 packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed index 1ae250bc..b0f0159c 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation-key.seed @@ -1 +1 @@ -v$ñ´ØÃ/Š ¼Èá|Æuö«g4FÜK%k±€~qû \ No newline at end of file +¢_Î#Ÿ/ "Q0\ûiCâ]\Ê�¦‚†ó;ÛY¨ø„k \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/activation.cc index 3676c985fb95c7105989d04f20473d4644699db5..57e2c86029f18c42beddce4b50be125380e8fcfc 100644 GIT binary patch delta 324 zcmWN|Jt)Ls9Ki9rQv^|5mpaKnxm~PuI=P?ucKtW=nn)z$Mxj{x${OK;AOS-p zFk>~45|F@L<0VHy1?TvR#c2gs}xOzCgbM^c_d7E!G zJGJ4x_UBXT9CA}`aN&9>wft8*3y(){v(?d&mHVFWq1}n&y-I8PB0k+pR38R<{{cce BZk7N5 delta 324 zcmWN}FGxc{9Ki7{xZ)FwD4I;uhYI(*|L5ODK{m7uEyKHecL<7LTN9>fC?Xa`g*9ys z7IYRFyfM_Utid2^wg?u%*S_Jyx0b18>Xq~Y0|1M-6_{$I!8m7#YME6W6DM)!z2V+|X@M+z%VapZ+^vm@FMWSJqn7J7IrMH#_s_k}yVK|0X BZY%%* diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/expected.cc index 040ea0f69b70a5dcd3de806d7bc62e3d2000f294..878c090bc8ed0450e3418b0897d15ba3e4eddb71 100644 GIT binary patch delta 76 zcmWN>u@QhE3mewscsnj8f1fl-d7w=T?U? b$AnO`snc9~T%8D1y9O`-Q!O5HpXvPoI6W3X delta 76 zcmV~$yAgmO3;@uA^Nw*sh>&zP4nlqlxrhaOxq|QQy6bzCD=-F6x9G6g8;Y}bnA!w^ aI8D5FR0zs(8o^u~Z-rDiWw1do6+3P!7Pj?q$lF<0xW%}1qxgdpuQ0;DQT0H zF<^+v+00f-lFDKx^8b<5w(M%%qNpu(CMJvJoWL0AJN07i%b81Kts8@Dd-pG{w(pMQ ziAQc~;Qh~se=mR7IQ!>XV`KP0_ttt_znX9KKD`{<_51tmzVYVA*H^|DzFvJk(5puw z!5QO)1EX!u8WRS}j1oXqSFuyUgLYLR8cr#OOc*L-XFZZJo%+z+p?hyXKOQaCw-3%< z9NM~m{Qi$q&!+E;bY~6^(utmZ`sDm(bI5nL~a`)De0d3JcnEixkwjA1O;K6`#6^tMPOhr$iiq5Sq(u8 zv78`^Aead^f}5CT(Mv3(K&TdhOQRrHh4gRr`xd{&$2Z+I-F0_x*Z@EjHae-j@DP}T zLhF@8LMl*1&VVS`C6banfrhAL@Dgkwr&kW0JLRRZ{q_2RdSgrd{a7su$;4o@lRhys zgs8H?kTrTHNNh7gmYB$^9DFe;>HwM9VE~=xu9P>ICs?vYmm(|SF@dCnK?xUtq|lfn zB~@q$*4dh=bs;Irc~Ycdthm8a>%Z-Zq3RR=nIBA@nOk0c)moh2d1vfRWu<1= zQ#8(KTLj5A1nsg%iv^hf{>l}>G*Pt00YMlSHTYCyjDbYTom$`I)@{%BzwN)j*xUSc zu+_3JdKXqNZ@<|yyy<84?2q>Cj~kWdpPud$zh*|iT(~zo`*QCi{5rU?dpuMc*DhV} F{s*O)pYH$w diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/presence-warming.bin index ad4c0d9f1d15e506031aba710fe9223d3f672f0a..246e99cb2b4f3d875d4b9ab2a7e3c52a0151c603 100644 GIT binary patch delta 442 zcmWlVOGuSb6ou~}TpcuUkdXyaLc@e}_Id4t6h8@O1U)7Of#liyoKum5N)SR!ND`gI zy--9r5qby_7!?K8$nK;FDuPI87Bz@QH^Lh9_?xWJf^U6mv2(GrF}R;{vL4V%QsAOc zAiYLsm6sVv37ZXC(@Y>6LSDC)%wD+MG3X_pWnR$(G&)rd27scv8BM@+ zTFF9!GsX)CM%$D$MhuiPN&r<|m7NM6w5tkia*P}jVW^Cq^+?9F%EtHlryINbwyvIj zIDLEa<NHt8uZ|OpZ9qYF|dC_4GIpo)Zn2l zx*RTP&{PnK2*QDR22FuNQA0yRoJ2?k4gH$FpW!=v_^b@93@p^I7yuw)$w?hVfXEz` zw?Rpd9!nI&8N3(l5=w~yK?5lng9IDV>4VGnCWAC~q}{&Q?o`|F?$$_1Mh2Um42hW` zlFG)%tbJgF#5Q}#(kBcm$52ip9U(J2A3>*=vqiO-W69RLM65*c2_z;Tm2eSI3hgst zQl*Aqovk4)TwvBQCq&dDlceH(rBZad*QYDbI7wpwyR}ERf1l^x$rm!7pw9f^*$eCY z%f}-Q4V~)^|5=^cSsY&5d(ynqYOGGp-Q1WO9DV%dQ)8$3c31AI=I4iv;-F?YAQ@-0 zErVnmqjouX>q{{I{go?&X+*N+!Gkc4G=x-Uq=+KrZf$Ytet+hO&ir&w)wS`zSpT*) zzkDOV9y>Mu^~1>I#OT=7V;g&eM@y$)ZMOKq_mBO4uYPOc`K8vI?fuzbKdS!#kwBg{ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/rudp-route/provider-identity.credential index 8e1849bed2cbbf500423c8ae70cbb54bb1d8aff1..d8026bb447ee01203b4b6a3a9e1cf3ef1984f771 100644 GIT binary patch delta 285 zcmZ9EJxBs!9EVk~MM5a3poT&lM3n#gzmNA$#A*s7qQM-R?%unLS{rM)p$HNJ!nYXIbP2@;x$%XI!l&ujdQSac8@r{k^cLRAE6X^pCg<@LA1)IflS2;bxEn`p6Q4!Y U+*XQ-%^cY|wk!01+1QR5e|nN~TmS$7 delta 323 zcmZw9JxIe)7=>Z26dfHMD{U8>)Gs%;X>Jz<2mf&EEHueYMeQIc6hx{nI*5M(2f>PN z4$@AFh=b^O1lvJ~=wfkk5mXAgXu3O|7tV7Su79ih&BZ!VN9`Gb;4!%RE2)G22= z8WWCb>6ErK!HAo#EgW5E7D?_JDVtL|CndLBM+m{mwVD0Y7EvbqCt_GdgHPi$K?!{0 z3!7LT!N&l$;04=bI6`F@SGX^t7JN`atvjmZu|BjE$DN8ZRMG4~b9`p1h!fo66ZJ9N z;2Jgj;Jg=Qudn^NL>c0ll#$d8&E!loC_zD@mbifn)Ukqc9D8_$AL+s|4hnb)>JD~s sX`z8N{ATgysH0Il;sNh|lo2yj5-Jnf7WJR*EBRbO+6%Mu-eM^B2gKHq3jhEB diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed new file mode 100644 index 00000000..491dbddb --- /dev/null +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation-key.seed @@ -0,0 +1 @@ +KýaŸz´-2‰Ë¡V>)ë»xìºÚÏ®¢T =/‰* \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/activation.cc new file mode 100644 index 0000000000000000000000000000000000000000..3d670ab64a880f82aa8a23f590cbf40a670e3be8 GIT binary patch literal 806 zcmbu5J!n%=6vq=090VO46kH@Kh?sEieeZr{$|hY(1(ncJ&UiStxD2vWl;hG!`geKn=-!JDGN2k*nMJOwW90E*3W4Ut%fMBbQ3mkV`b4<9Af@;JG z4}oJGf(#Ht99xDcRfhqn{qxv| z;p(^NmxqNy@#$cqJ~ygqzb_JqYeYHGMsgqFh*iKu5Mt063j!8Qz?juogh4>D#MqzB zLsDa2fF;y_4Qe;17q@SX6{9EphmGCK2N&+VTG_m@b-P}xcZTEpulBaS9A2B^h3n7Q b+R<~H!$$|soY?++?|Z|1-9Gtd_;K$qI`kv; literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/expected.cc new file mode 100644 index 0000000000000000000000000000000000000000..8e96b29b011a97ffb9d2abad505584f2de0ea0ce GIT binary patch literal 1401 zcmbtU&5G1O5LWTx)hAfu#e<#c&R=J85Aw9UOK*@VM&( zi0&-QieAKr2>3Ae%u3vm%qZkb=TbjkRabS@(I?*Inlqk(U2CbpZpr-JSCKSjX~RJU zTto6wl$@!OX(3CybuPARyS?o?<7vouQ+MTSt2Vh5>>(?_imaV<*1g}xPeIitw}082 z@W!QpA~yC`^!&w}`R;Vy&KIY(R=|o%96(;98dNJd?Djf$uoECm%z1)wv_3Y*_}Pz} zw|-u`z1w*(UbkOo%<%(6MxwKZt~0sPUqt#~&Pa57GoiC4wz(`O`=*}{%u|~@Uf1){ zx}M!#_MhmJa!2CZPyx{4Y?dfii@dv^`W#QW8w50T!Q(XHNytObY}J%n6o4{dxz76Qcq6gg45pa|)c$E99f|LYi_HL+ z?;k1IXgJZlIyHYZnGlEC*nA`kL)TsYEhH=Hd~rU+WwT0r*`_cPd)$&6^Y=?L016T$ z%GC5ptA^`F!BQr|Y$P+yfE1JoylQ1rpIwn^S;<_^4ljwWe~ckDOAb{^7IL-DKCEm% a7p!EnHG9A3`VRRcai*GNvqT;ZDftC?QqFb& literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-target.cc new file mode 100644 index 0000000000000000000000000000000000000000..9f99abd088dd3391e03cdc3212121cea2fc80037 GIT binary patch literal 1012 zcmbtQ%Ze015Zv`EJoniiGwazkKj2L~hzCWMtg5V?hVGuFt9Mor|G`1QoA?PL2rGzQ zcF&&YZ)_j546^NEg1BWyWMsVl7|xp^P+0LI59^*68{EGAQpfYITb%Xlz&W2>^!30e z4O9L({(R%`+l`Y^HgeW>*Ssk57^9{8)N-*b=FEyae|8^npX+t^pY`$fsA#$M<9e}t z^7Q3)Hp{E+n-6*DncB1Z88^ASU_UFk6d+6qIPxLD@)s8X@c8=H_T8-VpF6vIc|}5- zQdyR$RfsmFz$`HOkYuvSdW*saS7{GcSj84mv6EoHVl<#Nk#=G-!UM4@_PWU2Z|cBz z>n>2=(Jq&R`~tPOit5ra#Wbe1&+f)o{10ZfWAk&DKA6pYeX z{t=ex9Ei!t8%$P+=%WKeF4zi9!pcQps(^(g gQE5=5v_VBAiINC{jnOia1~~qHbaedw>cQ&v4|jw^VE_OC literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease-other-warming.cc new file mode 100644 index 0000000000000000000000000000000000000000..cc21e54c91719e55e58befe66fc9b3e5e93c2f2a GIT binary patch literal 1020 zcmbtS%SyvQ6m9(q*IAo*XOhe${Q-C4LR=_P=5ePDBx%W{RT2NeqT){c22oHD-RRnN z|Hk+rQfT8Mhr2uXJmzt6rzUBsgc_n}%Ta;el+3Q56FtduZ(NKNTH8M=k`ntV3j0Mr ztt4MpGL@{FM%#IM>e#AACbmU}PS0sFb2ff=_pq?ZDF0`@%B~a{X0lp$dIyJRv&Jsx zv&(y17AUfDa*U~UEf&*6VWa4g0%b0cYIXs7-{S$m{`=b1O`~#Y)_ltj1r3=SaP&zS zD9w#gh?HoljBmK%k`u~R9EB1%uMhC+lft&K=a%$;R-vYF&c6uBs? zXK+a!Mq8pb0xBi=sEi5F0GAjtlyM{wViWl>$B-xiMh7BNz>M~dh&7XvP$WL%_^?>I r6eYUTzn;7$UKZ_t{)u}s80bRSB(0CZTB0`6Ez$kxbh@{1JHz!)1eH#i literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/lease.cc new file mode 100644 index 0000000000000000000000000000000000000000..b387f6e67d284a14f33e3cc85be640bccb5a74c3 GIT binary patch literal 1020 zcmbtS%W4!s6ixgJ*S)qU_tvv(ejqz>AubdNx2kSs8oGNzS7%hje{fK7Cw_w{D2Q%! z?Rx&kRzpi5=`7}Ocjul*JzjkX=gkl(tay=!b~z3y&UAG1eA%dH>Ri{+!o zFSfH?UT$B1%tO!Ap4Cse$z|q#QwN?EeHI{02{_JufaTv=0Kmhax8A&)RbH93JI*T- z+LX$&M6E)!DFtSM(T60HP1ai!Hn>WAu)->~h>D#A0~VtJt%|}`2NSe)$QNC+eXs> literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-active.bin new file mode 100644 index 0000000000000000000000000000000000000000..b1c33139c5ba7d8e8cb1d71bd3e64614386b23b7 GIT binary patch literal 1105 zcmbtTPe_zO990hm5;q^oB7RnsEq=#E}e^z`SY9Ig>{wP&$YnJ=+Lc5 zJb4li1)jo^f*>l)o7%yMg)#^tLV~)aVMw1ETY{bT4a{L?c<;@;H}4wSs_j|Ri*BG0 zr%MCHINyss{Ut18(P2*xO(D*uE9_A@E}`k|&tJhpIaS6|CGW7>Rn8eEWGVvWR7B20 z2o8}^Z(ZO40$897oL0gTU=Sft8lmPy5};lZA&e24)re@Fs2<(TXGgy1OsQz%d|xp| zYhtWU)QAYPmT+_-L>LHI6cVjg28j-eYd}GrA;CvRIUDd!8dYsfk=6TAM-j*cYoG)V^Sq)KsqckIb-`f1ajfBL_T zbcIe%kh1*`3)zJcW3`s`*ox1#RcUU4~7C}1&*|3n|Ga;T{1 z$z*~8F2FkGgaK|ji%$a%W+&ab@ao^S6zUA3iTkA74sMrJH|}8>54-=jIQIYhA;W-$$Bn z?yY8o5nF{*5}1ZagYqE+k33>p5u>dRm>8yLHrvU9mD+d#AZ7ADiE*=;p|Q1tui9Yc z&G^RtUoXy|+_QMr4Zgp9W^HtOJv0AlspZn@!(+>>!{cl#5TTPYkKgPr?Ti1eYepU-Iz|W< z^uh?CD0&qDp4Ul@N5klsyBva=Z*-HEW@MyqwX5@pvGBVis5@ zyte=ZJ7Ycd!V4oX5mrfNkRT!efpGweB8~)h95JRfQ=~zJn?${9GoQ(9!RoM}E7@Wp zjvHd6Ni>KMF$Phv-Ur~3Fu*t%B{d-BB~=0#wiMXu6^+ z5U>9ADVoIdjr8l;#mCZV7wU30*SH3660;Rs>P;r2z!NAMxED)F>BQvI&GZwxlzsNU zjYJjSsp7VJY|{gOS{h$3=RF7MP&;t-SXdm1Uan|X>$w(o@A`_(=v)p8Y4|66Z=?l@ z9gQaKrJzh0ODxlZ8cG6S3}fP=n2>Nnc&Zo}EV+@?SmPwFr#!PrS<1PP6ap)pb<)Zp zu%5^$aRNqa!MMlYlKcD4 z?MMu^*HcWhwaQ5@h!TMk(s}P4(10k3v@*&=WEg{9?I-buEA5yNoJT(tX)CSl!=Zr_`v$HJ&F>xE O*)cu3>&L-~!M5LP#AciT literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/presence-warming.bin new file mode 100644 index 0000000000000000000000000000000000000000..9634ec21965c0a46f18c63457cbc5d5a20ec5512 GIT binary patch literal 1034 zcmbtTJ!n%=7)_j{6vRytiX?;6OYXhz-M=hmQK9}-I@s2s+c2)Z~( zJJizRAZoQ}9n_&%D2O5$TognqVn7h>BseJQq7J65L<)7v`L=sM&Ue0h&V85I)k+ss z8BDDdXQEmd2W1$oL{Np$z-SRvDK2K}iA|IZ)>HuZ1|f}XCs^o<0>i#VWE|d#%k?{8`Ep)T=zP+abk+9RD!So4t~?y`K8%?J1h_^?8n bzppnY_uuBPZ+-7sSs2{&13f!-ZKU@PdMZ+@ literal 0 HcmV?d00001 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/service-stateful/provider-identity.credential new file mode 100644 index 0000000000000000000000000000000000000000..3429906f256d59afca8712a91eb1360581602e80 GIT binary patch literal 680 zcmb7?yK59t6vo%i!XkxY5d@!w##!#2$9+ttTf|z_Mu@|mJNNDmOlBlIJ6Z8hkf0%I zkwP(QA*h9wWtv#XR|ysqjUZ?WwGaeby%M(+OmjGg^L>YNew^hy?UBBkH+odm-qnSw z$uqOy4U8)4f#>qF&g*OK>4~H}kt92kWFm2myiv93Hgsdpuhrc-+A}uNMCyPsE&>y3 zp6N_z5r*3O!bH?JsZcy(Tq!1HN=cN4RuQgwC;~w(f8RNbWh@=X3p}2}`boUR{R?wF zZ1-pIqhwhkT& z@W8dUuz6}9wy{#W+wAA`qTFN7_*3j0g zhDlxyYgH7g&Z}}~)3h3uS)z-oG%9tQr)p^2A*Ma%6Q6lRdcM3!XiP~=f^H}T3ArTU z3~@F2!}lv5jj)Pm4Q}9dCqF+shbNc!Ps�Õ8ÑÇ£rÆyÜà�&—ž@‚â± ç�ÜÌqM}‰ \ No newline at end of file +ö�„Bf®Õ=?ÞÀ�Z¹jÇâÏ&ŠÜæ3¦õÜŸf…mJ \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/activation.cc index b633e64c21e43688fba5f0946933b20b0da03d1c..3fa6a1f3f4885dd988a9625af676acb4509d69ff 100644 GIT binary patch delta 325 zcmWN~zbk}c902fp{kXqCY+?|@rRVuRzrN2vH_2eQ(W35sp7;9>N=aRv=}P%ANr=U8 zhC4qdw@68jUa}bsSPlcppeu?=Zv8BuYPy;}S`4xdEQ__aC@lwXKsyx~h)^>Xq7dLU zmyUSMypjYy^c);Wt&IT5Z7epfg6WbZO@QP*a*tMfO9akijLdQ;1tYYI2}KNqlK>z> z2BZZu=v}Nsk@THRH0%3a+xO4qyPn2o=R@Uupxb6XYr~Do*?8u4<~EfI%1Lgr_?UH) z3`1rdQ>L|8!-|H*5QU^sNKTG(1d7NDts}=#3Zb~=+{8wqP~PuR1gb?z_HyBE<@$p< z4wjw{Ywd&YatDv+wx;TZw)w)y+StqQ_h~x!l}}2|zx8~mYZtxC{Wm{DtFxoU!Y+lu EKdLKpKmY&$ delta 325 zcmWlTze|Ea9EWug9HeloDZ#Pi?%w;}dj|~?($Ex$R8!oK?+p$SM&J_B;A%rO1`VPa z4$%}SEg8~KER5V#VQ84AKEqCBDmrKM+<;mn{ zgwkPD?1^EzDrrDv0Fm4qV3zsF>O3{l2#QQwi-G49xg&-f)PDnz5;ok(sfHxsj=*nQ=;5vT<^%k&%f>no**;xq-2X msY$A_QA%=Ra&n@DfpLX6kT1(&V+K&A>aB;4Dk@`XOU2#(86ew zAGs(hN(gJ=GKi!?=(P&VhyoXi&>~vsI*_t&cQyyknQfbGn;aMl;6&EiS#lECfDS4e zh*k^bEh4a%6q3w>6(wNL5(1Fc27#o@xODXTWe-XpD2m=yKXRTAil@}?3Qqr~R{k2_hY207gw|pYh)_-(1 z*1FG5F5a8|;I7_jPN)m*6CaD!k&_);xA%Q z(g|#0wvw&W>{5cjHe2CjAS{=h5~z}Z@)rQm3rHG;u^h7Y*s2e0oW68%?ndorWBK>m z;hELv?{Iy}!GnM24&83P?qB)V*u5|~x99G|mCwgI2G49X&)+&Tm~p2hKNQ%^qi Gw*LpWt(hhO delta 444 zcmWlUJ808U6o(U9=pa4@2OoQvBHY)x_nbu|Qmu4Qs%wS&I2T{IC^k!pLs0~wMzJ^u zx`>L!K?zRLf&mRg+6o0D5)4@CP!ybe7XSX%?*|{>{LuW+^@E3Ri88Zt7DxfB? zEYVsY*+f>-11ph2445<8g0O+Cm_b2+?xFK1y#l+ZUf)}PTRp%+r-WKL3C=_ifjsz# z=mmKVkdlXFNfizkIAswM7eD36qq9org@qUO^la$?Cj}s>RhhZ^^Hpc9apuUzt-nv! zx4f^8O+0;heBjZ?xz96S+p9O$?k|tL+j)6Y?@yz)GQ8vU!{)Q;^~S}!dH$TAuCmoU;_!t%m_tBR?+xS;P8Jk7!VqqMh1wPQf^gfwAY`V8-KpBbg8{G zS)01@ZG5n|^>P1N|4sGw%J&OXAG(vxQ@c9N(zcP)i~D9j4eUNQoKJKczb1}###Y;d F{{X|HrojLJ diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/service/presence-active.bin index 1f13b519925596e9b77619edbbd991d2321f5b30..d28b1349f8a9ebaa5d5536d8b5407273e36d7bde 100644 GIT binary patch delta 444 zcmWNMO-of#6hQ9_%OEI(Y9M8S?+v1obN1fnYw+ShVXrT0+Q`n?_Z%ezgCZ0M(g#6M z)Fp@rtw%%-R0wL&D2-mu!W={xL`d`l9GM{Dovq1&wPrhKJ0JI-3E)K1+F5cE*nkcy zH;`K`l(&e$T2hEI304$=Jxd5cS{nqCF5$}Q>q8!tK3)_jim#(5jppDfNrYrAdlMx| z3o)Y0a}4BS08GfjVhne;-xr4_tFKJM``M{nq^LdS7kz)l_}##>AcJZ5P+@SF^Y0{JX0=J6d1Pep<>_ z8WeQ|8=0+S>omID9J*VzIug!>~9S^JD z_YW*@KKnU3^LpRL!NK|e)!n!H%=RDGCJ%q^zSnhN#TNa2bM;?@MkM%1_6`|dLsCIliP5v@cE!&~kn30g>+6d5Lk*hTUy zVqppqi3q90CLlTk!=T{c0wO^REyczns4#K){o3=v;j9d-3@nbFvVzbUlQLx%3REEN z09g{Ek8C0<>4B9D`ghWxNHFxF$#^w zIU&1zRf;5{B&$_6%QRB zslM2BvD@C*xZXe5|NCkD#_~jMaOS~<^`CD(ylie6i6`do_WEC!?$7O7*qZKqJ~mld ztCw9YoM6Xb97+`=U~s}^W`Z&!t7v>Eari$O3b`+k3kbF#8&r#0nfx%$5M`#vk&SQ%SdDN%|Y zTu_oMcH^X^6gpN;?MjlXaj_dVB_ug=q5STio2Q_m2_1WX=oBd}d zS0~2Dw*3Bbvo~<=LUnQ5=2sd#|L@7*)Q(Rt7F6!N?%WzXG19*BunXUpc0QUqT3u_% zq!Zf2Yz14V*`?){p3HS=SA`or_; EfBcV{pa1{> delta 444 zcmWlUPe_zO7>9Q|D6t)+I(eu=2P=tY-kEpi9TZsztPX*YP*DW)=ba)X3K24b1?#4e zA0CymMW`Sz9rTn4t#lBn2&p9s=@0~UD5R)E^!2+wKX`Z+I~O~r2Txi-Mr)liWfp)C zNI5ht$rvA5M+WJUL8K4^-x?aJJmdt3(zWV4E-ZCC-v4N0;r@?_ z=U3j&f4OOcM>^ZosvzY`{t#ogrwe}2#Jd7gC0CMBsGEK;W{ z*-!?9D;s67u;^pZmAWudEG(2ilgDg)53l!raxYoQCqB0d##-R3D3m^95jTk}E^IPg ziwBY7DkKsjB5B)<3sZ!g0wtA_4&%P#QVb09UhBr&Xc{Lad`#eA4ma4w8NOHW(~H~r z;Sr;C5WCnx4ePkZrHSLzpDCj@iLbVvrDi3uF@ySSS0R@vqS29F&7+PB+@XR;5AP|w zR{IhUf#DgSuAYcs7ml5YUN3ZCNkkY0AOoI9_mC;<}FK#de%a4M#1w W8jBhkVq|5RnAZ9F&=z9jQ4gIcUXAOS`Ru zQrbN_BXW{*dt9Wp4L2ukZsch8==l75`qdMSL}f|)tuC7-Y6-L8NgfE(42fbY6gH=_ z$ce?7Qc~KcU1UDzerTD#FQgU9P%>2@mI#<9-nvGTSu`el*5}dcAH^Qd3~vDKxs{m! z`@`|R!sY5tZWH&o7>nOH$!_)HLyugP*O0!M#WU(Sz&Bpe9KD01MmWxy2Bl(>TgI5gu@j$5aZZ XN!;aIX-X(Jh%%^hJH&sr7Im{f0hn|8 diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed index 1f8ff352..7b6d37ff 100644 --- a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed +++ b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation-key.seed @@ -1,2 +1 @@ -c$…Jp[vcϽ|½Ô@ ž×í†`JÛûd -Í_z3Í3¢ \ No newline at end of file +¯ÂOd×f¯l*oµ‘ÀFªv‰n�hLûÑpšS´p \ No newline at end of file diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/activation.cc index 00c3c07fe4835b043b3eb018a11aa82dc52faabf..3931baf2d0ad2e084e2704913f368f46605cef11 100644 GIT binary patch delta 325 zcmWN~zbnLH9Kdmx>no@LtU~+2E=M0`~x;u@9DL?%b9Yfx{}H}<{h)1qtp_D0kmjC)ZT0Gh8yNVa)T-g zXO$-FlE_Ii6hvHWrQ`N(DlS_LF(ybFI3a3>DhWn`4vGQ;E~G#UsH`U7T47X-aT8HS zX>>hZ>zt|%^fhg{$Y=Z=Fc}Mw5*EX4bba2RU*FG1UA`29>Cz>|aG#%7%vWa;16?VDn=i$s z7_eE@cc#>lB0m@93>Jf~eE)#gZ$Y1WGI0MXPn^30)P#)#atR;loiH12@(^~ z&IpW|p;T$Cz)SDF`&I2Mjlb3A zRx%@7mkYPwg~liucwMixm+#vjx%X%%`#jB#8kwG<=3-Ykx%P7SG?ncb9$f9a-uo^0 F{sSiua76$B diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/expected.cc index 748cc83dc11de7479ac6e80fcb4d8d49f79eb59a..dbf7153b5b59ff258a410787f85a7f7695945346 100644 GIT binary patch delta 76 zcmV~$!4-fY2msL6^Wr#(fN-l(0A-=Z@X}mO(f_;d`X1%-IoU>t7%LKElO}0ukbw~k ZbU{mX!tTSH5Q6wnm delta 76 zcmWN{u@S%^2mrv#**Fdg3UtXRfJ~GzY%*7-NWT7EcYTg>+GrvkX`KZPAf}Gw6i;G; Z=wT*EBbu^n$Wol}Pc)w1(fPj0`vEWg6-odA diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-active.bin index cefcf1d0f05aedf619ed6d6bb5b67de23d286e83..37edbf6569b2e60ee4819cef5745c6445523a4aa 100644 GIT binary patch delta 441 zcmWlUU1-f=9DvU$b2PhAa>0d0(^?(9zxVfhKYmj>x!_P5xL8VVbXG3#Up?1P&(o^3DmQlR4I&2NBcWyu$pL!w zDRYbr(UB7oG&!_c1#j8-ATt>)P#Htamh-}%lOs+lU2Qf8o9)%jx$&CdY^-obqa}l= zh@`?0fdGYGk$`ZH0kxAR7*r7;I0`Of7F#m zmbW~3`Qpcp?!^;FPIcDozxDX*+kFGQM+cu?Z}fE<&)=PsyT=Z4#i&6!W|y=US>&#OBV(;qM0{`+&o)uoMn^V5sls@vxWs(0(h>O+&)Cbu43S{|-! G>iQ4Da+wtX delta 441 zcmWNNO^D4=7{>oMG8UtdYCS{ha^q#MLg=tobd!yN@$#s)V zsf)%{YRbY`DRG<9j7W_d5lW4f?3lX6**`tc^ZT_2+5Z^k9+bDWcU4vE^v2Iu-4sD&9A|^ZvWxT?mZ)`@1L%|X?$o7Hiy@SEBQm7c(k^2>ErcA!@pX;QV%*R2T`X` zWT6o$YimiNL0JqHZN-*x0z@HH!bB-hFze(M7yN|1`qc8m^Pl_o9zQqp^~~LTc@8%B ztJiODT>M1e`-jH9Eg$L3zMP)lbyn>A{9@tiwjHCV?mf7D=j4{de=bbv`K_C(|AX_J ALI3~& diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/presence-warming.bin index 375131d00f282c71e7b67679f28c03c0bc167f71..55409b8dfb48737e1978b3c92261076c18e2bded 100644 GIT binary patch delta 441 zcmWlUOGuPa7(nMI%7Qj7q8xBxMnM$a?|%33eNi^JsUV^UXj#GBCxK~5k5weIQ&4bf zU{--3;@TG>(rzH=fr}z=XA3t$n_5f#!dV~AX?3-_<_50^5rgnipzv$95@wpK9{=k2S{9y_Vc`DXKCbN*2C^F&Q>wj`W2NXjB=rKH9X zK>-T0F9G2k0~#l7u&5(Ia4PuV60v~1@Tw>#eT>H0K;#T7Z4?ktcI;#hDug6CNyzNA zLSG6=D@oq6jHx^kWp1%msTA{_TTd!WDN3z@JGF_S&G*z;{We4Ob+xp7VfuID+OIRm zcVDjc9oYHV+c^E{#}QxUQ@i6mlY1lg#)iJHjohw3UA=T}WAE?t%O{R**F2`2C0d64 zH4aEJIRZ{(lYlZ*qX#6<78Da^5<b&NEl;#_qRzvuTnkJYjqqd%0HNHgWU zoXzN>kvT~>I~SWsBPY8^%Iza39G6D3TIAAWtr(`_;(nvCgo<11DIypvkTKFuMH8)$(nv-? zh*n5N+!>$$1}8MjNr6Ut5Z*LC7e!-pc==}0j{%h8ZdmQyo|?F^`s3^K-S_Q-m3PmY z3)7d5Jb_~5ajRJQIk~a^X6@hAZ2!OXXj!-`Z8 z7J`bDWl7CqQpPwTwdC1Q)|PE)%YbDFW|u-Yp7%~Py>=F0RVy7i@= znLl{B`f%>w;!!%*+1eRwr+f3A-;>8@KRmcz^?r3fjg`$ccP;F1zi$7nua1qD{{i1~ BmC67B diff --git a/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential b/packages/cultnet-ts/test/fixtures/idunn-runtime/web/provider-identity.credential index 52b1b8e809059949d9b7af89dab19973f0b1e529..2e617139cb114b951bfdc3fca179a02ff872830c 100644 GIT binary patch delta 297 zcmZXOyGjE=07cD8w6d^Ku@WC((CEzU?Ci{bf?yGdAx$9rEG#5JB$7rY(Z*&a3U-1T zBtoh{&`L|u3pNW9P;4x11i?ZFr zt!x&!+;x4Xd`1K17+iNuhf0Gp>6nUnKGy}2f$`ioF^M`J(MsW`i1t!BgU;w`4)-Hy z;s)3Ft|A=6U1fL?Ux+f*crjhVAujN?f|;vzoMC%bSRDHCb6!7U(}Wnq2u6qego}(9F;fmfKR-mSHW)l)f$x2$8Z+F+H=lAqG51a;$QtiG*v9RM;bG5iIHO3t23TiW5 z5F#yZy0*(4*RTyDsT6|ghAA~}J6y6wu3N-2bzxf$)gAk-Z3rJY8z-nHaDhYQQ4H>S z2&nc@EX|aom4uyA{maa z3GAUfQ&4>GIT9mA%(PUEX(rXZ(+cqrX~j+!rsi;m12k|mk47JU@I08o6ZY|f>&VCw tnn44uOLtR^6%@*?h&>uD;gJS>y From 8072314b7c47c20f594d7585250296949299904e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:11:48 +0200 Subject: [PATCH 08/12] cultnet-ts: the Idunn signer owns the reported health and warming proofs The signer holds the last reported health, starting at warming, and gains reportHealth(state). sign(detail), answerRouteObservation(request) and the Odin publisher's publish(detail) no longer take a state, so the route answer and the Odin publish cannot disagree and a fresh signer cannot answer active. Every warming presence the signer signs becomes a valid write-lease target; the publisher's recordPublishedWarming is deleted. Tests now assert every committed stateless vector, dead ones included. Co-Authored-By: Claude Sonnet 5 --- .../src/idunn-odin-presence-publisher.ts | 11 +- .../cultnet-ts/src/idunn-runtime-authority.ts | 42 +++++--- .../test/idunn-runtime-authority.test.ts | 100 ++++++++++++------ 3 files changed, 100 insertions(+), 53 deletions(-) diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 8e13acb4..2e992658 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -5,7 +5,6 @@ import { encodeCultNetMessageForWire, parseCultNetMessage } from "./contracts"; import type { IdunnRuntimeSigner } from "./idunn-runtime-authority"; import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "./rudp"; import type { CultNetRudpPacket } from "./rudp"; -import type { RuntimePresenceHealth } from "./runtime-presence-health"; const ODIN_CAPABILITY = "odin.verse-rendezvous"; const MAX_LEASE_WAIT_ROUNDS = 720; @@ -14,7 +13,8 @@ export type IdunnRuntimePresencePublisher = { readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - publish(state: RuntimePresenceHealth["state"], detail: string): Promise; + /** Publishes the signer's presence in the health the signer reports. */ + publish(detail: string): Promise; waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; assertWriteLease(): Promise; }; @@ -44,15 +44,14 @@ export function createIdunnRuntimePresencePublisher( const connectionId = 0x0d1d0002; let messageSequence = 0; - const publish = async (state: RuntimePresenceHealth["state"], detail: string) => { - const signed = signer.sign(state, detail); + const publish = async (detail: string) => { + const signed = signer.sign(detail); messageSequence += 1; await publishDocument(endpoint, connectionId, { schemaVersion: "cultnet.document_put_raw.v0", messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, document: signed.document, }); - if (state === "warming") signer.recordPublishedWarming(signed.canonicalSha256); return signed.canonicalSha256; }; @@ -65,7 +64,7 @@ export function createIdunnRuntimePresencePublisher( if (!signer.requiresWriteLease) return ""; let warmingSequence = 0; while (!signal?.aborted) { - await publish("warming", "waiting-for-process-write-lease"); + await publish("waiting-for-process-write-lease"); warmingSequence += 1; try { return signer.assertWriteLease(); diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index 47e132b3..bb217ed0 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -82,25 +82,30 @@ export type SignedRuntimePresence = { /** * Signs this launch's runtime presence and answers Idunn's route challenge. It * owns no network: Odin publication, if a service wants it, is a separate - * consumer of `sign`. + * consumer of `sign`. The signer is the single owner of the reported health: + * it starts `warming`, the app moves it with `reportHealth`, and every + * presence it signs, however it is carried, states that health. */ export type IdunnRuntimeSigner = { readonly authority: IdunnRuntimeAuthority; readonly runtimeId: string; readonly runtimeInstanceId: string; readonly requiresWriteLease: boolean; - /** Signs one presence document. Every call takes the next publisher sequence. */ - sign(state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence; + /** Records the app's health. Until the first call the signer reports `warming`. */ + reportHealth(state: RuntimePresenceHealth["state"]): void; + /** + * Signs one presence document in the reported health. Every call takes the + * next publisher sequence; every warming presence signed becomes a valid + * write-lease target. + */ + sign(detail: string): SignedRuntimePresence; /** * Answers an Idunn route challenge from local authority alone: signs a fresh - * presence in the given state, with no network. The caller supplies the - * app's current health (`warming` until first healthy). + * presence in the reported health, with no network. */ - answerRouteObservation(request: CultNetSnapshotRequestMessage, state: RuntimePresenceHealth["state"]): CultNetRawDocumentRecord; - /** Reads Idunn's write lease and checks it names this incarnation and a warming proof this process published. */ + answerRouteObservation(request: CultNetSnapshotRequestMessage): CultNetRawDocumentRecord; + /** Reads Idunn's write lease and checks it names this incarnation and a warming presence this signer signed. */ assertWriteLease(): string; - /** Marks a warming presence as accepted by its consumer, making it a valid lease target. */ - recordPublishedWarming(canonicalSha256: string): void; }; export type IdunnRuntimeSignerOptions = { @@ -219,6 +224,7 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id const { authority } = options; const expected = authority.expected; let sequence = 0; + let health: RuntimePresenceHealth["state"] = "warming"; const recentWarmingProofs: string[] = []; const assertWriteLease = () => { @@ -231,7 +237,8 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id return prefixedSha256(Buffer.from(encode(lease))); }; - const sign = (state: RuntimePresenceHealth["state"], detail: string): SignedRuntimePresence => { + const sign = (detail: string): SignedRuntimePresence => { + const state = health; const writeLeaseSha256 = state === "warming" || !expected.writeLeaseRequired ? null : assertWriteLease(); sequence += 1; const signed = signIdunnRuntimePresence(authority, { @@ -242,6 +249,10 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id publisherSequence: sequence, observedAtUnixMillis: Date.now(), }); + if (state === "warming") { + recentWarmingProofs.push(signed.canonicalSha256); + if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); + } return { canonicalSha256: signed.canonicalSha256, document: { @@ -262,8 +273,11 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id runtimeId: expected.runtimeId, runtimeInstanceId: authority.activation.runtimeInstanceId, requiresWriteLease: expected.writeLeaseRequired, + reportHealth(state) { + health = state; + }, sign, - answerRouteObservation(request, state) { + answerRouteObservation(request) { if (!request.messageId || request.messageId.trim() !== request.messageId) throw new Error("Idunn route observation requires a message id."); if ((request.schemaIds && !request.schemaIds.includes(GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA)) || (request.recordKeys && !request.recordKeys.includes(expected.target)) @@ -271,13 +285,9 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id || request.shardEpoch !== undefined) { throw new Error("Idunn route observation does not request this runtime presence document."); } - return sign(state, `route-observation:${request.messageId}`).document; + return sign(`route-observation:${request.messageId}`).document; }, assertWriteLease, - recordPublishedWarming(canonicalSha256) { - recentWarmingProofs.push(canonicalSha256); - if (recentWarmingProofs.length > MAX_RECENT_WARMING_PROOFS) recentWarmingProofs.shift(); - }, }; } diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 1639b9aa..1df6e98f 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -25,7 +25,7 @@ const CHALLENGE: CultNetSnapshotRequestMessage = { messageId: "route-challenge-1", }; -type FixtureSet = "web" | "service" | "rudp-route"; +type FixtureSet = "web" | "service" | "rudp-route" | "service-stateful"; function fixtureFile(set: FixtureSet, name: string): Buffer { return fs.readFileSync(path.join(FIXTURES, set, name)); @@ -50,7 +50,11 @@ function expectedFacts(set: FixtureSet) { } /** Points the process at one Rust-written bundle and its inherited descriptors, as Idunn's systemd unit would. */ -function openAuthority(context: TestContext, set: FixtureSet, options: { machineId?: "etc" | "dbus" } = {}): IdunnRuntimeAuthority { +function openAuthority( + context: TestContext, + set: FixtureSet, + options: { machineId?: "etc" | "dbus"; lease?: string } = {}, +): IdunnRuntimeAuthority { const facts = expectedFacts(set); const originalEnvironment = { ...process.env }; context.after(() => { @@ -59,7 +63,8 @@ function openAuthority(context: TestContext, set: FixtureSet, options: { machine }); process.env.GAMECULT_IDUNN_RUNTIME_BUNDLE = path.join(FIXTURES, set); process.env.GAMECULT_IDUNN_CANDIDATE_BIND = facts.candidate; - delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; + if (options.lease) process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE = path.join(FIXTURES, set, options.lease); + else delete process.env.GAMECULT_IDUNN_PROCESS_WRITE_LEASE; process.env.LISTEN_PID = String(process.pid); process.env.LISTEN_FDS = "2"; process.env.LISTEN_FDNAMES = "gamecult-idunn-runtime-activation-key:gamecult-runtime-presence-identity"; @@ -78,8 +83,8 @@ function openAuthority(context: TestContext, set: FixtureSet, options: { machine return loadIdunnRuntimeAuthorityFromEnvironment(facts.target, facts.healthContract); } -function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0) { - const authority = openAuthority(context, set); +function signerFor(context: TestContext, set: FixtureSet, capacityDelta = 0, lease?: string) { + const authority = openAuthority(context, set, { lease }); const capabilities = expectedFacts(set).capabilities.map((capability) => ({ ...capability, capacity: capability.capacity + capacityDelta })); return createIdunnRuntimeSigner({ authority, capabilities }); } @@ -107,7 +112,7 @@ for (const set of ["web", "service", "rudp-route"] as const) { test("a target that declares no Odin dependency opens and answers Idunn's challenge without one", (context) => { assert.equal(expectedFacts("web").odinEndpoint, undefined); const signer = signerFor(context, "web"); - assert.ok(signer.answerRouteObservation(CHALLENGE, "warming").payload.byteLength > 0); + assert.ok(signer.answerRouteObservation(CHALLENGE).payload.byteLength > 0); }); test("finds the machine-id at the dbus path when /etc/machine-id is absent, as the Rust protector does", (context) => { @@ -127,35 +132,44 @@ test("rejects a credential file holding more than one envelope", (context) => { ); }); -test("answers a route challenge byte for byte as the Rust-verified vectors, in the state the app reports", (context) => { +function presenceSlot(document: { payload: Uint8Array }, slot: number): unknown { + return (decode(document.payload) as unknown[])[slot]; +} + +// Every committed vector is asserted, dead ones included. Stateless sets sign at sequence 1. +for (const set of ["web", "service", "rudp-route"] as const) { + test(`${set}: a fresh signer answers warming, and after reportHealth('active') answers active, byte for byte as the Rust-verified vectors`, (context) => { + freezeClock(context); + assert.deepEqual(Buffer.from(signerFor(context, set).answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-warming.bin")); + const active = signerFor(context, set); + active.reportHealth("active"); + assert.deepEqual(Buffer.from(active.answerRouteObservation(CHALLENGE).payload), fixtureFile(set, "presence-active.bin")); + }); +} + +test("a shortfall signs the vector Rust correlates to the typed capacity disagreement", (context) => { freezeClock(context); - const signer = signerFor(context, "web"); - const warming = signer.answerRouteObservation(CHALLENGE, "warming"); - assert.deepEqual(Buffer.from(warming.payload), fixtureFile("web", "presence-warming.bin")); - assert.equal((decode(warming.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.state], "warming"); - const second = signer.answerRouteObservation(CHALLENGE, "active"); - assert.equal((decode(second.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.publisherSequence], 2); - // Sequence 2 cannot match a vector written at sequence 1; sign the same fields from a fresh signer. - const fresh = signerFor(context, "web"); - assert.deepEqual(Buffer.from(fresh.answerRouteObservation(CHALLENGE, "active").payload), fixtureFile("web", "presence-active.bin")); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile("service", "presence-active-below-minimum.bin")); }); -test("a service with an Odin dependency signs the Rust-verified vectors, including a shortfall Rust correlates to a capacity disagreement", (context) => { +test("the signer owns the reported health: a fresh signer is warming, reportHealth moves it, the sequence keeps counting", (context) => { freezeClock(context); - assert.deepEqual( - Buffer.from(signerFor(context, "service").answerRouteObservation(CHALLENGE, "active").payload), - fixtureFile("service", "presence-active.bin"), - ); - assert.deepEqual( - Buffer.from(signerFor(context, "service", -1).answerRouteObservation(CHALLENGE, "active").payload), - fixtureFile("service", "presence-active-below-minimum.bin"), - ); + const signer = signerFor(context, "web"); + const first = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(first, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.sign("detail"), RUNTIME_PRESENCE_SLOT.state), "warming"); + signer.reportHealth("active"); + const third = signer.answerRouteObservation(CHALLENGE); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.publisherSequence), 3); }); test("answers locally: synchronously, with no socket opened", (context) => { const sockets = context.mock.method(dgram, "createSocket"); const signer = signerFor(context, "web"); - const answer = signer.answerRouteObservation(CHALLENGE, "warming"); + const answer = signer.answerRouteObservation(CHALLENGE); assert.equal(typeof (answer as unknown as { then?: unknown }).then, "undefined"); assert.equal(sockets.mock.callCount(), 0); }); @@ -169,7 +183,7 @@ test("refuses a challenge that does not ask for this runtime's presence", (conte { ...CHALLENGE, schemaIds: ["another.schema"] }, { ...CHALLENGE, shardId: "shard-1" }, ]; - for (const request of cases) assert.throws(() => signer.answerRouteObservation(request, "active"), /route observation/); + for (const request of cases) assert.throws(() => signer.answerRouteObservation(request), /route observation/); }); async function startOdinPeer(context: TestContext, endpoint: string) { @@ -222,7 +236,7 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte const signer = signerFor(context, "service"); const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - await publisher.publish("warming", "accepted presence"); + await publisher.publish("accepted presence"); assert.equal(peer.received[0]?.schemaVersion, "cultnet.document_put_raw.v0"); const published = decode(peer.received[0]!.document.payload) as unknown[]; assert.equal(published[RUNTIME_PRESENCE_SLOT.state], "warming"); @@ -230,15 +244,18 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte assert.equal(peer.responseAck, 2, "the response data packet must acknowledge the publisher's reliable schema packet"); peer.reject = true; - await assert.rejects(publisher.publish("active", "denied presence"), /Odin rejected runtime presence: test admission denied/); + signer.reportHealth("active"); + await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); assert.equal(peer.failure, undefined, peer.failure?.message); }); test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { const endpoint = expectedFacts("service").odinEndpoint!; const peer = await startOdinPeer(context, endpoint); - const publisher = createIdunnRuntimePresencePublisher({ signer: signerFor(context, "service", -1), endpoint }); - await publisher.publish("active", "below minimum"); + const signer = signerFor(context, "service", -1); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await publisher.publish("below minimum"); const capabilities = (decode(peer.received[0]!.document.payload) as unknown[])[RUNTIME_PRESENCE_SLOT.capabilities] as unknown[][]; assert.equal(capabilities[0]![3], expectedFacts("service").capabilities[0]!.capacity - 1); }); @@ -253,3 +270,24 @@ test("the Odin publisher takes only the endpoint Expected names", (context) => { /does not match Idunn Expected dependency authority/, ); }); + +test("the route answer and the Odin publish report the same state, because the signer owns it", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + const signer = signerFor(context, "service"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + + await publisher.publish("first"); + assert.equal(presenceSlot(peer.received[0]!.document, RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "warming"); + + signer.reportHealth("active"); + await publisher.publish("second"); + assert.equal(presenceSlot(peer.received[1]!.document, RUNTIME_PRESENCE_SLOT.state), "active"); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.state), "active"); + // One sequence across both carriers. + assert.deepEqual( + [1, 2].map((index) => presenceSlot(peer.received[index - 1]!.document, RUNTIME_PRESENCE_SLOT.publisherSequence)), + [1, 3], + ); +}); From 67b8ae3eda1915a86aecb1befbbbb8676be44696 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:13:28 +0200 Subject: [PATCH 09/12] cultnet-ts: freeze the Idunn authority and allow one signer per authority The loaded authority is deeply frozen (bind, health contract, Expected, Activation, identities), so a caller cannot steer what the signer signs; the typed Buffers that could not be frozen are dropped (Expected canonical bytes, Activation public key kept as hex only for the load-time comparison). A second createIdunnRuntimeSigner on the same authority throws, so two signers cannot emit sequence 1 under one runtimeInstanceId. Tests: stateful set pinned against Rust (warming, active, degraded), lease accepted for a route-answered warming, refused for another warming and another target, one signer per authority, frozen authority. Co-Authored-By: Claude Sonnet 5 --- .../cultnet-ts/src/idunn-runtime-authority.ts | 41 ++++++++--- .../test/idunn-runtime-authority.test.ts | 72 +++++++++++++++++++ 2 files changed, 104 insertions(+), 9 deletions(-) diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index bb217ed0..cd942cec 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -30,6 +30,7 @@ const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "ut const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); const MAX_RECENT_WARMING_PROOFS = 64; +const authoritiesWithSigner = new WeakSet(); const authorityKeys = new WeakMap(); type ExpectedIncarnation = { @@ -51,7 +52,6 @@ type ExpectedIncarnation = { route: { routeId: string; transport: string; stableEndpoint: string; candidateEndpoint: string } | null; capabilities: Array<{ capability: string; schema: string; compatibility: string; minimumCapacity: number }>; dependencies: Array<{ kind: string; capability: string; schema: string; compatibility: string; providerEndpoint: string | null }>; - canonicalBytes: Buffer; canonicalSha256: string; }; @@ -61,18 +61,27 @@ type RuntimeActivation = { runtimeId: string; runtimeInstanceId: string; activationSignerIdentityId: string; - activationSignerPublicKey: Buffer; + activationSignerPublicKeyHex: string; canonicalSha256: string; }; -export type IdunnRuntimeAuthority = { +type DeepReadonly = T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends object ? { readonly [K in keyof T]: DeepReadonly } : T; + +/** + * What Idunn admitted for this launch. Deeply frozen at load: bind, health + * contract, Expected, Activation and identities are read-only for the life of + * the process, so no caller can steer what the signer signs. + */ +export type IdunnRuntimeAuthority = DeepReadonly<{ expected: ExpectedIncarnation; activation: RuntimeActivation; providerSignerIdentityId: string; activationSignerIdentityId: string; boundEndpoint: string; processWriteLeasePath?: string; -}; +}>; export type SignedRuntimePresence = { document: CultNetRawDocumentRecord; @@ -152,7 +161,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( const activationPublicKey = rawEd25519PublicKey(crypto.createPublicKey(activationPrivateKey)); const activationSignerIdentityId = sha256Hex(Buffer.concat([ACTIVATION_ID_DOMAIN, activationPublicKey])); if (activationSignerIdentityId !== activationValue.activationSignerIdentityId - || !activationPublicKey.equals(activationValue.activationSignerPublicKey)) { + || activationPublicKey.toString("hex") !== activationValue.activationSignerPublicKeyHex) { throw new Error("Idunn activation credential does not match the root-provisioned Activation witness."); } @@ -167,7 +176,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( throw new Error("Provider identity does not match Idunn Expected."); } - const authority: IdunnRuntimeAuthority = { + const authority: IdunnRuntimeAuthority = deepFreeze({ expected: expectedValue, activation: activationValue, providerSignerIdentityId: expectedValue.expectedSignerIdentityId, @@ -176,7 +185,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( ...(process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] ? { processWriteLeasePath: process.env[IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT] } : {}), - }; + }); authorityKeys.set(authority, { providerPrivateKey, activationPrivateKey }); return authority; } @@ -220,8 +229,15 @@ export function signIdunnRuntimePresence( }; } +/** + * One signer per loaded authority: the publisher sequence and the warming proofs + * are the signer's, and a second signer would restart the sequence under the + * same runtimeInstanceId. + */ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): IdunnRuntimeSigner { const { authority } = options; + if (authoritiesWithSigner.has(authority)) throw new Error("This Idunn runtime authority already has a signer."); + authoritiesWithSigner.add(authority); const expected = authority.expected; let sequence = 0; let health: RuntimePresenceHealth["state"] = "warming"; @@ -291,6 +307,14 @@ export function createIdunnRuntimeSigner(options: IdunnRuntimeSignerOptions): Id }; } +function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + function readAuthorityRecord(filePath: string, type: string, schemaId: string): { payload: Uint8Array } { const decoded = decode(fs.readFileSync(filePath)); if (!Array.isArray(decoded) || decoded[0] !== CULTCACHE_STORE_FORMAT || !Array.isArray(decoded[1]) || !Array.isArray(decoded[2])) { @@ -360,7 +384,6 @@ function decodeExpected(payload: Uint8Array): ExpectedIncarnation { route, capabilities: decodeExpectedCapabilities(values[16]), dependencies: decodeExpectedDependencies(values[17]), - canonicalBytes, canonicalSha256: prefixedSha256(canonicalBytes), }; } @@ -388,7 +411,7 @@ function decodeActivation(payload: Uint8Array): RuntimeActivation { runtimeId: string(values[2], "Activation runtime id"), runtimeInstanceId: string(values[3], "Activation instance id"), activationSignerIdentityId, - activationSignerPublicKey: Buffer.from(publicKey), + activationSignerPublicKeyHex: Buffer.from(publicKey).toString("hex"), canonicalSha256: prefixedSha256(canonicalBytes), }; } diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 1df6e98f..5f34c02c 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -291,3 +291,75 @@ test("the route answer and the Odin publish report the same state, because the s [1, 3], ); }); + +// The lease-bound set: Rust issued lease.cc naming presence-warming.bin's digest, and presence-active.bin +// and presence-degraded.bin carry that lease's digest. +const STATEFUL = "service-stateful" as const; + +function statefulSigner(context: TestContext, lease = "lease.cc") { + return signerFor(context, STATEFUL, 0, lease); +} + +test("stateful: warming, active and degraded match the Rust-verified vectors byte for byte, active and degraded bound to Rust's lease", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.deepEqual(Buffer.from(signer.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-warming.bin")); + signer.reportHealth("active"); + const active = signer.answerRouteObservation(CHALLENGE); + assert.deepEqual(Buffer.from(active.payload), fixtureFile(STATEFUL, "presence-active.bin")); + assert.match(String(presenceSlot(active, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)), /^sha256-[0-9a-f]{64}$/); + + const degraded = statefulSigner(context); + degraded.answerRouteObservation(CHALLENGE); + degraded.reportHealth("degraded"); + assert.deepEqual(Buffer.from(degraded.answerRouteObservation(CHALLENGE).payload), fixtureFile(STATEFUL, "presence-degraded.bin")); +}); + +test("a warming answered on the route becomes a lease target: Rust's lease naming it is accepted", (context) => { + freezeClock(context); + const signer = statefulSigner(context); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/, "no warming signed yet"); + signer.answerRouteObservation(CHALLENGE); + assert.equal(signer.assertWriteLease(), presenceSlot({ payload: fixtureFile(STATEFUL, "presence-active.bin") }, RUNTIME_PRESENCE_SLOT.writeLeaseSha256)); +}); + +for (const [lease, reason] of [["lease-other-warming.cc", "names another warming digest"], ["lease-other-target.cc", "names another target"]] as const) { + test(`a lease that ${reason} is refused, and the signer cannot sign active under it`, (context) => { + freezeClock(context); + const signer = statefulSigner(context, lease); + signer.answerRouteObservation(CHALLENGE); + assert.throws(() => signer.assertWriteLease(), /does not match the current Expected incarnation/); + signer.reportHealth("active"); + assert.throws(() => signer.answerRouteObservation(CHALLENGE), /does not match the current Expected incarnation/); + }); +} + +test("one signer per loaded authority", (context) => { + const authority = openAuthority(context, "web"); + const capabilities = expectedFacts("web").capabilities; + createIdunnRuntimeSigner({ authority, capabilities }); + assert.throws(() => createIdunnRuntimeSigner({ authority, capabilities }), /already has a signer/); +}); + +test("the loaded authority is frozen: the bind, contract, Expected, Activation and identities cannot change, and the signer signs the loaded endpoint", (context) => { + freezeClock(context); + const authority = openAuthority(context, "web"); + const loaded = expectedFacts("web"); + const open = authority as unknown as { [key: string]: any }; + const mutations: Array<[string, () => void]> = [ + ["boundEndpoint", () => { open.boundEndpoint = "http://127.0.0.1:1"; }], + ["providerSignerIdentityId", () => { open.providerSignerIdentityId = "forged"; }], + ["activationSignerIdentityId", () => { open.activationSignerIdentityId = "forged"; }], + ["expected.healthContract", () => { open.expected.healthContract = "forged"; }], + ["expected.target", () => { open.expected.target = "forged"; }], + ["expected.canonicalSha256", () => { open.expected.canonicalSha256 = "forged"; }], + ["expected.route.candidateEndpoint", () => { open.expected.route.candidateEndpoint = "http://127.0.0.1:1"; }], + ["expected.capabilities[0].minimumCapacity", () => { open.expected.capabilities[0].minimumCapacity = 0; }], + ["expected.capabilities.push", () => { open.expected.capabilities.push({}); }], + ["activation.runtimeInstanceId", () => { open.activation.runtimeInstanceId = "forged"; }], + ["activation.canonicalSha256", () => { open.activation.canonicalSha256 = "forged"; }], + ]; + for (const [name, mutate] of mutations) assert.throws(mutate, TypeError, name); + const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities }); + assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate); +}); From 06d250070a9ece3b62a5e6565ea1fc84bf654513 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:13:37 +0200 Subject: [PATCH 10/12] cultnet-ts: add an idunn-runtime subpath export that does not load dgram `cultnet-ts/idunn-runtime` exposes the signer and loader alone. Web apps import it instead of the package root, which also carries the Odin publisher and node:dgram. The root export is unchanged. Test loads both under a stubbed dgram: the subpath loads, the root fails. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-ts/package.json | 5 ++++ .../test/idunn-runtime-authority.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index f33b8f83..e72a10aa 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -15,6 +15,11 @@ "require": "./dist/contracts.js", "default": "./dist/contracts.js" }, + "./idunn-runtime": { + "types": "./dist/idunn-runtime-authority.d.ts", + "require": "./dist/idunn-runtime-authority.js", + "default": "./dist/idunn-runtime-authority.js" + }, "./authority": { "types": "./dist/cultmesh-authority.d.ts", "require": "./dist/cultmesh-authority.js", diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 5f34c02c..075738be 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -1,5 +1,6 @@ import { test, type TestContext } from "node:test"; import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import dgram from "node:dgram"; import fs from "node:fs"; import path from "node:path"; @@ -363,3 +364,26 @@ test("the loaded authority is frozen: the bind, contract, Expected, Activation a const signer = createIdunnRuntimeSigner({ authority, capabilities: loaded.capabilities }); assert.equal(presenceSlot(signer.answerRouteObservation(CHALLENGE), RUNTIME_PRESENCE_SLOT.boundEndpoint), loaded.candidate); }); + +// A web app must be able to import the signer without pulling the Odin publisher and dgram. +function loadUnderDgramStub(modulePath: string): string { + const script = [ + 'const Module = require("node:module");', + "const load = Module._load;", + 'Module._load = function (request, ...rest) { if (request === "dgram" || request === "node:dgram") throw new Error("dgram was loaded"); return load.call(this, request, ...rest); };', + "try { require(process.argv[1]); process.stdout.write(\"loaded\"); } catch (error) { process.stdout.write(\"failed: \" + error.message); }", + ].join("\n"); + const run = spawnSync(process.execPath, ["-e", script, modulePath], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + return run.stdout; +} + +test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => { + const subpath = require.resolve("cultnet-ts/idunn-runtime"); + assert.equal(loadUnderDgramStub(subpath), "loaded"); + assert.equal(loadUnderDgramStub(require.resolve("cultnet-ts")), "failed: dgram was loaded"); + const exported = require(subpath) as Record; + assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); + assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); + assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); +}); From 322b7c815ae64f2aa008c344dc4a995f345c3a20 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:15:40 +0200 Subject: [PATCH 11/12] cultnet-ts test: read the signed document, not the SignedRuntimePresence wrapper Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-ts/test/idunn-runtime-authority.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 075738be..560f5933 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -160,7 +160,7 @@ test("the signer owns the reported health: a fresh signer is warming, reportHeal const signer = signerFor(context, "web"); const first = signer.answerRouteObservation(CHALLENGE); assert.equal(presenceSlot(first, RUNTIME_PRESENCE_SLOT.state), "warming"); - assert.equal(presenceSlot(signer.sign("detail"), RUNTIME_PRESENCE_SLOT.state), "warming"); + assert.equal(presenceSlot(signer.sign("detail").document, RUNTIME_PRESENCE_SLOT.state), "warming"); signer.reportHealth("active"); const third = signer.answerRouteObservation(CHALLENGE); assert.equal(presenceSlot(third, RUNTIME_PRESENCE_SLOT.state), "active"); From 5ed9dbbcb8562b552bcf4f49cd2f731464665348 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 03:26:10 +0200 Subject: [PATCH 12/12] cultnet-ts: stop exporting signIdunnRuntimePresence; document two limits The function took state and lease digest from the caller, so anyone holding the authority could sign active without reportHealth or assertWriteLease. It is now private to the signer. Test: the subpath and root do not expose it. Docs: waitForWriteLease throws out of publish if health is already non-warming; MAX_RECENT_WARMING_PROOFS=64 is a recorded limit. Co-Authored-By: Claude Sonnet 5 --- packages/cultnet-ts/src/idunn-odin-presence-publisher.ts | 5 +++++ packages/cultnet-ts/src/idunn-runtime-authority.ts | 4 +++- packages/cultnet-ts/test/idunn-runtime-authority.test.ts | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 2e992658..3713363d 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -15,6 +15,11 @@ export type IdunnRuntimePresencePublisher = { readonly requiresWriteLease: boolean; /** Publishes the signer's presence in the health the signer reports. */ publish(detail: string): Promise; + /** + * Publishes warming presence until Idunn's lease names one. Call it before + * `reportHealth('active')`: once the signer reports a non-warming health, + * `publish` inside this loop throws (it needs the lease) instead of polling. + */ waitForWriteLease(options?: { pollIntervalMs?: number; signal?: AbortSignal }): Promise; assertWriteLease(): Promise; }; diff --git a/packages/cultnet-ts/src/idunn-runtime-authority.ts b/packages/cultnet-ts/src/idunn-runtime-authority.ts index cd942cec..7f7b882f 100644 --- a/packages/cultnet-ts/src/idunn-runtime-authority.ts +++ b/packages/cultnet-ts/src/idunn-runtime-authority.ts @@ -29,6 +29,8 @@ const PROVIDER_PROTECTOR_CONTEXT = "gamecult-provider-health-identity-v1"; const ACTIVATION_ID_DOMAIN = Buffer.from("idunn.runtime-activation.id.v1\0", "utf8"); const CULTCACHE_STORE_FORMAT = "cultcache.store.v1"; const ED25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex"); +// Limit: more than this many warmings signed between Idunn's observation and +// assertWriteLease would push the leased one out and refuse a valid lease. const MAX_RECENT_WARMING_PROOFS = 64; const authoritiesWithSigner = new WeakSet(); const authorityKeys = new WeakMap(); @@ -195,7 +197,7 @@ export function loadIdunnRuntimeAuthorityFromEnvironment( * Signs the fixed Idunn v2 positional runtime-presence contract with the * provider identity and this launch's Idunn-issued activation key. */ -export function signIdunnRuntimePresence( +function signIdunnRuntimePresence( authority: IdunnRuntimeAuthority, fields: Omit, ): { presence: RuntimePresenceHealth; payload: Uint8Array; canonicalSha256: string } { diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 560f5933..b5c9236a 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -386,4 +386,6 @@ test("the idunn-runtime subpath loads the signer and loader without loading dgra assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); + assert.equal(exported.signIdunnRuntimePresence, undefined); + assert.equal((require("cultnet-ts") as Record).signIdunnRuntimePresence, undefined); });