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:
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}`"
+ );
+ }
+}