myc

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

commit 25784d276008b877f9527489a366d2ee65edd74c
parent 875099514f23d10593f7c36969237be44afeda69
Author: triesap <tyson@radroots.org>
Date:   Fri, 21 Aug 2026 12:05:41 +0000

config: implement strict v1 parser

Diffstat:
MCargo.toml | 4++--
MREADME | 8++++++++
Asrc/config_v1.rs | 1336+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 6++++++
Atests/services_hardening_config_parser.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1400 insertions(+), 2 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -35,6 +35,7 @@ chacha20poly1305 = "0.10" clap = { version = "4.5", features = ["derive"] } futures-executor = "0.3" hex = "0.4" +jsonschema = { version = "0.48.1", default-features = false } keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service"] } nostr = { version = "0.44.2", features = ["nip04", "nip44", "nip46", "nip49"] } nostr-sdk = { version = "0.44.1" } @@ -55,13 +56,12 @@ tokio = { version = "1.48", default-features = false, features = ["io-util", "ma tracing = "0.1" tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +toml = "0.8" url = "2.5" uuid = { version = "1.18", features = ["serde", "v7"] } zeroize = "1.8" [dev-dependencies] futures-util = "0.3.32" -jsonschema = { version = "0.48.1", default-features = false } serial_test = "3" tokio-tungstenite = "0.26.2" -toml = "0.8" diff --git a/README b/README @@ -13,6 +13,14 @@ Only reviewed bounded operational leaves have defaults. Bootstrap profile, instance, repo-local root, and config-path selection are CLI concerns and are not document fields. +`parse_myc_config_v1` caps original bytes before decoding, checks the schema +header before closed contract admission, rejects duplicate, null, unknown, and +semantically inconsistent input, and returns an immutable document plus a +deterministic redacted effective-configuration projection. Every projected +leaf records whether it came from the document or one of the exact governed +default authorities. Ordinary errors and `Debug` output contain no source +text, paths, credentials, relay URLs, or identity values. + The current runtime loader remains transitional until its ordered replacement steps are complete. Existing environment variables and `.env.example` are prototype evidence, not authority to change or weaken the v1 contract. diff --git a/src/config_v1.rs b/src/config_v1.rs @@ -0,0 +1,1336 @@ +//! Strict, bounded Myc configuration document v1 admission. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::fmt; +use std::net::SocketAddr; + +use serde::Serialize; +use serde_json::{Map, Value, json}; +use url::Url; + +const CONFIG_SCHEMA: &str = include_str!("../contracts/services_hardening/config.v1.schema.json"); + +/// Exact schema identity for the production Myc configuration document. +pub const MYC_CONFIG_SCHEMA: &str = "radroots.myc.config"; + +/// Exact supported Myc configuration schema version. +pub const MYC_CONFIG_SCHEMA_VERSION: u32 = 1; + +/// Hard cap applied to original bytes before UTF-8 or TOML parsing. +pub const MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES: usize = 1_048_576; + +/// Bootstrap-selected network posture used during relay admission. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConfigProfile { + /// Production and ordinary service-host configurations require WSS relays. + Production, + /// Explicit repository-local development may also use loopback WS relays. + RepoLocal, +} + +/// Stable source classification for an effective configuration value. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum MycConfigValueSource { + Document, + RadrootsServiceHost, + RadrootsServiceSqlite, + RadrootsEvent, + RadrootsNostrConnect, + AcceptedServiceAuthority, + EngineeringSafety, +} + +/// Stable source-free classification for configuration admission failures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MycConfigV1ErrorKind { + TooLarge, + InvalidUtf8, + MalformedToml, + MissingSchema, + InvalidSchema, + SchemaMismatch, + MissingSchemaVersion, + InvalidSchemaVersion, + UnsupportedSchemaVersion, + InvalidDocument, + InvalidRelationship, + Encoding, +} + +impl MycConfigV1ErrorKind { + const fn message(self) -> &'static str { + match self { + Self::TooLarge => "configuration document exceeds its size limit", + Self::InvalidUtf8 => "configuration document is not valid UTF-8", + Self::MalformedToml => "configuration document is not valid TOML", + Self::MissingSchema => "configuration document schema is missing", + Self::InvalidSchema => "configuration document schema is invalid", + Self::SchemaMismatch => "configuration document schema is unsupported", + Self::MissingSchemaVersion => "configuration document schema version is missing", + Self::InvalidSchemaVersion => "configuration document schema version is invalid", + Self::UnsupportedSchemaVersion => { + "configuration document schema version is unsupported" + } + Self::InvalidDocument => "configuration document fields are invalid", + Self::InvalidRelationship => "configuration document relationships are invalid", + Self::Encoding => "effective configuration could not be encoded", + } + } +} + +/// One source-free configuration admission failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct MycConfigV1Error { + kind: MycConfigV1ErrorKind, +} + +impl MycConfigV1Error { + const fn new(kind: MycConfigV1ErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure classification. + #[must_use] + pub const fn kind(self) -> MycConfigV1ErrorKind { + self.kind + } +} + +impl fmt::Debug for MycConfigV1Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConfigV1Error") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for MycConfigV1Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for MycConfigV1Error {} + +/// Deterministic redacted effective configuration with exact leaf provenance. +#[derive(Clone, PartialEq, Eq)] +pub struct MycEffectiveConfigV1 { + canonical_json: Box<str>, + field_count: usize, +} + +impl MycEffectiveConfigV1 { + /// Returns compact JSON in deterministic path order. + #[must_use] + pub fn canonical_json(&self) -> &str { + &self.canonical_json + } + + /// Returns the number of projected effective leaf values. + #[must_use] + pub const fn field_count(&self) -> usize { + self.field_count + } +} + +impl fmt::Debug for MycEffectiveConfigV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycEffectiveConfigV1") + .field("canonical_json", &"[redacted]") + .field("field_count", &self.field_count) + .finish() + } +} + +/// A validated immutable Myc configuration document v1. +pub struct MycConfigDocumentV1 { + profile: MycConfigProfile, + normalized: Value, + effective: MycEffectiveConfigV1, +} + +impl MycConfigDocumentV1 { + /// Returns the exact admitted schema identity. + #[must_use] + pub const fn schema(&self) -> &'static str { + MYC_CONFIG_SCHEMA + } + + /// Returns the exact admitted schema version. + #[must_use] + pub const fn schema_version(&self) -> u32 { + MYC_CONFIG_SCHEMA_VERSION + } + + /// Returns the bootstrap-selected network posture used during admission. + #[must_use] + pub const fn profile(&self) -> MycConfigProfile { + self.profile + } + + /// Returns the deterministic redacted effective configuration projection. + #[must_use] + pub const fn effective(&self) -> &MycEffectiveConfigV1 { + &self.effective + } + + /// Returns the exact number of configured relay bindings. + #[must_use] + pub fn relay_count(&self) -> usize { + self.normalized + .pointer("/relays") + .and_then(Value::as_array) + .map_or(0, Vec::len) + } +} + +impl fmt::Debug for MycConfigDocumentV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MycConfigDocumentV1") + .field("schema", &MYC_CONFIG_SCHEMA) + .field("schema_version", &MYC_CONFIG_SCHEMA_VERSION) + .field("profile", &self.profile) + .field("effective", &self.effective) + .finish() + } +} + +/// Parses and semantically validates one complete Myc configuration document. +pub fn parse_myc_config_v1( + bytes: &[u8], + profile: MycConfigProfile, +) -> Result<MycConfigDocumentV1, MycConfigV1Error> { + if bytes.len() > MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES { + return Err(error(MycConfigV1ErrorKind::TooLarge)); + } + let source = + std::str::from_utf8(bytes).map_err(|_| error(MycConfigV1ErrorKind::InvalidUtf8))?; + let original = source + .parse::<toml::Table>() + .map_err(|_| error(MycConfigV1ErrorKind::MalformedToml))?; + validate_header(&original)?; + let mut normalized = serde_json::to_value(toml::Value::Table(original.clone())) + .map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument))?; + let schema: Value = + serde_json::from_str(CONFIG_SCHEMA).map_err(|_| error(MycConfigV1ErrorKind::Encoding))?; + let validator = + jsonschema::validator_for(&schema).map_err(|_| error(MycConfigV1ErrorKind::Encoding))?; + if !validator.is_valid(&normalized) { + return Err(error(MycConfigV1ErrorKind::InvalidDocument)); + } + apply_defaults(&mut normalized)?; + if !validator.is_valid(&normalized) { + return Err(error(MycConfigV1ErrorKind::InvalidDocument)); + } + validate_relationships(&normalized, profile)?; + let effective = build_effective(&normalized, &original)?; + Ok(MycConfigDocumentV1 { + profile, + normalized, + effective, + }) +} + +fn validate_header(header: &toml::Table) -> Result<(), MycConfigV1Error> { + let schema = header + .get("schema") + .ok_or_else(|| error(MycConfigV1ErrorKind::MissingSchema))? + .as_str() + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidSchema))?; + if !valid_schema_id(schema) { + return Err(error(MycConfigV1ErrorKind::InvalidSchema)); + } + if schema != MYC_CONFIG_SCHEMA { + return Err(error(MycConfigV1ErrorKind::SchemaMismatch)); + } + let version = header + .get("schema_version") + .ok_or_else(|| error(MycConfigV1ErrorKind::MissingSchemaVersion))? + .as_integer() + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidSchemaVersion))?; + let version = + u32::try_from(version).map_err(|_| error(MycConfigV1ErrorKind::InvalidSchemaVersion))?; + if version == 0 { + return Err(error(MycConfigV1ErrorKind::InvalidSchemaVersion)); + } + if version != MYC_CONFIG_SCHEMA_VERSION { + return Err(error(MycConfigV1ErrorKind::UnsupportedSchemaVersion)); + } + Ok(()) +} + +fn valid_schema_id(value: &str) -> bool { + let mut bytes = value.bytes(); + !value.is_empty() + && value.len() <= 128 + && bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && bytes + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) +} + +#[derive(Clone, Copy)] +struct DefaultEntry { + path: &'static str, + value: DefaultValue, + source: MycConfigValueSource, + enabled_pointer: Option<&'static str>, +} + +#[derive(Clone, Copy)] +enum DefaultValue { + Integer(u64), + String(&'static str), +} + +const DEFAULTS: &[DefaultEntry] = &[ + default( + "/service/shutdown_grace_ms", + 30_000, + MycConfigValueSource::EngineeringSafety, + ), + default_string( + "/logging/level", + "info", + MycConfigValueSource::EngineeringSafety, + ), + default_string( + "/logging/format", + "json", + MycConfigValueSource::AcceptedServiceAuthority, + ), + conditional_default( + "/operations/limits/header_count", + 32, + MycConfigValueSource::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/header_bytes", + 16_384, + MycConfigValueSource::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/response_body_utf8_bytes", + 1_048_576, + MycConfigValueSource::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/concurrent_connections", + 32, + MycConfigValueSource::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/request_deadline_ms", + 15_000, + MycConfigValueSource::RadrootsServiceHost, + "/operations/enabled", + ), + conditional_default( + "/operations/limits/idle_timeout_ms", + 30_000, + MycConfigValueSource::RadrootsServiceHost, + "/operations/enabled", + ), + default( + "/database/busy_timeout_ms", + 5_000, + MycConfigValueSource::RadrootsServiceSqlite, + ), + default( + "/database/max_connections", + 8, + MycConfigValueSource::RadrootsServiceSqlite, + ), + default( + "/transport/connect_deadline_ms", + 10_000, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/transport/publish_retry/max_attempts", + 5, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/transport/publish_retry/initial_backoff_ms", + 250, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/transport/publish_retry/maximum_backoff_ms", + 30_000, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/transport/publish_retry/attempt_deadline_ms", + 15_000, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/header_count", + 32, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/header_bytes", + 16_384, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/request_body_utf8_bytes", + 65_536, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/response_body_utf8_bytes", + 1_048_576, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/concurrent_connections", + 32, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/request_deadline_ms", + 15_000, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/idle_timeout_ms", + 30_000, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/admin/query_items", + 100, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/events/wire_bytes", + 262_144, + MycConfigValueSource::RadrootsEvent, + ), + default( + "/resource_limits/events/content_bytes", + 131_072, + MycConfigValueSource::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_count", + 1_024, + MycConfigValueSource::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_total_elements", + 4_096, + MycConfigValueSource::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_element_bytes", + 4_096, + MycConfigValueSource::RadrootsEvent, + ), + default( + "/resource_limits/events/tag_total_bytes", + 131_072, + MycConfigValueSource::RadrootsEvent, + ), + default( + "/resource_limits/events/decrypted_plaintext_bytes", + 262_144, + MycConfigValueSource::RadrootsNostrConnect, + ), + default( + "/resource_limits/queues/ingress", + 1_024, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/resource_limits/queues/provider", + 64, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/resource_limits/queues/outbox", + 4_096, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/resource_limits/queues/discovery", + 64, + MycConfigValueSource::EngineeringSafety, + ), + default( + "/resource_limits/metrics/descriptors", + 64, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/metrics/samples", + 512, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/metrics/labels_per_sample", + 8, + MycConfigValueSource::RadrootsServiceHost, + ), + default( + "/resource_limits/metrics/render_utf8_bytes", + 1_048_576, + MycConfigValueSource::RadrootsServiceHost, + ), +]; + +const fn default(path: &'static str, value: u64, source: MycConfigValueSource) -> DefaultEntry { + DefaultEntry { + path, + value: DefaultValue::Integer(value), + source, + enabled_pointer: None, + } +} + +const fn conditional_default( + path: &'static str, + value: u64, + source: MycConfigValueSource, + enabled_pointer: &'static str, +) -> DefaultEntry { + DefaultEntry { + path, + value: DefaultValue::Integer(value), + source, + enabled_pointer: Some(enabled_pointer), + } +} + +const fn default_string( + path: &'static str, + value: &'static str, + source: MycConfigValueSource, +) -> DefaultEntry { + DefaultEntry { + path, + value: DefaultValue::String(value), + source, + enabled_pointer: None, + } +} + +fn apply_defaults(document: &mut Value) -> Result<(), MycConfigV1Error> { + for entry in DEFAULTS { + if entry + .enabled_pointer + .is_some_and(|pointer| document.pointer(pointer) != Some(&Value::Bool(true))) + || document.pointer(entry.path).is_some() + { + continue; + } + insert_json_pointer(document, entry.path, entry.value)?; + } + Ok(()) +} + +fn insert_json_pointer( + root: &mut Value, + pointer: &str, + value: DefaultValue, +) -> Result<(), MycConfigV1Error> { + let mut parts = pointer + .split('/') + .filter(|part| !part.is_empty()) + .peekable(); + let mut current = root; + while let Some(part) = parts.next() { + let object = current + .as_object_mut() + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument))?; + if parts.peek().is_none() { + object.insert( + part.to_owned(), + match value { + DefaultValue::Integer(value) => Value::Number(value.into()), + DefaultValue::String(value) => Value::String(value.to_owned()), + }, + ); + return Ok(()); + } + current = object + .entry(part.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + } + Err(error(MycConfigV1ErrorKind::InvalidDocument)) +} + +fn validate_relationships( + document: &Value, + profile: MycConfigProfile, +) -> Result<(), MycConfigV1Error> { + validate_utf8_byte_limits(document)?; + let relays = array(document, "/relays")?; + let mut ids = BTreeSet::new(); + let mut urls = BTreeSet::new(); + let mut has_read = false; + let mut has_write = false; + let mut has_required_read = false; + let mut has_required_write = false; + for relay in relays { + let id = string_at(relay, "/id")?; + let raw_url = string_at(relay, "/url")?; + canonical_relay_url(raw_url, profile)?; + let read = bool_at(relay, "/read")?; + let write = bool_at(relay, "/write")?; + let required = bool_at(relay, "/required")?; + if !ids.insert(id) || !urls.insert(raw_url) || (!read && !write) { + return relationship_error(); + } + has_read |= read; + has_write |= write; + has_required_read |= required && read; + has_required_write |= required && write; + } + if !(has_read && has_write && has_required_read && has_required_write) { + return relationship_error(); + } + validate_unique_role_keys(document)?; + validate_client_policy(document)?; + validate_challenges(document)?; + validate_rate_limits(document)?; + validate_transport(document, relays)?; + validate_discovery(document, relays)?; + validate_operations(document) +} + +fn validate_utf8_byte_limits(document: &Value) -> Result<(), MycConfigV1Error> { + validate_provider_bytes(document, "/identity/transport")?; + validate_provider_bytes(document, "/identity/user")?; + if bool_value(document, "/identity/discovery/enabled")? { + validate_provider_bytes(document, "/identity/discovery/binding")?; + } + for relay in array(document, "/relays")? { + validate_string_bytes(string_at(relay, "/id")?, 1, 64)?; + validate_string_bytes(string_at(relay, "/url")?, 1, 2_048)?; + } + if bool_value(document, "/operations/enabled")? { + validate_string_bytes(string(document, "/operations/listen")?, 1, 128)?; + } + if bool_value(document, "/policy/challenges/enabled")? { + validate_string_bytes(string(document, "/policy/challenges/url")?, 1, 2_048)?; + } + if bool_value(document, "/discovery/enabled")? { + for (pointer, minimum, maximum) in [ + ("/discovery/domain", 1, 253), + ("/discovery/handler_identifier", 1, 128), + ("/discovery/nostrconnect_url_template", 1, 2_048), + ("/discovery/metadata/name", 1, 128), + ("/discovery/metadata/display_name", 1, 128), + ("/discovery/metadata/about", 0, 1_024), + ("/discovery/metadata/website", 1, 2_048), + ("/discovery/metadata/picture", 1, 2_048), + ] { + validate_string_bytes(string(document, pointer)?, minimum, maximum)?; + } + } + Ok(()) +} + +fn validate_provider_bytes(document: &Value, prefix: &str) -> Result<(), MycConfigV1Error> { + match string(document, &format!("{prefix}/provider"))? { + "encrypted_file" => { + validate_string_bytes( + string(document, &format!("{prefix}/envelope_path"))?, + 1, + 4_096, + )?; + validate_string_bytes( + string(document, &format!("{prefix}/credential_reference"))?, + 1, + 128, + ) + } + "local_signer" => validate_string_bytes( + string(document, &format!("{prefix}/socket_path"))?, + 1, + 4_096, + ), + _ => document_error(), + } +} + +fn validate_string_bytes( + value: &str, + minimum: usize, + maximum: usize, +) -> Result<(), MycConfigV1Error> { + if (minimum..=maximum).contains(&value.len()) { + Ok(()) + } else { + document_error() + } +} + +fn validate_unique_role_keys(document: &Value) -> Result<(), MycConfigV1Error> { + let mut keys = vec![ + string(document, "/identity/transport/expected_public_key")?, + string(document, "/identity/user/expected_public_key")?, + ]; + if bool_value(document, "/identity/discovery/enabled")? { + keys.push(string( + document, + "/identity/discovery/binding/expected_public_key", + )?); + } + if keys.iter().copied().collect::<BTreeSet<_>>().len() != keys.len() { + return relationship_error(); + } + Ok(()) +} + +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) { + return relationship_error(); + } + let permission_kinds = string_set(document, "/policy/permission_ceiling")? + .into_iter() + .filter_map(|permission| permission.strip_prefix("sign_event:kind:")) + .map(|kind| { + kind.parse::<u32>() + .map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument)) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + let allowed_kinds = array(document, "/policy/allowed_sign_event_kinds")? + .iter() + .map(|kind| kind.as_u64().and_then(|value| u32::try_from(value).ok())) + .collect::<Option<BTreeSet<_>>>() + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument))?; + if permission_kinds != allowed_kinds { + return relationship_error(); + } + Ok(()) +} + +fn validate_challenges(document: &Value) -> Result<(), MycConfigV1Error> { + if bool_value(document, "/policy/challenges/enabled")? + && integer(document, "/policy/challenges/authorized_lifetime_ms")? + < integer(document, "/policy/challenges/pending_lifetime_ms")? + { + return relationship_error(); + } + Ok(()) +} + +fn validate_rate_limits(document: &Value) -> Result<(), MycConfigV1Error> { + for (name, expected_scope) in [ + ("connection_admission", "global_and_relay"), + ("challenge_creation", "connection"), + ("challenge_authorization", "connection"), + ] { + let prefix = format!("/rate_limits/{name}"); + if string(document, &format!("{prefix}/scope"))? != expected_scope + || integer(document, &format!("{prefix}/retention_ms"))? + < integer(document, &format!("{prefix}/window_ms"))? + { + return relationship_error(); + } + } + Ok(()) +} + +fn validate_transport(document: &Value, relays: &[Value]) -> Result<(), MycConfigV1Error> { + if integer(document, "/transport/publish_retry/initial_backoff_ms")? + > integer(document, "/transport/publish_retry/maximum_backoff_ms")? + { + return relationship_error(); + } + if string(document, "/transport/delivery_policy/mode")? == "required_quorum" { + let required = integer( + document, + "/transport/delivery_policy/required_acknowledgements", + )?; + let required_writers = relays + .iter() + .filter(|relay| { + bool_at(relay, "/required") == Ok(true) && bool_at(relay, "/write") == Ok(true) + }) + .count() as u64; + if required > required_writers { + return relationship_error(); + } + } + Ok(()) +} + +fn validate_discovery(document: &Value, relays: &[Value]) -> Result<(), MycConfigV1Error> { + let enabled = bool_value(document, "/discovery/enabled")?; + if enabled != bool_value(document, "/identity/discovery/enabled")? { + return relationship_error(); + } + if !enabled { + return Ok(()); + } + for (pointer, capability) in [ + ("/discovery/public_relay_ids", "read"), + ("/discovery/publish_relay_ids", "write"), + ] { + for relay_id in string_set(document, pointer)? { + if !relays.iter().any(|relay| { + string_at(relay, "/id") == Ok(relay_id) + && bool_at(relay, &format!("/{capability}")) == Ok(true) + }) { + return relationship_error(); + } + } + } + Ok(()) +} + +fn validate_operations(document: &Value) -> Result<(), MycConfigV1Error> { + if !bool_value(document, "/operations/enabled")? { + return Ok(()); + } + let address = string(document, "/operations/listen")? + .parse::<SocketAddr>() + .map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument))?; + if address.port() == 0 + || (string(document, "/operations/bind_policy")? == "loopback_only" + && !address.ip().is_loopback()) + { + return relationship_error(); + } + Ok(()) +} + +fn canonical_relay_url(value: &str, profile: MycConfigProfile) -> Result<Url, MycConfigV1Error> { + let parsed = Url::parse(value).map_err(|_| error(MycConfigV1ErrorKind::InvalidDocument))?; + if parsed.as_str() != value + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + { + return document_error(); + } + let allowed = match profile { + MycConfigProfile::Production => parsed.scheme() == "wss", + MycConfigProfile::RepoLocal => match parsed.scheme() { + "wss" => true, + "ws" => parsed + .host_str() + .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1")), + _ => false, + }, + }; + if !allowed { + return relationship_error(); + } + Ok(parsed) +} + +fn array<'a>(value: &'a Value, pointer: &str) -> Result<&'a [Value], MycConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) +} + +fn string<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, MycConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_str) + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) +} + +fn string_at<'a>(value: &'a Value, pointer: &str) -> Result<&'a str, MycConfigV1Error> { + string(value, pointer) +} + +fn string_set<'a>(value: &'a Value, pointer: &str) -> Result<BTreeSet<&'a str>, MycConfigV1Error> { + array(value, pointer)? + .iter() + .map(|entry| { + entry + .as_str() + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) + }) + .collect() +} + +fn bool_value(value: &Value, pointer: &str) -> Result<bool, MycConfigV1Error> { + bool_at(value, pointer) +} + +fn bool_at(value: &Value, pointer: &str) -> Result<bool, MycConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_bool) + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) +} + +fn integer(value: &Value, pointer: &str) -> Result<u64, MycConfigV1Error> { + value + .pointer(pointer) + .and_then(Value::as_u64) + .ok_or_else(|| error(MycConfigV1ErrorKind::InvalidDocument)) +} + +fn document_error<T>() -> Result<T, MycConfigV1Error> { + Err(error(MycConfigV1ErrorKind::InvalidDocument)) +} + +fn relationship_error<T>() -> Result<T, MycConfigV1Error> { + Err(error(MycConfigV1ErrorKind::InvalidRelationship)) +} + +const fn error(kind: MycConfigV1ErrorKind) -> MycConfigV1Error { + MycConfigV1Error::new(kind) +} + +#[derive(Serialize)] +struct EffectiveProjection { + schema: &'static str, + schema_version: u32, + fields: Vec<EffectiveField>, +} + +#[derive(Serialize)] +struct EffectiveField { + path: String, + source: MycConfigValueSource, + value: Value, +} + +fn build_effective( + normalized: &Value, + original: &toml::Table, +) -> Result<MycEffectiveConfigV1, MycConfigV1Error> { + let mut flattened = BTreeMap::new(); + flatten_value("", normalized, &mut flattened); + let original = toml::Value::Table(original.clone()); + let fields = flattened + .into_iter() + .map(|(path, value)| EffectiveField { + source: default_entry(&path).map_or(MycConfigValueSource::Document, |entry| { + if toml_path(&original, &path).is_some() { + MycConfigValueSource::Document + } else { + entry.source + } + }), + value: redacted_value(&path, value), + path, + }) + .collect::<Vec<_>>(); + let field_count = fields.len(); + let canonical_json = serde_json::to_string(&EffectiveProjection { + schema: "radroots.myc.effective-config", + schema_version: 1, + fields, + }) + .map_err(|_| error(MycConfigV1ErrorKind::Encoding))? + .into_boxed_str(); + Ok(MycEffectiveConfigV1 { + canonical_json, + field_count, + }) +} + +fn flatten_value(path: &str, value: &Value, fields: &mut BTreeMap<String, Value>) { + match value { + Value::Object(object) => { + for (key, child) in object { + flatten_value(&format!("{path}/{key}"), child, fields); + } + } + Value::Array(array) if array.iter().all(Value::is_object) => { + for (index, child) in array.iter().enumerate() { + flatten_value(&format!("{path}/{index}"), child, fields); + } + } + _ => { + fields.insert(path.to_owned(), value.clone()); + } + } +} + +fn redacted_value(path: &str, value: Value) -> Value { + let scalar_redaction = if path.ends_with("/envelope_path") || path.ends_with("/socket_path") { + Some("[redacted-path]") + } else if path.ends_with("/credential_reference") { + Some("[redacted-credential-reference]") + } else if path.ends_with("/expected_public_key") { + Some("[redacted-public-key]") + } else if path == "/operations/listen" { + Some("[redacted-address]") + } else if path.starts_with("/relays/") && path.ends_with("/id") { + Some("[redacted-relay-id]") + } else if (path.starts_with("/relays/") && path.ends_with("/url")) + || path == "/policy/challenges/url" + { + Some("[redacted-url]") + } else if matches!( + path, + "/discovery/domain" + | "/discovery/handler_identifier" + | "/discovery/nostrconnect_url_template" + | "/discovery/metadata/name" + | "/discovery/metadata/display_name" + | "/discovery/metadata/about" + | "/discovery/metadata/website" + | "/discovery/metadata/picture" + ) { + Some("[redacted]") + } else { + None + }; + if let Some(redaction) = scalar_redaction { + return Value::String(redaction.to_owned()); + } + if matches!( + path, + "/policy/trusted_clients" + | "/policy/denied_clients" + | "/discovery/public_relay_ids" + | "/discovery/publish_relay_ids" + ) { + return json!({ + "count": value.as_array().map_or(0, Vec::len), + "values": "[redacted]" + }); + } + value +} + +fn default_entry(path: &str) -> Option<&'static DefaultEntry> { + DEFAULTS.iter().find(|entry| entry.path == path) +} + +fn toml_path<'a>(root: &'a toml::Value, path: &str) -> Option<&'a toml::Value> { + path.split('/') + .filter(|part| !part.is_empty()) + .try_fold(root, |value, part| { + if let Ok(index) = part.parse::<usize>() { + value.as_array()?.get(index) + } else { + value.as_table()?.get(part) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + + fn parse(source: &str) -> Result<MycConfigDocumentV1, MycConfigV1Error> { + parse_myc_config_v1(source.as_bytes(), MycConfigProfile::Production) + } + + fn replace(source: &str, old: &str, new: &str) -> String { + assert!(source.contains(old), "missing fixture fragment: {old}"); + source.replacen(old, new, 1) + } + + #[test] + fn canonical_example_is_deterministic_and_redacted() { + let first = parse(EXAMPLE).expect("canonical example"); + let second = parse(EXAMPLE).expect("canonical example again"); + assert_eq!(first.schema(), MYC_CONFIG_SCHEMA); + assert_eq!(first.schema_version(), MYC_CONFIG_SCHEMA_VERSION); + assert_eq!(first.profile(), MycConfigProfile::Production); + assert_eq!(first.relay_count(), 2); + assert_eq!(first.effective(), second.effective()); + assert!(first.effective().field_count() > 80); + let output = first.effective().canonical_json(); + assert!(output.starts_with( + "{\"schema\":\"radroots.myc.effective-config\",\"schema_version\":1,\"fields\":[" + )); + for forbidden in [ + "/var/lib/radroots", + "/run/radroots", + "1111111111111111", + "aaaaaaaaaaaaaaaa", + "relay-primary.example.test", + "myc.example.test", + "transport_wrapping_key", + "Radroots Myc", + ] { + assert!(!output.contains(forbidden), "leaked {forbidden}"); + assert!(!format!("{first:?}").contains(forbidden)); + } + } + + #[test] + fn exact_document_bound_precedes_parsing() { + let mut exact = EXAMPLE.as_bytes().to_vec(); + exact.extend_from_slice(b"\n#"); + exact.resize(MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, b'a'); + assert!(parse_myc_config_v1(&exact, MycConfigProfile::Production).is_ok()); + exact.push(b'a'); + assert_eq!( + parse_myc_config_v1(&exact, MycConfigProfile::Production) + .unwrap_err() + .kind(), + MycConfigV1ErrorKind::TooLarge + ); + } + + #[test] + fn invalid_utf8_duplicate_null_unknown_and_malformed_wire_fail() { + assert_eq!( + parse_myc_config_v1(&[0xff], MycConfigProfile::Production) + .unwrap_err() + .kind(), + MycConfigV1ErrorKind::InvalidUtf8 + ); + for source in [ + format!("schema = \"radroots.myc.config\"\n{EXAMPLE}"), + replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + "shutdown_grace_ms = null", + ), + replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + "shutdown_grace_ms = [null]", + ), + ] { + assert_eq!( + parse(&source).unwrap_err().kind(), + MycConfigV1ErrorKind::MalformedToml + ); + } + let unknown = replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + "shutdown_grace_ms = 30000\nsecret = \"do-not-render\"", + ); + assert_eq!( + parse(&unknown).unwrap_err().kind(), + MycConfigV1ErrorKind::InvalidDocument + ); + let nested_duplicate = replace( + EXAMPLE, + "busy_timeout_ms = 5000", + "busy_timeout_ms = 5000\nbusy_timeout_ms = 5000", + ); + assert_eq!( + parse(&nested_duplicate).unwrap_err().kind(), + MycConfigV1ErrorKind::MalformedToml + ); + let mut table = EXAMPLE.parse::<toml::Table>().expect("example table"); + table.remove("resource_limits"); + assert_eq!( + parse(&toml::to_string(&table).expect("missing-table TOML")) + .unwrap_err() + .kind(), + MycConfigV1ErrorKind::InvalidDocument + ); + } + + #[test] + fn header_failures_are_classified_before_document_admission() { + for (source, expected) in [ + ( + EXAMPLE.replace("schema = \"radroots.myc.config\"\n", ""), + MycConfigV1ErrorKind::MissingSchema, + ), + ( + replace(EXAMPLE, "schema = \"radroots.myc.config\"", "schema = 1"), + MycConfigV1ErrorKind::InvalidSchema, + ), + ( + replace(EXAMPLE, "radroots.myc.config", "radroots.rhi.config"), + MycConfigV1ErrorKind::SchemaMismatch, + ), + ( + EXAMPLE.replace("schema_version = 1\n", ""), + MycConfigV1ErrorKind::MissingSchemaVersion, + ), + ( + replace(EXAMPLE, "schema_version = 1", "schema_version = \"1\""), + MycConfigV1ErrorKind::InvalidSchemaVersion, + ), + ( + replace(EXAMPLE, "schema_version = 1", "schema_version = 2"), + MycConfigV1ErrorKind::UnsupportedSchemaVersion, + ), + ] { + assert_eq!(parse(&source).unwrap_err().kind(), expected); + } + } + + #[test] + fn production_and_repo_local_relay_postures_are_distinct() { + let local = replace( + EXAMPLE, + "wss://relay-primary.example.test/", + "ws://127.0.0.1:7777/", + ); + assert_eq!( + parse(&local).unwrap_err().kind(), + MycConfigV1ErrorKind::InvalidRelationship + ); + assert!(parse_myc_config_v1(local.as_bytes(), MycConfigProfile::RepoLocal).is_ok()); + let remote = replace(&local, "ws://127.0.0.1:7777/", "ws://relay.example.test/"); + assert_eq!( + parse_myc_config_v1(remote.as_bytes(), MycConfigProfile::RepoLocal) + .unwrap_err() + .kind(), + MycConfigV1ErrorKind::InvalidDocument + ); + } + + #[test] + fn semantic_relationship_failures_are_rejected() { + let cases = [ + replace(EXAMPLE, "id = \"secondary\"", "id = \"primary\""), + replace( + EXAMPLE, + "url = \"wss://relay-secondary.example.test/\"", + "url = \"wss://relay-primary.example.test/\"", + ), + replace( + EXAMPLE, + "trusted_clients = [\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"]", + "trusted_clients = [\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"]", + ), + replace( + EXAMPLE, + "expected_public_key = \"3333333333333333333333333333333333333333333333333333333333333333\"", + "expected_public_key = \"2222222222222222222222222222222222222222222222222222222222222222\"", + ), + replace( + EXAMPLE, + "allowed_sign_event_kinds = [1]", + "allowed_sign_event_kinds = [2]", + ), + replace( + EXAMPLE, + "authorized_lifetime_ms = 3600000", + "authorized_lifetime_ms = 1000", + ), + replace(EXAMPLE, "retention_ms = 3600000", "retention_ms = 1"), + replace( + EXAMPLE, + "initial_backoff_ms = 250", + "initial_backoff_ms = 30001", + ), + replace( + EXAMPLE, + "public_relay_ids = [\"primary\", \"secondary\"]", + "public_relay_ids = [\"missing\"]", + ), + replace( + EXAMPLE, + "[discovery]\nenabled = true", + "[discovery]\nenabled = false", + ), + ]; + for source in cases { + assert!(matches!( + parse(&source).unwrap_err().kind(), + MycConfigV1ErrorKind::InvalidDocument | MycConfigV1ErrorKind::InvalidRelationship + )); + } + } + + #[test] + fn defaults_have_exact_sources_and_explicit_values_override_them() { + assert_eq!(DEFAULTS.len(), 39); + let mut table = EXAMPLE.parse::<toml::Table>().expect("example TOML"); + for entry in DEFAULTS { + remove_toml_path(&mut table, entry.path); + } + let minimal = toml::to_string(&table).expect("minimal TOML"); + let parsed = parse(&minimal).expect("defaults admitted"); + let output = parsed.effective().canonical_json(); + for source in [ + "engineering_safety", + "accepted_service_authority", + "radroots_service_sqlite", + "radroots_service_host", + "radroots_event", + "radroots_nostr_connect", + ] { + assert!(output.contains(&format!("\"source\":\"{source}\""))); + } + let explicit = parse(EXAMPLE).expect("explicit example"); + assert!( + explicit + .effective() + .canonical_json() + .matches("\"source\":\"document\"") + .count() + > output.matches("\"source\":\"document\"").count() + ); + } + + #[test] + fn errors_and_debug_are_source_free() { + let secret = "credential-secret-value"; + let source = replace( + EXAMPLE, + "shutdown_grace_ms = 30000", + &format!("unknown = \"{secret}\""), + ); + let failure = parse(&source).unwrap_err(); + let rendered = format!("{failure} {failure:?}"); + assert!(!rendered.contains(secret)); + assert!(Error::source(&failure).is_none()); + } + + #[test] + fn annotated_utf8_limits_are_measured_in_bytes() { + let exact_name = "é".repeat(64); + let exact = replace( + EXAMPLE, + "name = \"myc\"", + &format!("name = \"{exact_name}\""), + ); + assert!(parse(&exact).is_ok()); + let over_name = "é".repeat(65); + let over = replace( + EXAMPLE, + "name = \"myc\"", + &format!("name = \"{over_name}\""), + ); + assert_eq!( + parse(&over).unwrap_err().kind(), + MycConfigV1ErrorKind::InvalidDocument + ); + } + + fn remove_toml_path(table: &mut toml::Table, pointer: &str) { + let mut parts = pointer + .split('/') + .filter(|part| !part.is_empty()) + .peekable(); + let mut current = table; + while let Some(part) = parts.next() { + if parts.peek().is_none() { + current.remove(part); + return; + } + let Some(next) = current.get_mut(part).and_then(toml::Value::as_table_mut) else { + return; + }; + current = next; + } + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -6,6 +6,7 @@ pub mod audit; mod audit_sqlite; pub mod cli; pub mod config; +mod config_v1; pub mod control; pub mod custody; pub mod discovery; @@ -40,6 +41,11 @@ pub use config::{ MycPolicyConfig, MycRuntimeAuditBackend, MycRuntimeContractOutput, MycServiceConfig, MycSignerStateBackend, MycTransportConfig, MycTransportDeliveryPolicy, }; +pub use config_v1::{ + MYC_CONFIG_DOCUMENT_MAX_UTF8_BYTES, MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION, + MycConfigDocumentV1, MycConfigProfile, MycConfigV1Error, MycConfigV1ErrorKind, + MycConfigValueSource, MycEffectiveConfigV1, parse_myc_config_v1, +}; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use custody::{ MycActiveIdentity, MycCustodyExportOutput, MycCustodyImportOutput, MycCustodyRotateOutput, diff --git a/tests/services_hardening_config_parser.rs b/tests/services_hardening_config_parser.rs @@ -0,0 +1,48 @@ +#![forbid(unsafe_code)] + +use std::error::Error; + +use myc::{ + MYC_CONFIG_SCHEMA, MYC_CONFIG_SCHEMA_VERSION, MycConfigProfile, MycConfigV1ErrorKind, + parse_myc_config_v1, +}; + +const EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml"); + +#[test] +fn root_api_admits_the_canonical_document_and_exposes_only_redacted_effective_output() { + let document = parse_myc_config_v1(EXAMPLE.as_bytes(), MycConfigProfile::Production) + .expect("canonical configuration"); + assert_eq!(document.schema(), MYC_CONFIG_SCHEMA); + assert_eq!(document.schema_version(), MYC_CONFIG_SCHEMA_VERSION); + assert_eq!(document.relay_count(), 2); + + let effective = document.effective().canonical_json(); + assert!(effective.contains("\"source\":\"document\"")); + assert!(effective.contains("[redacted-public-key]")); + for forbidden in [ + "/var/lib/radroots", + "/run/radroots", + "relay-primary.example.test", + "1111111111111111", + "transport_wrapping_key", + ] { + assert!(!effective.contains(forbidden)); + assert!(!format!("{document:?}").contains(forbidden)); + } +} + +#[test] +fn root_api_failure_is_stable_and_source_free() { + let secret = "never-render-this-value"; + let invalid = EXAMPLE.replacen( + "shutdown_grace_ms = 30000", + &format!("unknown = \"{secret}\""), + 1, + ); + let failure = parse_myc_config_v1(invalid.as_bytes(), MycConfigProfile::Production) + .expect_err("unknown field must fail"); + assert_eq!(failure.kind(), MycConfigV1ErrorKind::InvalidDocument); + assert!(Error::source(&failure).is_none()); + assert!(!format!("{failure} {failure:?}").contains(secret)); +}