myc

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

commit 1be8aa782864713f5011a2f0298e24262551c634
parent 681277a35eb026989eb68fd5c7defbd36a30e4c9
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 14:04:56 +0000

myc: bind state metadata

- derive sealed Myc state identity from runtime and normalized config\n- reject invalid configured x-only public keys and freeze exact metadata vectors\n- require Myc metadata at shared state-host lifecycle boundaries

Diffstat:
MCargo.toml | 2+-
Mcontracts/services_hardening/config.v1.example.toml | 6+++---
Mcontracts/services_hardening/config.v1.schema.json | 1+
Msrc/config_v1.rs | 28++++++++++++++++++++++------
Msrc/lib.rs | 6++++++
Msrc/state_host.rs | 58+++++++++++++++++++++++++++++-----------------------------
Asrc/state_metadata.rs | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 2+-
Mtests/services_hardening_config_contract.rs | 20++++++++++++++++++++
Mtests/services_hardening_config_parser.rs | 2+-
Mtests/services_hardening_state_host.rs | 40+++++++++++++++++++---------------------
Atests/services_hardening_state_metadata.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 684 insertions(+), 62 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -47,6 +47,7 @@ radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "b radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha" } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std", "keyring"] } radroots_signing = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", features = ["std"] } +radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" @@ -65,6 +66,5 @@ zeroize = "1.8" [dev-dependencies] futures-util = "0.3.32" -radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "b44119fbac5985be8127ad1bf56d2950e6399427", version = "=0.1.0-alpha", default-features = false } serial_test = "3" tokio-tungstenite = "0.26.2" diff --git a/contracts/services_hardening/config.v1.example.toml b/contracts/services_hardening/config.v1.example.toml @@ -20,7 +20,7 @@ minimum_free_bytes = 268435456 provider = "encrypted_file" envelope_path = "/var/lib/radroots/services/myc/primary/secrets/transport.identity.ncrypt" credential_reference = "transport_wrapping_key" -expected_public_key = "1111111111111111111111111111111111111111111111111111111111111111" +expected_public_key = "4444444444444444444444444444444444444444444444444444444444444444" [identity.user] provider = "local_signer" @@ -70,8 +70,8 @@ attempt_deadline_ms = 15000 [policy] connection_approval = "explicit_user" -trusted_clients = ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] -denied_clients = ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] +trusted_clients = ["7777777777777777777777777777777777777777777777777777777777777777"] +denied_clients = ["8888888888888888888888888888888888888888888888888888888888888888"] permission_ceiling = ["nip04_decrypt", "nip04_encrypt", "nip44_decrypt", "nip44_encrypt", "sign_event:kind:1"] allowed_sign_event_kinds = [1] diff --git a/contracts/services_hardening/config.v1.schema.json b/contracts/services_hardening/config.v1.schema.json @@ -473,6 +473,7 @@ "at_least_one_write_relay", "at_least_one_required_read_relay", "at_least_one_required_write_relay", + "configured_public_keys_are_valid_x_only_secp256k1", "enabled_role_expected_public_keys_unique", "trusted_and_denied_clients_disjoint", "permission_sign_event_kinds_equal_allowed_sign_event_kinds", diff --git a/src/config_v1.rs b/src/config_v1.rs @@ -5,6 +5,7 @@ use std::error::Error; use std::fmt; use std::net::SocketAddr; +use nostr::PublicKey; use serde::Serialize; use serde_json::{Map, Value, json}; use url::Url; @@ -186,6 +187,10 @@ impl MycConfigDocumentV1 { .and_then(Value::as_array) .map_or(0, Vec::len) } + + pub(crate) const fn normalized(&self) -> &Value { + &self.normalized + } } impl fmt::Debug for MycConfigDocumentV1 { @@ -692,7 +697,9 @@ fn validate_unique_role_keys(document: &Value) -> Result<(), MycConfigV1Error> { "/identity/discovery/binding/expected_public_key", )?); } - if keys.iter().copied().collect::<BTreeSet<_>>().len() != keys.len() { + if keys.iter().any(|key| !valid_nostr_public_key(key)) + || keys.iter().copied().collect::<BTreeSet<_>>().len() != keys.len() + { return relationship_error(); } Ok(()) @@ -701,7 +708,12 @@ fn validate_unique_role_keys(document: &Value) -> Result<(), MycConfigV1Error> { fn validate_client_policy(document: &Value) -> Result<(), MycConfigV1Error> { let trusted = string_set(document, "/policy/trusted_clients")?; let denied = string_set(document, "/policy/denied_clients")?; - if !trusted.is_disjoint(&denied) { + if trusted + .iter() + .chain(denied.iter()) + .any(|key| !valid_nostr_public_key(key)) + || !trusted.is_disjoint(&denied) + { return relationship_error(); } let permission_kinds = string_set(document, "/policy/permission_ceiling")? @@ -723,6 +735,10 @@ fn validate_client_policy(document: &Value) -> Result<(), MycConfigV1Error> { Ok(()) } +fn valid_nostr_public_key(value: &str) -> bool { + PublicKey::from_hex(value).is_ok_and(|public_key| public_key.xonly().is_ok()) +} + fn validate_challenges(document: &Value) -> Result<(), MycConfigV1Error> { if bool_value(document, "/policy/challenges/enabled")? && integer(document, "/policy/challenges/authorized_lifetime_ms")? @@ -1062,8 +1078,8 @@ mod tests { for forbidden in [ "/var/lib/radroots", "/run/radroots", - "1111111111111111", - "aaaaaaaaaaaaaaaa", + "4444444444444444", + "7777777777777777", "relay-primary.example.test", "myc.example.test", "transport_wrapping_key", @@ -1207,8 +1223,8 @@ mod tests { ), replace( EXAMPLE, - "trusted_clients = [\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"]", - "trusted_clients = [\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]", + "trusted_clients = [\"7777777777777777777777777777777777777777777777777777777777777777\"]", + "trusted_clients = [\"8888888888888888888888888888888888888888888888888888888888888888\"]", ), replace( EXAMPLE, diff --git a/src/lib.rs b/src/lib.rs @@ -27,6 +27,7 @@ mod signing_adapter; pub mod sql; mod state_catalog; mod state_host; +mod state_metadata; pub mod transport; pub use app::{ @@ -116,4 +117,9 @@ pub use state_host::{ MycStateHost, MycStateHostError, MycStateHostErrorKind, MycStateHostMode, initialize_myc_state, open_myc_state_inspection, open_myc_state_read_write, }; +pub use state_metadata::{ + MYC_OPERATOR_CONTRACT_VERSION, MYC_STATE_APPLICATION_ID, MycExpectedIdentities, + MycExpectedPublicIdentity, MycNormalizedConfigDigest, MycStateMetadata, MycStateMetadataError, + MycStateMetadataErrorKind, MycStatePolicyVersions, +}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; diff --git a/src/state_host.rs b/src/state_host.rs @@ -4,15 +4,14 @@ use core::fmt; use std::{error::Error, path::PathBuf}; use radroots_service_sqlite::{ - MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, ServiceDatabaseIdentity, - ServiceDatabaseMetadata, ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, - initialize_database, + MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, OpenMode, + ServiceSqliteConnectionOptions, ServiceSqliteHost, ServiceSqlitePaths, initialize_database, }; use sqlx::{ConnectOptions, Connection, SqliteConnection, sqlite::SqliteConnectOptions}; use crate::{ - MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, myc_migration_catalog, myc_schema_catalog, - validate_myc_state_catalogs, + MYC_STATE_SCHEMA_VERSION, MycRuntimeContext, MycStateMetadata, myc_migration_catalog, + myc_schema_catalog, validate_myc_state_catalogs, }; /// Stable lifecycle mode of one opened Myc state host. @@ -124,6 +123,7 @@ impl Error for MycStateHostError {} pub struct MycStateHost { host: ServiceSqliteHost, mode: MycStateHostMode, + metadata: MycStateMetadata, } impl MycStateHost { @@ -133,6 +133,12 @@ impl MycStateHost { self.mode } + /// Returns the immutable Myc metadata bound to this host session. + #[must_use] + pub const fn metadata(&self) -> &MycStateMetadata { + &self.metadata + } + /// Drains the shared host and explicitly releases retained authority. pub async fn close(&self) -> Result<(), MycStateHostError> { self.host @@ -159,7 +165,7 @@ impl fmt::Debug for MycStateHost { /// its exact application and configuration bindings. pub async fn initialize_myc_state( runtime: &MycRuntimeContext, - metadata: &ServiceDatabaseMetadata, + metadata: &MycStateMetadata, ) -> Result<(), MycStateHostError> { let paths = state_paths(runtime)?; require_metadata(runtime, metadata)?; @@ -167,7 +173,7 @@ pub async fn initialize_myc_state( let mut authority = initialize_database( &paths, OpenMode::Initialize, - metadata, + metadata.database(), &schema, initialize_empty_catalog, ) @@ -187,16 +193,17 @@ pub async fn initialize_myc_state( /// empty. pub async fn open_myc_state_read_write( runtime: &MycRuntimeContext, - identity: &ServiceDatabaseIdentity, + metadata: &MycStateMetadata, applied_at: MigrationAppliedAtUnixSeconds, build: &MigrationBuildIdentity, ) -> Result<MycStateHost, MycStateHostError> { let paths = state_paths(runtime)?; - require_identity(runtime, identity)?; + require_metadata(runtime, metadata)?; + let identity = metadata.database_identity(); let (migrations, schema) = catalogs()?; let (host, outcome) = ServiceSqliteHost::open_read_write_existing( &paths, - identity, + &identity, &migrations, &schema, ServiceSqliteConnectionOptions::reviewed(), @@ -216,20 +223,22 @@ pub async fn open_myc_state_read_write( Ok(MycStateHost { host, mode: MycStateHostMode::ReadWriteExisting, + metadata: metadata.clone(), }) } /// Opens an already initialized Myc catalog for immutable inspection. pub async fn open_myc_state_inspection( runtime: &MycRuntimeContext, - identity: &ServiceDatabaseIdentity, + metadata: &MycStateMetadata, ) -> Result<MycStateHost, MycStateHostError> { let paths = state_paths(runtime)?; - require_identity(runtime, identity)?; + require_metadata(runtime, metadata)?; + let identity = metadata.database_identity(); let (migrations, schema) = catalogs()?; let host = ServiceSqliteHost::open_read_only_inspection( &paths, - identity, + &identity, &migrations, &schema, ServiceSqliteConnectionOptions::reviewed(), @@ -239,6 +248,7 @@ pub async fn open_myc_state_inspection( Ok(MycStateHost { host, mode: MycStateHostMode::ReadOnlyInspection, + metadata: metadata.clone(), }) } @@ -249,23 +259,13 @@ fn state_paths(runtime: &MycRuntimeContext) -> Result<ServiceSqlitePaths, MycSta fn require_metadata( runtime: &MycRuntimeContext, - metadata: &ServiceDatabaseMetadata, -) -> Result<(), MycStateHostError> { - let matches = metadata.service() == runtime.context().service() - && metadata.instance() == runtime.context().instance() - && metadata.state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; - matches - .then_some(()) - .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) -} - -fn require_identity( - runtime: &MycRuntimeContext, - identity: &ServiceDatabaseIdentity, + metadata: &MycStateMetadata, ) -> Result<(), MycStateHostError> { - let matches = identity.service() == runtime.context().service() - && identity.instance() == runtime.context().instance() - && identity.supported_state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; + let database = metadata.database(); + let matches = metadata.matches_runtime(runtime) + && database.service() == runtime.context().service() + && database.instance() == runtime.context().instance() + && database.state_schema_version().get() == MYC_STATE_SCHEMA_VERSION; matches .then_some(()) .ok_or_else(|| MycStateHostError::new(MycStateHostErrorKind::InvalidEvidence)) diff --git a/src/state_metadata.rs b/src/state_metadata.rs @@ -0,0 +1,380 @@ +//! Immutable Myc-specific identity and policy evidence for one state host. + +use core::fmt; +use std::error::Error; + +use nostr::PublicKey; +use radroots_service_sqlite::{ + ServiceDatabaseIdentity, ServiceDatabaseMetadata, ServiceSqliteApplicationId, + ServiceSqlitePaths, +}; +use radroots_storage::event::SourceGeneration; +use sha2::{Digest, Sha256}; + +use crate::{ + MYC_CONFIG_SCHEMA_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, MYC_STATE_SCHEMA_VERSION, + MycBootstrapProfileV1, MycConfigDocumentV1, MycConfigProfile, MycRuntimeContext, +}; + +const NORMALIZED_CONFIG_DIGEST_DOMAIN: &[u8] = b"radroots.myc.normalized_config.v1\0"; + +/// SQLite application identity for Myc, encoded as ASCII `RDMY`. +pub const MYC_STATE_APPLICATION_ID: u32 = 0x5244_4d59; + +/// Exact version of the governed Myc operator contract. +pub const MYC_OPERATOR_CONTRACT_VERSION: u32 = 1; + +/// SHA-256 identity of one fully defaulted normalized Myc configuration. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MycNormalizedConfigDigest([u8; 32]); + +impl MycNormalizedConfigDigest { + /// Returns the exact digest bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for MycNormalizedConfigDigest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycNormalizedConfigDigest([redacted])") + } +} + +/// One validated canonical expected Nostr public identity. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MycExpectedPublicIdentity(Box<str>); + +impl MycExpectedPublicIdentity { + /// Returns the canonical lowercase 32-byte x-only public key in hex. + #[must_use] + pub fn as_hex(&self) -> &str { + &self.0 + } + + fn from_hex(value: &str) -> Result<Self, MycStateMetadataError> { + let public_key = PublicKey::from_hex(value) + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Identity))?; + public_key + .xonly() + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Identity))?; + let canonical = public_key.to_hex(); + if canonical != value { + return Err(MycStateMetadataError::new( + MycStateMetadataErrorKind::Identity, + )); + } + Ok(Self(canonical.into_boxed_str())) + } +} + +impl fmt::Debug for MycExpectedPublicIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("MycExpectedPublicIdentity([redacted])") + } +} + +/// Exact expected identities for every enabled Myc role. +#[derive(Clone, PartialEq, Eq)] +pub struct MycExpectedIdentities { + transport: MycExpectedPublicIdentity, + user: MycExpectedPublicIdentity, + discovery: Option<MycExpectedPublicIdentity>, +} + +impl MycExpectedIdentities { + /// Returns the required transport identity. + #[must_use] + pub const fn transport(&self) -> &MycExpectedPublicIdentity { + &self.transport + } + + /// Returns the required user identity. + #[must_use] + pub const fn user(&self) -> &MycExpectedPublicIdentity { + &self.user + } + + /// Returns the discovery identity only when discovery is enabled. + #[must_use] + pub const fn discovery(&self) -> Option<&MycExpectedPublicIdentity> { + self.discovery.as_ref() + } +} + +impl fmt::Debug for MycExpectedIdentities { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycExpectedIdentities") + .field("transport", &"[redacted]") + .field("user", &"[redacted]") + .field("discovery", &self.discovery.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +/// Exact contract versions bound to one Myc state-host session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MycStatePolicyVersions { + configuration: u32, + state: u32, + operator: u32, + status: u32, +} + +impl MycStatePolicyVersions { + const fn governed() -> Self { + Self { + configuration: MYC_CONFIG_SCHEMA_VERSION, + state: MYC_STATE_SCHEMA_VERSION, + operator: MYC_OPERATOR_CONTRACT_VERSION, + status: MYC_SIGNER_STATUS_CONTRACT_VERSION, + } + } + + #[must_use] + /// Returns the exact configuration-contract version. + pub const fn configuration(self) -> u32 { + self.configuration + } + + #[must_use] + /// Returns the exact state-schema version. + pub const fn state(self) -> u32 { + self.state + } + + #[must_use] + /// Returns the exact operator-contract version. + pub const fn operator(self) -> u32 { + self.operator + } + + #[must_use] + /// Returns the exact signer-status contract version. + pub const fn status(self) -> u32 { + self.status + } +} + +/// Non-forgeable Myc metadata bound to one runtime context and configuration. +#[derive(Clone, PartialEq, Eq)] +pub struct MycStateMetadata { + paths: ServiceSqlitePaths, + database: ServiceDatabaseMetadata, + configuration: MycNormalizedConfigDigest, + identities: MycExpectedIdentities, + policy_versions: MycStatePolicyVersions, +} + +impl MycStateMetadata { + /// Derives all state evidence from one sealed runtime context, one admitted + /// normalized configuration, and caller-injected generation/time evidence. + pub fn new( + runtime: &MycRuntimeContext, + configuration: &MycConfigDocumentV1, + source_generation: SourceGeneration, + created_at_unix_ms: u64, + ) -> Result<Self, MycStateMetadataError> { + require_profile_binding(runtime.profile(), configuration.profile())?; + let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()) + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Paths))?; + let application_id = ServiceSqliteApplicationId::new(MYC_STATE_APPLICATION_ID) + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?; + let state_schema_version = core::num::NonZeroU32::new(MYC_STATE_SCHEMA_VERSION) + .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Invariant))?; + let database = ServiceDatabaseMetadata::new( + &paths, + source_generation, + state_schema_version, + created_at_unix_ms, + application_id, + ) + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Database))?; + let normalized = configuration.normalized(); + let configuration = normalized_config_digest(configuration.profile(), normalized)?; + let identities = expected_identities(normalized)?; + let policy_versions = MycStatePolicyVersions::governed(); + if [ + policy_versions.configuration, + policy_versions.state, + policy_versions.operator, + policy_versions.status, + ] + .contains(&0) + { + return Err(MycStateMetadataError::new( + MycStateMetadataErrorKind::Invariant, + )); + } + Ok(Self { + paths, + database, + configuration, + identities, + policy_versions, + }) + } + + /// Returns the shared immutable database metadata. + #[must_use] + pub const fn database(&self) -> &ServiceDatabaseMetadata { + &self.database + } + + /// Returns the reopen identity derived from the immutable database metadata. + #[must_use] + pub fn database_identity(&self) -> ServiceDatabaseIdentity { + self.database.identity() + } + + /// Returns the normalized configuration digest. + #[must_use] + pub const fn configuration_digest(&self) -> MycNormalizedConfigDigest { + self.configuration + } + + /// Returns the exact configured identity-role bindings. + #[must_use] + pub const fn expected_identities(&self) -> &MycExpectedIdentities { + &self.identities + } + + /// Returns the exact governed policy versions. + #[must_use] + pub const fn policy_versions(&self) -> MycStatePolicyVersions { + self.policy_versions + } + + pub(crate) fn matches_runtime(&self, runtime: &MycRuntimeContext) -> bool { + ServiceSqlitePaths::from_runtime_context(runtime.context()) + .is_ok_and(|paths| paths == self.paths) + } +} + +impl fmt::Debug for MycStateMetadata { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateMetadata") + .field("database", &self.database) + .field("configuration", &self.configuration) + .field("identities", &self.identities) + .field("policy_versions", &self.policy_versions) + .field("paths", &"[redacted]") + .finish() + } +} + +/// Stable source-free class for invalid Myc state metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycStateMetadataErrorKind { + Profile, + Paths, + Configuration, + Identity, + Database, + Invariant, +} + +/// Source-free Myc state-metadata construction failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycStateMetadataError { + kind: MycStateMetadataErrorKind, +} + +impl MycStateMetadataError { + const fn new(kind: MycStateMetadataErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> MycStateMetadataErrorKind { + self.kind + } +} + +impl fmt::Display for MycStateMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self.kind { + MycStateMetadataErrorKind::Profile => "Myc configuration profile is inconsistent", + MycStateMetadataErrorKind::Paths => "Myc state metadata paths are invalid", + MycStateMetadataErrorKind::Configuration => { + "Myc normalized configuration identity is invalid" + } + MycStateMetadataErrorKind::Identity => "Myc expected identity binding is invalid", + MycStateMetadataErrorKind::Database => "Myc database metadata is invalid", + MycStateMetadataErrorKind::Invariant => "Myc metadata contract is invalid", + }) + } +} + +impl fmt::Debug for MycStateMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycStateMetadataError") + .field("kind", &self.kind) + .finish() + } +} + +impl Error for MycStateMetadataError {} + +fn require_profile_binding( + runtime: MycBootstrapProfileV1, + configuration: MycConfigProfile, +) -> Result<(), MycStateMetadataError> { + let matches = match runtime { + MycBootstrapProfileV1::ServiceHost | MycBootstrapProfileV1::Interactive => { + configuration == MycConfigProfile::Production + } + MycBootstrapProfileV1::RepoLocal => configuration == MycConfigProfile::RepoLocal, + }; + matches + .then_some(()) + .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Profile)) +} + +fn normalized_config_digest( + profile: MycConfigProfile, + normalized: &serde_json::Value, +) -> Result<MycNormalizedConfigDigest, MycStateMetadataError> { + let bytes = serde_json::to_vec(normalized) + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Configuration))?; + let length = u64::try_from(bytes.len()) + .map_err(|_| MycStateMetadataError::new(MycStateMetadataErrorKind::Configuration))?; + let mut hasher = Sha256::new(); + hasher.update(NORMALIZED_CONFIG_DIGEST_DOMAIN); + hasher.update([match profile { + MycConfigProfile::Production => 0, + MycConfigProfile::RepoLocal => 1, + }]); + hasher.update(length.to_be_bytes()); + hasher.update(bytes); + Ok(MycNormalizedConfigDigest(hasher.finalize().into())) +} + +fn expected_identities( + normalized: &serde_json::Value, +) -> Result<MycExpectedIdentities, MycStateMetadataError> { + let identity = |pointer: &str| { + normalized + .pointer(pointer) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Identity)) + .and_then(MycExpectedPublicIdentity::from_hex) + }; + let discovery_enabled = normalized + .pointer("/identity/discovery/enabled") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| MycStateMetadataError::new(MycStateMetadataErrorKind::Identity))?; + Ok(MycExpectedIdentities { + transport: identity("/identity/transport/expected_public_key")?, + user: identity("/identity/user/expected_public_key")?, + discovery: if discovery_enabled { + Some(identity("/identity/discovery/binding/expected_public_key")?) + } else { + None + }, + }) +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -41,7 +41,7 @@ fn shared_service_sqlite_is_exactly_pinned_to_the_source_locked_lib() { } #[test] -fn shared_storage_test_evidence_is_exactly_pinned_to_the_source_locked_lib() { +fn shared_storage_evidence_is_exactly_pinned_to_the_source_locked_lib() { assert!(MANIFEST.contains( "radroots_storage = { git = \"https://github.com/radrootslabs/lib\", rev = \"b44119fbac5985be8127ad1bf56d2950e6399427\", version = \"=0.1.0-alpha\", default-features = false }" )); diff --git a/tests/services_hardening_config_contract.rs b/tests/services_hardening_config_contract.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::net::SocketAddr; +use nostr::PublicKey; use serde_json::{Map, Value, json}; const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); @@ -130,6 +131,14 @@ fn semantic_valid(value: &Value, profile: Profile) -> bool { if expected_role_keys.iter().collect::<BTreeSet<_>>().len() != expected_role_keys.len() { return false; } + if expected_role_keys + .iter() + .chain(trusted.iter()) + .chain(denied.iter()) + .any(|key| PublicKey::from_hex(key).map_or(true, |public_key| public_key.xonly().is_err())) + { + return false; + } let permitted_kinds = string_array(value, "/policy/permission_ceiling") .into_iter() @@ -640,6 +649,17 @@ fn bounds_relationships_and_conditional_authority_fail_closed() { let mut overlap = value.clone(); overlap["policy"]["denied_clients"] = overlap["policy"]["trusted_clients"].clone(); assert_rejected(&overlap, Profile::Production); + for pointer in [ + "/identity/transport/expected_public_key", + "/identity/user/expected_public_key", + "/identity/discovery/binding/expected_public_key", + "/policy/trusted_clients/0", + "/policy/denied_clients/0", + ] { + let mut invalid_key = value.clone(); + *invalid_key.pointer_mut(pointer).expect("public key") = json!("f".repeat(64)); + assert_rejected(&invalid_key, Profile::Production); + } let mut permission_mismatch = value.clone(); permission_mismatch["policy"]["allowed_sign_event_kinds"] = json!([1, 7]); assert_rejected(&permission_mismatch, Profile::Production); diff --git a/tests/services_hardening_config_parser.rs b/tests/services_hardening_config_parser.rs @@ -24,7 +24,7 @@ fn root_api_admits_the_canonical_document_and_exposes_only_redacted_effective_ou "/var/lib/radroots", "/run/radroots", "relay-primary.example.test", - "1111111111111111", + "4444444444444444", "transport_wrapping_key", ] { assert!(!effective.contains(forbidden)); diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs @@ -1,21 +1,21 @@ #![forbid(unsafe_code)] #![cfg(any(target_os = "linux", target_os = "macos"))] -use std::{error::Error, fs, num::NonZeroU32, os::unix::fs::PermissionsExt, path::Path}; +use std::{error::Error, fs, os::unix::fs::PermissionsExt, path::Path}; use myc::{ - MYC_STATE_SCHEMA_VERSION, MycStateHostErrorKind, MycStateHostMode, RadrootsHostEnvironment, - RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, open_myc_state_inspection, - open_myc_state_read_write, parse_myc_cli_v1_from, resolve_myc_runtime_context, -}; -use radroots_service_sqlite::{ - MigrationAppliedAtUnixSeconds, MigrationBuildIdentity, ServiceDatabaseMetadata, - ServiceSqliteApplicationId, ServiceSqlitePaths, + MycConfigProfile, MycStateHostErrorKind, MycStateHostMode, MycStateMetadata, + RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, initialize_myc_state, + open_myc_state_inspection, open_myc_state_read_write, parse_myc_cli_v1_from, + parse_myc_config_v1, resolve_myc_runtime_context, }; +use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity}; use radroots_storage::event::SourceGeneration; const HOST_SOURCE: &str = include_str!("../src/state_host.rs"); const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const CONFIG_EXAMPLE: &[u8] = + include_bytes!("../contracts/services_hardening/config.v1.example.toml"); fn runtime(root: &Path, instance: &str) -> myc::MycRuntimeContext { let root = root.to_str().expect("UTF-8 temporary root"); @@ -43,14 +43,14 @@ fn prepare_state_directory(runtime: &myc::MycRuntimeContext) { fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).expect("state mode"); } -fn metadata(runtime: &myc::MycRuntimeContext) -> ServiceDatabaseMetadata { - let paths = ServiceSqlitePaths::from_runtime_context(runtime.context()).expect("SQLite paths"); - ServiceDatabaseMetadata::new( - &paths, +fn metadata(runtime: &myc::MycRuntimeContext) -> MycStateMetadata { + let configuration = + parse_myc_config_v1(CONFIG_EXAMPLE, MycConfigProfile::RepoLocal).expect("configuration"); + MycStateMetadata::new( + runtime, + &configuration, SourceGeneration::new([0x5a; 32]).expect("generation"), - NonZeroU32::new(MYC_STATE_SCHEMA_VERSION).expect("schema version"), 1_725_000_000_000, - ServiceSqliteApplicationId::new(0x4d59_4331).expect("test application ID"), ) .expect("metadata") } @@ -80,7 +80,6 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( let runtime = runtime(directory.path(), "primary"); prepare_state_directory(&runtime); let metadata = metadata(&runtime); - let identity = metadata.identity(); let state = runtime.artifacts().state_database(); let lock = runtime.artifacts().state_lock(); @@ -105,7 +104,7 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( assert_eq!(duplicate.kind(), MycStateHostErrorKind::Initialize); let (applied_at, build) = migration_evidence(); - let writer = open_myc_state_read_write(&runtime, &identity, applied_at, &build) + let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) .await .expect("existing writable state"); assert_eq!(writer.mode(), MycStateHostMode::ReadWriteExisting); @@ -114,20 +113,20 @@ async fn initialize_is_create_new_and_both_existing_open_modes_close_explicitly( "MycStateHost { mode: ReadWriteExisting, state: \"[sealed]\" }" ); - let contended = open_myc_state_inspection(&runtime, &identity) + let contended = open_myc_state_inspection(&runtime, &metadata) .await .expect_err("inspection must not bypass active writer authority"); assert_eq!(contended.kind(), MycStateHostErrorKind::InspectionOpen); writer.close().await.expect("writer close"); writer.close().await.expect("idempotent writer close"); - let inspection = open_myc_state_inspection(&runtime, &identity) + let inspection = open_myc_state_inspection(&runtime, &metadata) .await .expect("existing inspection state"); assert_eq!(inspection.mode(), MycStateHostMode::ReadOnlyInspection); inspection.close().await.expect("inspection close"); - let writer = open_myc_state_read_write(&runtime, &identity, applied_at, &build) + let writer = open_myc_state_read_write(&runtime, &metadata, applied_at, &build) .await .expect("authority reacquisition after explicit close"); writer.close().await.expect("reopened writer close"); @@ -140,10 +139,9 @@ async fn missing_state_and_mismatched_evidence_fail_before_database_creation() { let secondary = runtime(directory.path(), "secondary"); prepare_state_directory(&primary); let primary_metadata = metadata(&primary); - let primary_identity = primary_metadata.identity(); let (applied_at, build) = migration_evidence(); - let missing = open_myc_state_read_write(&primary, &primary_identity, applied_at, &build) + let missing = open_myc_state_read_write(&primary, &primary_metadata, applied_at, &build) .await .expect_err("missing state is never created by open"); assert_eq!(missing.kind(), MycStateHostErrorKind::ReadWriteOpen); diff --git a/tests/services_hardening_state_metadata.rs b/tests/services_hardening_state_metadata.rs @@ -0,0 +1,201 @@ +#![forbid(unsafe_code)] + +use std::{error::Error, path::Path}; + +use myc::{ + MYC_CONFIG_SCHEMA_VERSION, MYC_OPERATOR_CONTRACT_VERSION, MYC_SIGNER_STATUS_CONTRACT_VERSION, + MYC_STATE_APPLICATION_ID, MYC_STATE_SCHEMA_VERSION, MycConfigProfile, MycStateMetadata, + MycStateMetadataErrorKind, RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, + parse_myc_cli_v1_from, parse_myc_config_v1, resolve_myc_runtime_context, +}; +use radroots_storage::event::SourceGeneration; + +const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const METADATA_SOURCE: &str = include_str!("../src/state_metadata.rs"); + +fn runtime(root: &Path, profile: &str) -> myc::MycRuntimeContext { + let root = root.to_str().expect("UTF-8 temporary root"); + let invocation = parse_myc_cli_v1_from([ + "myc", + "--profile", + profile, + "--instance", + "primary", + "--repo-local-root", + root, + "run", + ]) + .expect("valid test invocation"); + resolve_myc_runtime_context( + &RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()), + &invocation, + ) + .expect("runtime context") +} + +fn state_metadata( + runtime: &myc::MycRuntimeContext, + source: &str, +) -> Result<MycStateMetadata, myc::MycStateMetadataError> { + let configuration = + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::RepoLocal).expect("configuration"); + MycStateMetadata::new( + runtime, + &configuration, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1_725_000_000_000, + ) +} + +#[test] +fn exact_database_configuration_identity_and_policy_bindings_are_frozen() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let metadata = state_metadata(&runtime, EXAMPLE).expect("state metadata"); + let database = metadata.database(); + + assert_eq!(MYC_STATE_APPLICATION_ID.to_be_bytes(), *b"RDMY"); + assert_eq!(database.application_id().get(), MYC_STATE_APPLICATION_ID); + assert_eq!(database.service().as_str(), "myc"); + assert_eq!(database.instance().as_str(), "primary"); + assert_eq!(database.source_generation().as_bytes(), &[0x5a; 32]); + assert_eq!( + database.state_schema_version().get(), + MYC_STATE_SCHEMA_VERSION + ); + assert_eq!(database.created_at_unix_ms(), 1_725_000_000_000); + + let identities = metadata.expected_identities(); + assert_eq!(identities.transport().as_hex(), "4".repeat(64)); + assert_eq!(identities.user().as_hex(), "2".repeat(64)); + assert_eq!( + identities.discovery().expect("discovery identity").as_hex(), + "3".repeat(64) + ); + + let versions = metadata.policy_versions(); + assert_eq!(versions.configuration(), MYC_CONFIG_SCHEMA_VERSION); + assert_eq!(versions.state(), MYC_STATE_SCHEMA_VERSION); + assert_eq!(versions.operator(), MYC_OPERATOR_CONTRACT_VERSION); + assert_eq!(versions.status(), MYC_SIGNER_STATUS_CONTRACT_VERSION); + assert_eq!( + hex::encode(metadata.configuration_digest().as_bytes()), + "5fd8ecb8d526ed8cc2d5af8963838ea9a3707a74d0da6ed7c97851a4d6760a44" + ); +} + +#[test] +fn digest_uses_fully_defaulted_values_and_changes_with_normalized_policy() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let explicit = state_metadata(&runtime, EXAMPLE).expect("explicit defaults"); + let implicit_source = EXAMPLE + .replace("shutdown_grace_ms = 30000\n", "") + .replace("level = \"info\"\n", "") + .replace("format = \"json\"\n", "") + .replace("busy_timeout_ms = 5000\n", "") + .replace("max_connections = 8\n", ""); + let implicit = state_metadata(&runtime, &implicit_source).expect("implicit defaults"); + assert_eq!( + explicit.configuration_digest(), + implicit.configuration_digest() + ); + + let changed = state_metadata( + &runtime, + &EXAMPLE.replace("level = \"info\"", "level = \"warn\""), + ) + .expect("changed policy"); + assert_ne!( + explicit.configuration_digest(), + changed.configuration_digest() + ); +} + +#[test] +fn disabled_discovery_binds_no_discovery_identity() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let mut document = toml::from_str::<toml::Value>(EXAMPLE).expect("example TOML"); + document["identity"]["discovery"] = toml::Value::Table( + [("enabled".to_owned(), toml::Value::Boolean(false))] + .into_iter() + .collect(), + ); + document["discovery"] = toml::Value::Table( + [("enabled".to_owned(), toml::Value::Boolean(false))] + .into_iter() + .collect(), + ); + let source = toml::to_string(&document).expect("disabled-discovery TOML"); + let metadata = state_metadata(&runtime, &source).expect("state metadata"); + + assert!(metadata.expected_identities().discovery().is_none()); +} + +#[test] +fn profile_and_invalid_nostr_identity_bindings_fail_closed() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let production = parse_myc_config_v1(EXAMPLE.as_bytes(), MycConfigProfile::Production) + .expect("production configuration"); + let error = MycStateMetadata::new( + &runtime, + &production, + SourceGeneration::new([0x5a; 32]).expect("generation"), + 1, + ) + .expect_err("profile mismatch"); + assert_eq!(error.kind(), MycStateMetadataErrorKind::Profile); + assert!(Error::source(&error).is_none()); + + let invalid_role = EXAMPLE.replacen(&"4".repeat(64), &"f".repeat(64), 1); + assert!( + parse_myc_config_v1(invalid_role.as_bytes(), MycConfigProfile::RepoLocal).is_err(), + "an out-of-field role identity must not remain a valid configuration" + ); + let invalid_client = EXAMPLE.replacen(&"7".repeat(64), &"f".repeat(64), 1); + assert!( + parse_myc_config_v1(invalid_client.as_bytes(), MycConfigProfile::RepoLocal).is_err(), + "an out-of-field policy identity must not remain a valid configuration" + ); +} + +#[test] +fn metadata_debug_error_and_package_boundary_disclose_no_values() { + let directory = tempfile::tempdir().expect("temporary root"); + let runtime = runtime(directory.path(), "repo-local"); + let metadata = state_metadata(&runtime, EXAMPLE).expect("state metadata"); + let rendered = format!("{metadata:?}"); + for forbidden in [ + directory.path().to_string_lossy().as_ref(), + &"4".repeat(64), + &"2".repeat(64), + &"3".repeat(64), + &hex::encode(metadata.configuration_digest().as_bytes()), + ] { + assert!(!rendered.contains(forbidden)); + } + + assert!(LIB_SOURCE.contains("mod state_metadata;")); + assert!(!LIB_SOURCE.contains("pub mod state_metadata;")); + for forbidden in [ + "sqlx::", + "rusqlite", + "CREATE TABLE", + "INSERT INTO", + "UPDATE ", + "DELETE FROM", + "std::fs", + "std::env", + "std::time", + "Serialize", + "Deserialize", + ] { + assert!( + !METADATA_SOURCE.contains(forbidden), + "found forbidden metadata authority `{forbidden}`" + ); + } +}