myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit 4823f22cb8b3b96b5c17cb1a17bc41c814f4cf3c
parent 3d3f43afffa0aa1acb25fadbd68f6910501d244b
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 22:34:33 +0000

runtime: compose existing-only service foundation

Add a sealed Myc runtime foundation that opens only existing state, retains provider capabilities, and owns startup and lifetime tasks through the shared supervisor. Freeze passive readiness prerequisites and cover joined provider success, failure cleanup, binding rejection, lock ownership, and redacted diagnostics.

Diffstat:
MAGENTS.md | 6++++++
MREADME | 9+++++++++
Acontracts/services_hardening/runtime_foundation.v1.json | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Asrc/runtime_foundation.rs | 697+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/state_metadata.rs | 11+++++++++++
Atests/services_hardening_runtime_foundation.rs | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1264 insertions(+), 0 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -77,6 +77,12 @@ surfaces. Do not restore those files, dependencies, or Tokio process capability; `radroots_nostr_connect` remains only as the active NIP-46 protocol dependency. +- Step 137 owns only the existing-state runtime foundation and exact startup + prerequisite inventory. Encrypted-file opening runs in joined one-shot + supervisor tasks; local-signer construction performs no probe, and no + provider is ready before its governed verification. Do not add detached + handles, a library runtime, signals, process exit, relay/admin execution, or + a false readiness transition here. - Treat checked-in source, tests, and prototype behavior as implementation evidence, not permission to preserve behavior that the active requirement removes. diff --git a/README b/README @@ -139,6 +139,15 @@ Tokio process capability are absent from the locked graph; the active typed provider contract, encrypted envelope, credential resolver, local-signer transport, and independent verifier are the only provider boundaries. +The existing-only runtime foundation owns the writable state host, retained +provider capabilities, and a shared Lib task supervisor. Encrypted-file +identities open only in synchronously joined one-shot startup tasks; +local-signer clients perform no construction-time I/O and remain unready until +their later governed handshake. A passive closed prerequisite snapshot keeps +state, providers, recovery, required relays, admin, and optional operations +conditions explicit without claiming the later status cache or final daemon +task graph. + Signer-request admission validates bounded client, request, event, method, canonical request, injected operation entropy, and injected time evidence before storage. Stable domain-separated operation and correlation identities diff --git a/contracts/services_hardening/runtime_foundation.v1.json b/contracts/services_hardening/runtime_foundation.v1.json @@ -0,0 +1,54 @@ +{ + "schema": "radroots.myc.runtime-foundation", + "schema_version": 1, + "contract_version": 1, + "state_open": { + "mode": "read_write_existing", + "initialize_if_missing": false, + "raw_sqlite_authority_exposed": false + }, + "provider_startup": { + "encrypted_file": "supervised_joined_one_shot", + "local_signer": "constructed_without_io_pending_handshake", + "database_transaction_held": false, + "detached_tasks": false, + "protected_values_exposed": false + }, + "readiness_prerequisites": [ + { "id": "existing_state", "condition": "always", "reason": "database_schema_mismatch" }, + { "id": "transport_provider", "condition": "always", "reason": "signer_provider_unavailable" }, + { "id": "user_provider", "condition": "always", "reason": "signer_provider_unavailable" }, + { "id": "discovery_provider", "condition": "discovery_enabled", "reason": "signer_provider_unavailable" }, + { "id": "outbox_recovery", "condition": "always", "reason": "outbox_invariant_failed" }, + { "id": "required_relay_connectivity", "condition": "required_relay_present", "reason": "required_relay_unavailable" }, + { "id": "required_relay_subscription", "condition": "required_read_relay_present", "reason": "subscriber_not_active" }, + { "id": "admin_listener", "condition": "always", "reason": "admin_listener_failed" }, + { "id": "operations_listener", "condition": "operations_enabled", "reason": "operations_listener_failed" } + ], + "initial_satisfaction": { + "existing_state": "after_exact_existing_open", + "encrypted_file_provider": "after_joined_identity_verification", + "local_signer_provider": "not_before_verified_describe_handshake", + "remaining_prerequisites": "later_owning_rcld" + }, + "task_ownership": { + "shared_supervisor": "radroots_service_host::TaskSupervisor", + "startup_tasks": "one_shot", + "lifetime_task": "critical_until_cancellation", + "task_handles_exposed": false, + "library_runtime_creation": false, + "signal_installation": false, + "process_exit": false + }, + "deferred": [ + "provider_handshake", + "outbox_recovery", + "relay_connectivity", + "relay_subscription", + "admin_listener", + "operations_listener", + "cached_status", + "signals", + "final_supervised_task_graph" + ] +} diff --git a/src/lib.rs b/src/lib.rs @@ -8,6 +8,7 @@ mod provider_envelope; mod provider_local_signer; mod provider_verification; mod runtime_context; +mod runtime_foundation; mod state_catalog; mod state_connection; mod state_delivery; @@ -69,6 +70,11 @@ pub use runtime_context::{ MycRuntimeContext, MycRuntimeContextError, MycRuntimeContextErrorKind, resolve_myc_runtime_context, }; +pub use runtime_foundation::{ + MYC_RUNTIME_FOUNDATION_CONTRACT_VERSION, MycRuntimeFoundation, MycRuntimeFoundationError, + MycRuntimeFoundationErrorKind, MycRuntimePrerequisite, MycRuntimeReadiness, + open_myc_runtime_foundation, +}; pub use state_catalog::{ MYC_MIGRATION_CATALOG_SHA256, MYC_STATE_BASE_SCHEMA_VERSION, MYC_STATE_SCHEMA_CATALOG_SHA256, MYC_STATE_SCHEMA_VERSION, MYC_STATE_SCHEMA_VERSION_1_OBJECT_COUNT, diff --git a/src/runtime_foundation.rs b/src/runtime_foundation.rs @@ -0,0 +1,697 @@ +//! Existing-only, join-owned Myc runtime foundation. + +use core::fmt; +use std::{error::Error, sync::mpsc}; + +use radroots_service_host::{ + CommonReasonCode, HostError, HostErrorKind, Readiness, ReasonCode, ReasonCodes, ShutdownPhase, + TaskClassification, TaskMetadata, TaskName, TaskSupervisor, +}; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; + +use crate::{ + MycConfigDocumentV1, MycDecryptedIdentity, MycLocalSignerClient, MycProviderBinding, + MycProviderKind, MycProviderRole, MycRuntimeContext, MycStateHost, MycStateMetadata, + open_myc_encrypted_identity, open_myc_state_read_write, resolve_myc_wrapping_credential, +}; + +#[cfg(test)] +const RUNTIME_FOUNDATION_CONTRACT: &str = + include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); + +/// Exact version of the Myc runtime-foundation contract. +pub const MYC_RUNTIME_FOUNDATION_CONTRACT_VERSION: u32 = 1; + +/// Closed startup conditions that must all be satisfied before Myc is ready. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MycRuntimePrerequisite { + ExistingState, + TransportProvider, + UserProvider, + DiscoveryProvider, + OutboxRecovery, + RequiredRelayConnectivity, + RequiredRelaySubscription, + AdminListener, + OperationsListener, +} + +impl MycRuntimePrerequisite { + /// Returns the exact machine-contract spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ExistingState => "existing_state", + Self::TransportProvider => "transport_provider", + Self::UserProvider => "user_provider", + Self::DiscoveryProvider => "discovery_provider", + Self::OutboxRecovery => "outbox_recovery", + Self::RequiredRelayConnectivity => "required_relay_connectivity", + Self::RequiredRelaySubscription => "required_relay_subscription", + Self::AdminListener => "admin_listener", + Self::OperationsListener => "operations_listener", + } + } + + const fn reason(self) -> CommonReasonCode { + match self { + Self::ExistingState => CommonReasonCode::DatabaseSchemaMismatch, + Self::TransportProvider | Self::UserProvider | Self::DiscoveryProvider => { + CommonReasonCode::SignerProviderUnavailable + } + Self::OutboxRecovery => CommonReasonCode::OutboxInvariantFailed, + Self::RequiredRelayConnectivity => CommonReasonCode::RequiredRelayUnavailable, + Self::RequiredRelaySubscription => CommonReasonCode::SubscriberNotActive, + Self::AdminListener => CommonReasonCode::AdminListenerFailed, + Self::OperationsListener => CommonReasonCode::OperationsListenerFailed, + } + } +} + +/// Immutable startup-readiness projection derived from admitted configuration. +/// +/// This snapshot is passive evidence. It performs no provider, relay, SQLite, +/// DNS, listener, or filesystem probe when read. +#[derive(Clone, PartialEq, Eq)] +pub struct MycRuntimeReadiness { + required: Box<[MycRuntimePrerequisite]>, + satisfied: Box<[MycRuntimePrerequisite]>, + reasons: ReasonCodes, +} + +impl MycRuntimeReadiness { + /// Returns readiness only when every exact prerequisite is satisfied. + #[must_use] + pub fn readiness(&self) -> Readiness { + if self.required.len() == self.satisfied.len() + && self + .required + .iter() + .all(|required| self.satisfied.contains(required)) + { + Readiness::READY + } else { + Readiness::NOT_READY + } + } + + /// Returns the exact ordered prerequisite inventory for this configuration. + #[must_use] + pub fn required(&self) -> &[MycRuntimePrerequisite] { + &self.required + } + + /// Returns the exact ordered prerequisites already proven at construction. + #[must_use] + pub fn satisfied(&self) -> &[MycRuntimePrerequisite] { + &self.satisfied + } + + /// Returns bounded stable reasons for every class of missing prerequisite. + #[must_use] + pub const fn reasons(&self) -> &ReasonCodes { + &self.reasons + } +} + +impl fmt::Debug for MycRuntimeReadiness { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycRuntimeReadiness") + .field("ready", &self.readiness().is_ready()) + .field("required", &self.required) + .field("satisfied", &self.satisfied) + .field("reasons", &self.reasons) + .finish() + } +} + +/// Stable source-free runtime-foundation failure class. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycRuntimeFoundationErrorKind { + InvalidBinding, + StateOpen, + Provider, + TaskRegistration, + TaskFailure, + Readiness, + Close, +} + +impl MycRuntimeFoundationErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidBinding => "runtime_binding_invalid", + Self::StateOpen => "runtime_state_open_failed", + Self::Provider => "runtime_provider_unavailable", + Self::TaskRegistration => "runtime_task_registration_failed", + Self::TaskFailure => "runtime_task_failed", + Self::Readiness => "runtime_readiness_invalid", + Self::Close => "runtime_close_failed", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidBinding => "Myc runtime binding is invalid", + Self::StateOpen => "Myc existing state could not be opened", + Self::Provider => "Myc provider startup failed", + Self::TaskRegistration => "Myc runtime task registration failed", + Self::TaskFailure => "Myc supervised startup task failed", + Self::Readiness => "Myc readiness prerequisites are invalid", + Self::Close => "Myc runtime foundation could not close", + } + } +} + +/// One redacted source-free runtime-foundation failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycRuntimeFoundationError { + kind: MycRuntimeFoundationErrorKind, +} + +impl MycRuntimeFoundationError { + const fn new(kind: MycRuntimeFoundationErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure kind. + #[must_use] + pub const fn kind(self) -> MycRuntimeFoundationErrorKind { + self.kind + } + + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Debug for MycRuntimeFoundationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycRuntimeFoundationError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycRuntimeFoundationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycRuntimeFoundationError {} + +enum MycRuntimeProvider { + EncryptedFile { + role: MycProviderRole, + _identity: MycDecryptedIdentity, + }, + LocalSigner { + role: MycProviderRole, + _client: MycLocalSignerClient, + }, +} + +enum ProviderStartupOutcome { + Ready(MycDecryptedIdentity), + ProviderFailure, + TaskFailure, +} + +impl MycRuntimeProvider { + const fn role(&self) -> MycProviderRole { + match self { + Self::EncryptedFile { role, .. } | Self::LocalSigner { role, .. } => *role, + } + } + + const fn kind(&self) -> MycProviderKind { + match self { + Self::EncryptedFile { .. } => MycProviderKind::EncryptedFile, + Self::LocalSigner { .. } => MycProviderKind::LocalSigner, + } + } +} + +/// Existing-only Myc foundation with sealed state, provider, and task ownership. +/// +/// The final relay/admin/process task graph remains owned by later RCLDs. This +/// value cannot be constructed directly or used to extract raw SQLite, +/// provider-secret, task-handle, or cancellation authority. +#[must_use = "the runtime foundation must be shut down so owned tasks and state are joined"] +pub struct MycRuntimeFoundation { + runtime: MycRuntimeContext, + configuration: MycConfigDocumentV1, + metadata: MycStateMetadata, + state: MycStateHost, + providers: Box<[MycRuntimeProvider]>, + readiness: MycRuntimeReadiness, + supervisor: TaskSupervisor, +} + +impl MycRuntimeFoundation { + /// Returns the immutable canonical instance context. + #[must_use] + pub const fn runtime_context(&self) -> &MycRuntimeContext { + &self.runtime + } + + /// Returns the admitted immutable configuration. + #[must_use] + pub const fn configuration(&self) -> &MycConfigDocumentV1 { + &self.configuration + } + + /// Returns metadata proven against the existing state host. + #[must_use] + pub const fn metadata(&self) -> &MycStateMetadata { + &self.metadata + } + + /// Returns the passive startup-readiness snapshot. + #[must_use] + pub const fn readiness(&self) -> &MycRuntimeReadiness { + &self.readiness + } + + /// Returns the configured retained provider kind for one enabled role. + #[must_use] + pub fn provider_kind(&self, role: MycProviderRole) -> Option<MycProviderKind> { + self.providers + .iter() + .find(|provider| provider.role() == role) + .map(MycRuntimeProvider::kind) + } + + /// Requests cancellation, joins every owned task, and explicitly closes state. + pub async fn shutdown(mut self) -> Result<(), MycRuntimeFoundationError> { + self.supervisor.request_cancellation(); + let supervised = self.supervisor.supervise().await; + let closed = self.state.close().await; + if supervised.is_err() { + Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::TaskFailure, + )) + } else if closed.is_err() { + Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::Close, + )) + } else { + Ok(()) + } + } +} + +impl fmt::Debug for MycRuntimeFoundation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycRuntimeFoundation") + .field("runtime", &"[redacted]") + .field("configuration", &"[redacted]") + .field("metadata", &"[redacted]") + .field("state", &"[sealed]") + .field("provider_count", &self.providers.len()) + .field("readiness", &self.readiness) + .field("task_count", &self.supervisor.task_count()) + .finish() + } +} + +/// Opens an existing Myc state host and composes its sealed startup foundation. +/// +/// Missing state is never initialized. Encrypted-file providers are opened on +/// bounded one-shot worker threads that are synchronously joined by +/// `TaskSupervisor`; local-signer clients are constructed without I/O and +/// remain unready until a later governed handshake succeeds. No task handle is +/// detached or returned. +pub async fn open_myc_runtime_foundation( + runtime: MycRuntimeContext, + configuration: MycConfigDocumentV1, + metadata: MycStateMetadata, + applied_at: MigrationAppliedAtUnixSeconds, + build: &MigrationBuildIdentity, +) -> Result<MycRuntimeFoundation, MycRuntimeFoundationError> { + if !metadata.matches_configuration(&runtime, &configuration) { + return Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::InvalidBinding, + )); + } + let state = open_myc_state_read_write(&runtime, &metadata, applied_at, build) + .await + .map_err(|_| MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::StateOpen))?; + + match compose_after_state_open(runtime, configuration, metadata, state).await { + Ok(foundation) => Ok(foundation), + Err((error, state)) => { + if state.close().await.is_err() { + Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::Close, + )) + } else { + Err(error) + } + } + } +} + +async fn compose_after_state_open( + runtime: MycRuntimeContext, + configuration: MycConfigDocumentV1, + metadata: MycStateMetadata, + state: MycStateHost, +) -> Result<MycRuntimeFoundation, (MycRuntimeFoundationError, MycStateHost)> { + let (providers, readiness, supervisor) = + match compose_runtime_components(&runtime, &configuration).await { + Ok(components) => components, + Err(error) => return Err((error, state)), + }; + Ok(MycRuntimeFoundation { + runtime, + configuration, + metadata, + state, + providers, + readiness, + supervisor, + }) +} + +async fn compose_runtime_components( + runtime: &MycRuntimeContext, + configuration: &MycConfigDocumentV1, +) -> Result< + ( + Box<[MycRuntimeProvider]>, + MycRuntimeReadiness, + TaskSupervisor, + ), + MycRuntimeFoundationError, +> { + let mut supervisor = TaskSupervisor::new(); + let mut providers: Vec<Option<MycRuntimeProvider>> = configuration + .provider_contract() + .bindings() + .iter() + .map(|_| None) + .collect(); + let mut encrypted = Vec::new(); + let mut pending = Vec::new(); + + for (index, binding) in configuration + .provider_contract() + .bindings() + .iter() + .cloned() + .enumerate() + { + match binding.kind() { + MycProviderKind::EncryptedFile => { + let role = binding.role(); + let task = provider_task_metadata(role)?; + encrypted.push((index, role, binding, task)); + } + MycProviderKind::LocalSigner => { + let role = binding.role(); + let client = MycLocalSignerClient::new(&binding).map_err(|_| { + MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::Provider) + })?; + providers[index] = Some(MycRuntimeProvider::LocalSigner { + role, + _client: client, + }); + } + } + } + + for (index, role, binding, task) in encrypted { + let (sender, receiver) = mpsc::sync_channel(1); + let task_runtime = runtime.clone(); + let registered = supervisor.spawn(task, move |_cancellation| async move { + let outcome = match std::thread::Builder::new() + .name(provider_thread_name(role).to_owned()) + .spawn(move || open_encrypted_provider(&task_runtime, &binding)) + { + Ok(thread) => match thread.join() { + Ok(Ok(identity)) => ProviderStartupOutcome::Ready(identity), + Ok(Err(())) => ProviderStartupOutcome::ProviderFailure, + Err(_) => ProviderStartupOutcome::TaskFailure, + }, + Err(_) => ProviderStartupOutcome::TaskFailure, + }; + let succeeded = matches!(outcome, ProviderStartupOutcome::Ready(_)); + let _ = sender.send(outcome); + if succeeded { + Ok(()) + } else { + Err(HostError::new(HostErrorKind::TaskFailure)) + } + }); + if registered.is_err() { + supervisor.request_cancellation(); + let _ = supervisor.supervise().await; + return Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::TaskRegistration, + )); + } + pending.push((index, role, receiver)); + } + + let supervised = supervisor.supervise().await; + let mut provider_failure = false; + let mut task_failure = false; + for (index, role, receiver) in pending { + match receiver.recv() { + Ok(ProviderStartupOutcome::Ready(identity)) => { + providers[index] = Some(MycRuntimeProvider::EncryptedFile { + role, + _identity: identity, + }); + } + Ok(ProviderStartupOutcome::ProviderFailure) => provider_failure = true, + Ok(ProviderStartupOutcome::TaskFailure) | Err(_) => task_failure = true, + } + } + if task_failure { + return Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::TaskFailure, + )); + } + if provider_failure { + return Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::Provider, + )); + } + if supervised.is_err() { + return Err(MycRuntimeFoundationError::new( + MycRuntimeFoundationErrorKind::TaskFailure, + )); + } + let providers = providers + .into_iter() + .collect::<Option<Vec<_>>>() + .ok_or_else(|| MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::Provider))? + .into_boxed_slice(); + + let readiness = startup_readiness(configuration, &providers)?; + let lifetime = TaskMetadata::new( + TaskName::new("runtime_lifetime").map_err(|_| { + MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::TaskRegistration) + })?, + TaskClassification::Critical, + Some(ShutdownPhase::RejectNewMutations), + ) + .map_err(|_| MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::TaskRegistration))?; + supervisor + .spawn(lifetime, |cancellation| async move { + cancellation.cancelled().await; + Ok(()) + }) + .map_err(|_| { + MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::TaskRegistration) + })?; + + Ok((providers, readiness, supervisor)) +} + +fn open_encrypted_provider( + runtime: &MycRuntimeContext, + binding: &MycProviderBinding, +) -> Result<MycDecryptedIdentity, ()> { + let credential = resolve_myc_wrapping_credential(runtime, binding).map_err(|_| ())?; + open_myc_encrypted_identity(binding, &credential).map_err(|_| ()) +} + +fn provider_task_metadata( + role: MycProviderRole, +) -> Result<TaskMetadata, MycRuntimeFoundationError> { + TaskMetadata::new( + TaskName::new(provider_task_name(role)).map_err(|_| { + MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::TaskRegistration) + })?, + TaskClassification::OneShot, + None, + ) + .map_err(|_| MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::TaskRegistration)) +} + +const fn provider_task_name(role: MycProviderRole) -> &'static str { + match role { + MycProviderRole::Transport => "startup_transport_provider", + MycProviderRole::User => "startup_user_provider", + MycProviderRole::Discovery => "startup_discovery_provider", + } +} + +const fn provider_thread_name(role: MycProviderRole) -> &'static str { + match role { + MycProviderRole::Transport => "myc-provider-transport", + MycProviderRole::User => "myc-provider-user", + MycProviderRole::Discovery => "myc-provider-discovery", + } +} + +fn startup_readiness( + configuration: &MycConfigDocumentV1, + providers: &[MycRuntimeProvider], +) -> Result<MycRuntimeReadiness, MycRuntimeFoundationError> { + let document = configuration.normalized(); + let mut required = vec![ + MycRuntimePrerequisite::ExistingState, + MycRuntimePrerequisite::TransportProvider, + MycRuntimePrerequisite::UserProvider, + ]; + if configuration + .provider_contract() + .binding(MycProviderRole::Discovery) + .is_some() + { + required.push(MycRuntimePrerequisite::DiscoveryProvider); + } + required.push(MycRuntimePrerequisite::OutboxRecovery); + + let relays = document + .pointer("/relays") + .and_then(serde_json::Value::as_array) + .ok_or_else(readiness_error)?; + if relays.iter().any(|relay| { + relay + .pointer("/required") + .and_then(serde_json::Value::as_bool) + == Some(true) + }) { + required.push(MycRuntimePrerequisite::RequiredRelayConnectivity); + } + if relays.iter().any(|relay| { + relay + .pointer("/required") + .and_then(serde_json::Value::as_bool) + == Some(true) + && relay.pointer("/read").and_then(serde_json::Value::as_bool) == Some(true) + }) { + required.push(MycRuntimePrerequisite::RequiredRelaySubscription); + } + required.push(MycRuntimePrerequisite::AdminListener); + if document + .pointer("/operations/enabled") + .and_then(serde_json::Value::as_bool) + .ok_or_else(readiness_error)? + { + required.push(MycRuntimePrerequisite::OperationsListener); + } + + let mut satisfied = vec![MycRuntimePrerequisite::ExistingState]; + for provider in providers { + if provider.kind() == MycProviderKind::EncryptedFile { + satisfied.push(provider_prerequisite(provider.role())); + } + } + satisfied.sort_by_key(|prerequisite| { + required + .iter() + .position(|candidate| candidate == prerequisite) + .unwrap_or(usize::MAX) + }); + let missing_reasons = required + .iter() + .filter(|prerequisite| !satisfied.contains(prerequisite)) + .map(|prerequisite| ReasonCode::from(prerequisite.reason())); + let reasons = ReasonCodes::new(missing_reasons).map_err(|_| readiness_error())?; + Ok(MycRuntimeReadiness { + required: required.into_boxed_slice(), + satisfied: satisfied.into_boxed_slice(), + reasons, + }) +} + +const fn provider_prerequisite(role: MycProviderRole) -> MycRuntimePrerequisite { + match role { + MycProviderRole::Transport => MycRuntimePrerequisite::TransportProvider, + MycProviderRole::User => MycRuntimePrerequisite::UserProvider, + MycProviderRole::Discovery => MycRuntimePrerequisite::DiscoveryProvider, + } +} + +const fn readiness_error() -> MycRuntimeFoundationError { + MycRuntimeFoundationError::new(MycRuntimeFoundationErrorKind::Readiness) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn machine_contract_and_closed_names_are_exact() { + let contract: serde_json::Value = + serde_json::from_str(RUNTIME_FOUNDATION_CONTRACT).expect("runtime contract"); + assert_eq!(contract["schema"], "radroots.myc.runtime-foundation"); + assert_eq!(contract["contract_version"], 1); + let names = contract["readiness_prerequisites"] + .as_array() + .expect("prerequisite inventory") + .iter() + .map(|entry| entry["id"].as_str().expect("prerequisite id")) + .collect::<Vec<_>>(); + let expected = [ + MycRuntimePrerequisite::ExistingState, + MycRuntimePrerequisite::TransportProvider, + MycRuntimePrerequisite::UserProvider, + MycRuntimePrerequisite::DiscoveryProvider, + MycRuntimePrerequisite::OutboxRecovery, + MycRuntimePrerequisite::RequiredRelayConnectivity, + MycRuntimePrerequisite::RequiredRelaySubscription, + MycRuntimePrerequisite::AdminListener, + MycRuntimePrerequisite::OperationsListener, + ]; + assert_eq!( + names, + expected + .into_iter() + .map(MycRuntimePrerequisite::as_str) + .collect::<Vec<_>>() + ); + } + + #[test] + fn safe_errors_cover_the_closed_inventory_without_sources() { + for kind in [ + MycRuntimeFoundationErrorKind::InvalidBinding, + MycRuntimeFoundationErrorKind::StateOpen, + MycRuntimeFoundationErrorKind::Provider, + MycRuntimeFoundationErrorKind::TaskRegistration, + MycRuntimeFoundationErrorKind::TaskFailure, + MycRuntimeFoundationErrorKind::Readiness, + MycRuntimeFoundationErrorKind::Close, + ] { + let error = MycRuntimeFoundationError::new(kind); + assert!(!error.code().is_empty()); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error} {error:?}").contains("source")); + } + } +} diff --git a/src/state_metadata.rs b/src/state_metadata.rs @@ -302,6 +302,17 @@ impl MycStateMetadata { ServiceSqlitePaths::from_runtime_context(runtime.context()) .is_ok_and(|paths| paths == self.paths) } + + pub(crate) fn matches_configuration( + &self, + runtime: &MycRuntimeContext, + configuration: &MycConfigDocumentV1, + ) -> bool { + self.matches_runtime(runtime) + && require_profile_binding(runtime.profile(), configuration.profile()).is_ok() + && normalized_config_digest(configuration.profile(), configuration.normalized()) + .is_ok_and(|digest| digest == self.configuration) + } } impl fmt::Debug for MycStateMetadata { diff --git a/tests/services_hardening_runtime_foundation.rs b/tests/services_hardening_runtime_foundation.rs @@ -0,0 +1,481 @@ +#![forbid(unsafe_code)] +#![cfg(any(target_os = "linux", target_os = "macos"))] + +use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; + +use myc::{ + MycConfigDocumentV1, MycConfigProfile, MycEncryptedIdentityProvisioningMaterial, + MycProviderKind, MycProviderRole, MycRuntimeFoundationErrorKind, MycRuntimePrerequisite, + MycStateMetadata, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, + initialize_myc_state, open_myc_runtime_foundation, open_myc_state_read_write, + parse_myc_cli_v1_from, parse_myc_config_v1, provision_myc_encrypted_identity, + resolve_myc_runtime_context, resolve_myc_wrapping_credential, +}; +use nostr::{Keys, SecretKey}; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; +use radroots_storage::event::SourceGeneration; +use serde_json::json; + +const FOUNDATION_SOURCE: &str = include_str!("../src/runtime_foundation.rs"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const CONTRACT_SOURCE: &str = + include_str!("../contracts/services_hardening/runtime_foundation.v1.json"); +const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + +const TRANSPORT_ENCRYPTED: &str = r#"[identity.transport] +provider = "encrypted_file" +envelope_path = "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt" +credential_reference = "transport_wrapping_key" +expected_public_key = "4444444444444444444444444444444444444444444444444444444444444444""#; + +const DISCOVERY_ENCRYPTED: &str = r#"[identity.discovery.binding] +provider = "encrypted_file" +envelope_path = "/var/lib/radroots/services/myc/primary/secrets/discovery.identity.ncrypt" +credential_reference = "discovery_wrapping_key" +expected_public_key = "3333333333333333333333333333333333333333333333333333333333333333""#; + +fn runtime(root: &Path) -> myc::MycRuntimeContext { + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + "repo-local", + "--instance", + "primary", + "--repo-local-root", + root.to_str().expect("UTF-8 temporary root"), + "run", + ]) + .expect("valid test invocation"); + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context") +} + +fn prepare_state_directory(runtime: &myc::MycRuntimeContext) { + let directory = runtime.context().paths().state(); + fs::create_dir_all(directory).expect("state directory"); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); +} + +fn local_transport_block(root: &Path) -> String { + format!( + r#"[identity.transport] +provider = "local_signer" +socket_path = "{}" +request_deadline_ms = 15000 +request_max_bytes = 65536 +response_max_bytes = 1048576 +concurrency = 32 +expected_public_key = "4444444444444444444444444444444444444444444444444444444444444444""#, + root.join("transport-signer.sock").display() + ) +} + +fn local_discovery_block(root: &Path) -> String { + format!( + r#"[identity.discovery.binding] +provider = "local_signer" +socket_path = "{}" +request_deadline_ms = 15000 +request_max_bytes = 65536 +response_max_bytes = 1048576 +concurrency = 32 +expected_public_key = "3333333333333333333333333333333333333333333333333333333333333333""#, + root.join("discovery-signer.sock").display() + ) +} + +fn all_local_source(root: &Path) -> String { + let transport = local_transport_block(root); + let discovery = local_discovery_block(root); + let source = CONFIG_EXAMPLE + .replacen(TRANSPORT_ENCRYPTED, &transport, 1) + .replacen(DISCOVERY_ENCRYPTED, &discovery, 1); + assert!(!source.contains(TRANSPORT_ENCRYPTED)); + assert!(!source.contains(DISCOVERY_ENCRYPTED)); + source +} + +fn all_local_configuration(root: &Path) -> MycConfigDocumentV1 { + parse_myc_config_v1( + all_local_source(root).as_bytes(), + MycConfigProfile::RepoLocal, + ) + .expect("all-local configuration") +} + +fn example_configuration() -> MycConfigDocumentV1 { + parse_myc_config_v1(CONFIG_EXAMPLE.as_bytes(), MycConfigProfile::RepoLocal) + .expect("example configuration") +} + +fn metadata( + runtime: &myc::MycRuntimeContext, + configuration: &MycConfigDocumentV1, +) -> MycStateMetadata { + MycStateMetadata::new( + runtime, + configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) + .expect("metadata") +} + +fn migration_evidence() -> (MigrationAppliedAtUnixSeconds, MigrationBuildIdentity) { + let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("migration time"); + let build = MigrationBuildIdentity::new( + env!("CARGO_PKG_VERSION"), + "1111111111111111111111111111111111111111", + "b44119fbac5985be8127ad1bf56d2950e6399427", + "rustc-test", + "test-target", + "service-host", + 1, + myc::MYC_STATE_SCHEMA_VERSION, + 1, + 1, + 1, + ) + .expect("build identity"); + (applied_at, build) +} + +#[tokio::test] +async fn foundation_owns_existing_state_and_never_claims_unproven_readiness() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let configuration = all_local_configuration(directory.path()); + let state_metadata = metadata(&runtime, &configuration); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &state_metadata, applied_at, &build) + .await + .expect("state initialization"); + + let foundation = open_myc_runtime_foundation( + runtime.clone(), + configuration, + state_metadata.clone(), + applied_at, + &build, + ) + .await + .expect("existing-state foundation"); + for role in [ + MycProviderRole::Transport, + MycProviderRole::User, + MycProviderRole::Discovery, + ] { + assert_eq!( + foundation.provider_kind(role), + Some(MycProviderKind::LocalSigner) + ); + } + assert!(!foundation.readiness().readiness().is_ready()); + assert_eq!( + foundation.readiness().required(), + [ + MycRuntimePrerequisite::ExistingState, + MycRuntimePrerequisite::TransportProvider, + MycRuntimePrerequisite::UserProvider, + MycRuntimePrerequisite::DiscoveryProvider, + MycRuntimePrerequisite::OutboxRecovery, + MycRuntimePrerequisite::RequiredRelayConnectivity, + MycRuntimePrerequisite::RequiredRelaySubscription, + MycRuntimePrerequisite::AdminListener, + ] + ); + assert_eq!( + foundation.readiness().satisfied(), + [MycRuntimePrerequisite::ExistingState] + ); + assert_eq!( + foundation + .readiness() + .reasons() + .as_slice() + .iter() + .map(|reason| reason.as_str()) + .collect::<Vec<_>>(), + [ + "admin_listener_failed", + "outbox_invariant_failed", + "required_relay_unavailable", + "signer_provider_unavailable", + "subscriber_not_active", + ] + ); + + let rendered = format!("{foundation:?}"); + assert!(!rendered.contains(directory.path().to_string_lossy().as_ref())); + assert!(!rendered.contains("transport-signer.sock")); + assert!(!rendered.contains("4444444444444444")); + + let contended = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build) + .await + .expect_err("foundation retains writer authority"); + assert_eq!(contended.kind(), myc::MycStateHostErrorKind::ReadWriteOpen); + + foundation.shutdown().await.expect("joined shutdown"); + let reopened = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build) + .await + .expect("authority released after joined shutdown"); + reopened.close().await.expect("reopened state close"); +} + +#[tokio::test] +async fn missing_state_and_configuration_mismatch_fail_without_mutation_or_lock_leak() { + let missing_directory = tempfile::tempdir().expect("missing temporary root"); + let missing_runtime = runtime(missing_directory.path()); + prepare_state_directory(&missing_runtime); + let missing_configuration = all_local_configuration(missing_directory.path()); + let missing_metadata = metadata(&missing_runtime, &missing_configuration); + let (applied_at, build) = migration_evidence(); + let missing = open_myc_runtime_foundation( + missing_runtime.clone(), + missing_configuration, + missing_metadata, + applied_at, + &build, + ) + .await + .expect_err("run never initializes missing state"); + assert_eq!(missing.kind(), MycRuntimeFoundationErrorKind::StateOpen); + assert!(!missing_runtime.artifacts().state_database().exists()); + + let directory = tempfile::tempdir().expect("mismatch temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let original = all_local_configuration(directory.path()); + let state_metadata = metadata(&runtime, &original); + initialize_myc_state(&runtime, &state_metadata, applied_at, &build) + .await + .expect("state initialization"); + let changed_source = CONFIG_EXAMPLE.replacen("level = \"info\"", "level = \"debug\"", 1); + let changed = parse_myc_config_v1(changed_source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("changed configuration"); + let mismatch = open_myc_runtime_foundation( + runtime.clone(), + changed, + state_metadata.clone(), + applied_at, + &build, + ) + .await + .expect_err("configuration digest mismatch"); + assert_eq!( + mismatch.kind(), + MycRuntimeFoundationErrorKind::InvalidBinding + ); + assert!(Error::source(&mismatch).is_none()); + + let reopened = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build) + .await + .expect("mismatch fails before writer acquisition"); + reopened.close().await.expect("reopened state close"); +} + +#[tokio::test] +async fn joined_encrypted_provider_failure_closes_the_already_open_state() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + let configuration = example_configuration(); + let state_metadata = metadata(&runtime, &configuration); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &state_metadata, applied_at, &build) + .await + .expect("state initialization"); + + let failure = open_myc_runtime_foundation( + runtime.clone(), + configuration, + state_metadata.clone(), + applied_at, + &build, + ) + .await + .expect_err("missing credential artifacts fail provider startup"); + assert_eq!(failure.kind(), MycRuntimeFoundationErrorKind::Provider); + assert!(Error::source(&failure).is_none()); + + let reopened = open_myc_state_read_write(&runtime, &state_metadata, applied_at, &build) + .await + .expect("failed provider startup closes state"); + reopened.close().await.expect("reopened state close"); +} + +#[tokio::test] +async fn joined_encrypted_provider_success_is_retained_as_proven_startup_evidence() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path()); + prepare_state_directory(&runtime); + + let identity_secret = [1_u8; 32]; + let public_key = Keys::new(SecretKey::from_slice(&identity_secret).expect("identity secret")) + .public_key() + .to_hex(); + let envelope_parent = directory.path().join("envelopes"); + fs::create_dir(&envelope_parent).expect("envelope parent"); + fs::set_permissions(&envelope_parent, fs::Permissions::from_mode(0o700)) + .expect("envelope parent mode"); + let envelope_path = envelope_parent.join("transport.identity.ncrypt"); + let transport_encrypted = format!( + r#"[identity.transport] +provider = "encrypted_file" +envelope_path = "{}" +credential_reference = "transport_wrapping_key" +expected_public_key = "{}""#, + envelope_path.display(), + public_key + ); + let source = all_local_source(directory.path()).replacen( + &local_transport_block(directory.path()), + &transport_encrypted, + 1, + ); + let configuration = parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal) + .expect("mixed provider configuration"); + let binding = configuration + .provider_contract() + .binding(MycProviderRole::Transport) + .expect("transport binding"); + + let secrets = runtime.context().paths().secrets(); + fs::create_dir_all(secrets).expect("secrets directory"); + fs::set_permissions(secrets, fs::Permissions::from_mode(0o700)) + .expect("secrets directory mode"); + let credential_path = secrets.join("transport_wrapping_key"); + fs::write(&credential_path, [9_u8; 32]).expect("offline credential fixture"); + fs::set_permissions(&credential_path, fs::Permissions::from_mode(0o600)) + .expect("credential mode"); + let credential = + resolve_myc_wrapping_credential(&runtime, binding).expect("credential resolution"); + let material = MycEncryptedIdentityProvisioningMaterial::new( + identity_secret, + [2_u8; 32], + [3_u8; 24], + [4_u8; 24], + ) + .expect("provisioning material"); + provision_myc_encrypted_identity(binding, &credential, material) + .expect("offline envelope provisioning"); + + let state_metadata = metadata(&runtime, &configuration); + let (applied_at, build) = migration_evidence(); + initialize_myc_state(&runtime, &state_metadata, applied_at, &build) + .await + .expect("state initialization"); + let foundation = + open_myc_runtime_foundation(runtime, configuration, state_metadata, applied_at, &build) + .await + .expect("joined encrypted-provider startup"); + assert_eq!( + foundation.provider_kind(MycProviderRole::Transport), + Some(MycProviderKind::EncryptedFile) + ); + assert!( + foundation + .readiness() + .satisfied() + .contains(&MycRuntimePrerequisite::TransportProvider) + ); + assert!(!foundation.readiness().readiness().is_ready()); + foundation.shutdown().await.expect("joined shutdown"); +} + +#[test] +fn machine_contract_and_source_keep_the_foundation_sealed_and_deferred() { + let contract: serde_json::Value = + serde_json::from_str(CONTRACT_SOURCE).expect("runtime-foundation contract"); + assert_eq!( + contract, + json!({ + "schema": "radroots.myc.runtime-foundation", + "schema_version": 1, + "contract_version": 1, + "state_open": { + "mode": "read_write_existing", + "initialize_if_missing": false, + "raw_sqlite_authority_exposed": false + }, + "provider_startup": { + "encrypted_file": "supervised_joined_one_shot", + "local_signer": "constructed_without_io_pending_handshake", + "database_transaction_held": false, + "detached_tasks": false, + "protected_values_exposed": false + }, + "readiness_prerequisites": [ + { "id": "existing_state", "condition": "always", "reason": "database_schema_mismatch" }, + { "id": "transport_provider", "condition": "always", "reason": "signer_provider_unavailable" }, + { "id": "user_provider", "condition": "always", "reason": "signer_provider_unavailable" }, + { "id": "discovery_provider", "condition": "discovery_enabled", "reason": "signer_provider_unavailable" }, + { "id": "outbox_recovery", "condition": "always", "reason": "outbox_invariant_failed" }, + { "id": "required_relay_connectivity", "condition": "required_relay_present", "reason": "required_relay_unavailable" }, + { "id": "required_relay_subscription", "condition": "required_read_relay_present", "reason": "subscriber_not_active" }, + { "id": "admin_listener", "condition": "always", "reason": "admin_listener_failed" }, + { "id": "operations_listener", "condition": "operations_enabled", "reason": "operations_listener_failed" } + ], + "initial_satisfaction": { + "existing_state": "after_exact_existing_open", + "encrypted_file_provider": "after_joined_identity_verification", + "local_signer_provider": "not_before_verified_describe_handshake", + "remaining_prerequisites": "later_owning_rcld" + }, + "task_ownership": { + "shared_supervisor": "radroots_service_host::TaskSupervisor", + "startup_tasks": "one_shot", + "lifetime_task": "critical_until_cancellation", + "task_handles_exposed": false, + "library_runtime_creation": false, + "signal_installation": false, + "process_exit": false + }, + "deferred": [ + "provider_handshake", + "outbox_recovery", + "relay_connectivity", + "relay_subscription", + "admin_listener", + "operations_listener", + "cached_status", + "signals", + "final_supervised_task_graph" + ] + }) + ); + + assert!(LIB_SOURCE.contains("mod runtime_foundation;")); + assert!(!LIB_SOURCE.contains("pub mod runtime_foundation;")); + for required in [ + "TaskSupervisor::new()", + "TaskClassification::OneShot", + "thread.join()", + "open_myc_state_read_write", + ] { + assert!( + FOUNDATION_SOURCE.contains(required), + "missing required foundation boundary `{required}`" + ); + } + for forbidden in [ + "tokio::spawn", + "spawn_blocking", + "JoinHandle", + "Runtime::new", + "process::exit", + "pub fn pool", + "pub fn connection", + "AdminServer::", + "TcpListener", + ] { + assert!( + !FOUNDATION_SOURCE.contains(forbidden), + "found deferred or escaping authority `{forbidden}`" + ); + } +}