myc

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

commit 9ba2411d5dab85f4e033bf3f4c8bf7b01ef8a5f3
parent 13a90eeafea2f56e70895c58d24d3e73b063daf3
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Apr 2026 16:55:04 +0000

operability: expose myc runtime contract surfaces

Diffstat:
M.env.example | 12+++++++++---
Msrc/config.rs | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/custody.rs | 27++++++++++++++++++++++++++-
Msrc/lib.rs | 2+-
Msrc/operability/mod.rs | 9++++++++-
Mtests/operability_cli.rs | 30+++++++++++++++++++++++++++++-
6 files changed, 168 insertions(+), 7 deletions(-)

diff --git a/.env.example b/.env.example @@ -18,9 +18,11 @@ MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=10 # MYC_DISCOVERY_NIP05_OUTPUT_PATH=/var/lib/radroots/services/myc/public/.well-known/nostr.json # set explicit path variables only when overriding the canonical profile-derived locations MYC_PATHS_SIGNER_IDENTITY_BACKEND=encrypted_file +# shared backends: encrypted_file, host_vault, external_command, plaintext_file +# runtime-specific custody mode: managed_account # encrypted_file and plaintext_file: identity file path # host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME -# managed_account: account store file path +# managed_account: account store file path layered over host-vault-backed custody primitives # external_command: signer helper executable path # MYC_PATHS_SIGNER_IDENTITY_PATH= MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID= @@ -28,9 +30,11 @@ MYC_PATHS_SIGNER_IDENTITY_KEYRING_ACCOUNT_ID= MYC_PATHS_SIGNER_IDENTITY_KEYRING_SERVICE_NAME=org.radroots.myc.signer MYC_PATHS_SIGNER_IDENTITY_PROFILE_PATH= MYC_PATHS_USER_IDENTITY_BACKEND=encrypted_file +# shared backends: encrypted_file, host_vault, external_command, plaintext_file +# runtime-specific custody mode: managed_account # encrypted_file and plaintext_file: identity file path # host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME -# managed_account: account store file path +# managed_account: account store file path layered over host-vault-backed custody primitives # external_command: signer helper executable path # MYC_PATHS_USER_IDENTITY_PATH= MYC_PATHS_USER_IDENTITY_KEYRING_ACCOUNT_ID= @@ -53,9 +57,11 @@ MYC_DISCOVERY_ENABLED=true MYC_DISCOVERY_DOMAIN=myc.radroots.org MYC_DISCOVERY_HANDLER_IDENTIFIER=myc MYC_DISCOVERY_APP_IDENTITY_BACKEND= +# shared backends: encrypted_file, host_vault, external_command, plaintext_file +# runtime-specific custody mode: managed_account # encrypted_file and plaintext_file: identity file path # host_vault: set *_KEYRING_ACCOUNT_ID and *_KEYRING_SERVICE_NAME -# managed_account: account store file path +# managed_account: account store file path layered over host-vault-backed custody primitives # external_command: signer helper executable path # MYC_DISCOVERY_APP_IDENTITY_PATH= MYC_DISCOVERY_APP_IDENTITY_KEYRING_ACCOUNT_ID= diff --git a/src/config.rs b/src/config.rs @@ -223,6 +223,18 @@ pub struct MycIdentitySourceSpec { pub profile_path: Option<PathBuf>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MycRuntimeContractOutput { + pub active_profile: MycPathProfile, + pub allowed_profiles: Vec<MycPathProfile>, + pub default_shared_secret_backend: MycIdentityBackend, + pub allowed_shared_secret_backends: Vec<MycIdentityBackend>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_specific_custody_modes: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_vault_policy: Option<String>, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MycTransportDeliveryPolicy { @@ -437,6 +449,37 @@ impl MycIdentityBackend { } } +const MYC_ALLOWED_PROFILES: [MycPathProfile; 3] = [ + MycPathProfile::InteractiveUser, + MycPathProfile::ServiceHost, + MycPathProfile::RepoLocal, +]; +const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [ + MycIdentityBackend::EncryptedFile, + MycIdentityBackend::HostVault, + MycIdentityBackend::ExternalCommand, + MycIdentityBackend::PlaintextFile, +]; +const MYC_RUNTIME_SPECIFIC_CUSTODY_MODES: [&str; 1] = ["managed_account"]; +const MYC_DEFAULT_SHARED_SECRET_BACKEND: MycIdentityBackend = MycIdentityBackend::EncryptedFile; +const MYC_HOST_VAULT_POLICY: &str = "desktop"; + +impl MycRuntimeContractOutput { + pub fn for_active_profile(active_profile: MycPathProfile) -> Self { + Self { + active_profile, + allowed_profiles: MYC_ALLOWED_PROFILES.to_vec(), + default_shared_secret_backend: MYC_DEFAULT_SHARED_SECRET_BACKEND, + allowed_shared_secret_backends: MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec(), + runtime_specific_custody_modes: MYC_RUNTIME_SPECIFIC_CUSTODY_MODES + .into_iter() + .map(str::to_owned) + .collect(), + host_vault_policy: Some(MYC_HOST_VAULT_POLICY.to_owned()), + } + } +} + impl MycSignerStateBackend { pub fn as_str(self) -> &'static str { match self { @@ -622,6 +665,33 @@ impl MycPathsConfig { } impl MycConfig { + pub fn allowed_profiles() -> Vec<MycPathProfile> { + MYC_ALLOWED_PROFILES.to_vec() + } + + pub fn default_shared_secret_backend() -> MycIdentityBackend { + MYC_DEFAULT_SHARED_SECRET_BACKEND + } + + pub fn allowed_shared_secret_backends() -> Vec<MycIdentityBackend> { + MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec() + } + + pub fn runtime_specific_custody_modes() -> Vec<String> { + MYC_RUNTIME_SPECIFIC_CUSTODY_MODES + .into_iter() + .map(str::to_owned) + .collect() + } + + pub fn host_vault_policy() -> Option<String> { + Some(MYC_HOST_VAULT_POLICY.to_owned()) + } + + pub fn runtime_contract_output(&self) -> MycRuntimeContractOutput { + MycRuntimeContractOutput::for_active_profile(self.paths.profile) + } + fn default_with_path_selection( resolver: &RadrootsPathResolver, profile: MycPathProfile, @@ -3227,4 +3297,29 @@ MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite .contains("MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite") ); } + + #[test] + fn runtime_contract_output_matches_shared_runtime_contract() { + let config = MycConfig::default(); + let contract = config.runtime_contract_output(); + + assert_eq!(contract.active_profile, MycPathProfile::InteractiveUser); + assert_eq!(contract.allowed_profiles, MycConfig::allowed_profiles()); + assert_eq!( + contract.default_shared_secret_backend, + MycConfig::default_shared_secret_backend() + ); + assert_eq!( + contract.allowed_shared_secret_backends, + MycConfig::allowed_shared_secret_backends() + ); + assert_eq!( + contract.runtime_specific_custody_modes, + MycConfig::runtime_specific_custody_modes() + ); + assert_eq!( + contract.host_vault_policy, + MycConfig::host_vault_policy() + ); + } } diff --git a/src/custody.rs b/src/custody.rs @@ -18,7 +18,7 @@ use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultOsKeyring}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -use crate::config::{MycIdentityBackend, MycIdentitySourceSpec}; +use crate::config::{MycConfig, MycIdentityBackend, MycIdentitySourceSpec}; use crate::error::MycError; use crate::identity_storage::{ load_encrypted_identity, load_identity_profile, rotate_encrypted_identity, @@ -60,6 +60,13 @@ pub struct MycIdentityStatusOutput { pub selected_account_label: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub selected_account_state: Option<MycManagedAccountSelectionState>, + pub default_shared_secret_backend: MycIdentityBackend, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_shared_secret_backends: Vec<MycIdentityBackend>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runtime_specific_custody_modes: Vec<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host_vault_policy: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub identity_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1172,6 +1179,10 @@ impl MycIdentityProvider { selected_account_id: None, selected_account_label: None, selected_account_state: None, + default_shared_secret_backend: MycConfig::default_shared_secret_backend(), + allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), + runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), + host_vault_policy: MycConfig::host_vault_policy(), identity_id: Some(identity.id.to_string()), public_key_hex: Some(identity.public_key_hex.clone()), error: None, @@ -1190,6 +1201,10 @@ impl MycIdentityProvider { selected_account_id: None, selected_account_label: None, selected_account_state: None, + default_shared_secret_backend: MycConfig::default_shared_secret_backend(), + allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), + runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), + host_vault_policy: MycConfig::host_vault_policy(), identity_id: None, public_key_hex: None, error: Some(error.to_string()), @@ -1246,6 +1261,12 @@ impl MycIdentityProvider { selected_account_id: None, selected_account_label: None, selected_account_state: None, + default_shared_secret_backend: MycConfig::default_shared_secret_backend(), + allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends( + ), + runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes( + ), + host_vault_policy: MycConfig::host_vault_policy(), identity_id: None, public_key_hex: None, error: Some(error.to_string()), @@ -1305,6 +1326,10 @@ impl MycIdentityProvider { selected_account_id, selected_account_label, selected_account_state, + default_shared_secret_backend: MycConfig::default_shared_secret_backend(), + allowed_shared_secret_backends: MycConfig::allowed_shared_secret_backends(), + runtime_specific_custody_modes: MycConfig::runtime_specific_custody_modes(), + host_vault_policy: MycConfig::host_vault_policy(), identity_id, public_key_hex, error, diff --git a/src/lib.rs b/src/lib.rs @@ -31,7 +31,7 @@ pub use config::{ MycDiscoveryConfig, MycDiscoveryMetadataConfig, MycIdentityBackend, MycIdentitySourceSpec, MycLoggingConfig, MycObservabilityConfig, MycPathsConfig, MycPersistenceConfig, MycPolicyConfig, MycRuntimeAuditBackend, MycServiceConfig, MycSignerStateBackend, - MycTransportConfig, MycTransportDeliveryPolicy, + MycRuntimeContractOutput, MycTransportConfig, MycTransportDeliveryPolicy, }; pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput}; pub use custody::{ diff --git a/src/operability/mod.rs b/src/operability/mod.rs @@ -18,7 +18,10 @@ use tokio::task::JoinSet; use crate::app::MycRuntime; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome}; -use crate::config::{MycRuntimeAuditBackend, MycSignerStateBackend, MycTransportDeliveryPolicy}; +use crate::config::{ + MycRuntimeAuditBackend, MycRuntimeContractOutput, MycSignerStateBackend, + MycTransportDeliveryPolicy, +}; use crate::custody::{MycActiveIdentity, MycIdentityStatusOutput}; use crate::discovery::MycDiscoveryContext; use crate::error::MycError; @@ -183,6 +186,7 @@ pub struct MycStatusFullOutput { pub status: MycRuntimeStatus, pub ready: bool, pub reasons: Vec<String>, + pub runtime_contract: MycRuntimeContractOutput, pub startup: crate::app::MycStartupSnapshot, pub signer_backend: MycSignerBackendStatusOutput, pub custody: MycCustodyStatusOutput, @@ -198,6 +202,7 @@ pub struct MycStatusSummaryOutput { pub ready: bool, pub reasons: Vec<String>, pub instance_name: String, + pub runtime_contract: MycRuntimeContractOutput, pub signer_backend: MycSignerBackendStatusOutput, pub custody: MycCustodyStatusOutput, pub persistence: MycPersistenceStatusOutput, @@ -352,6 +357,7 @@ pub async fn collect_status_full(runtime: &MycRuntime) -> Result<MycStatusFullOu status, ready, reasons, + runtime_contract: runtime.config().runtime_contract_output(), startup: snapshot, signer_backend, custody: custody.output, @@ -371,6 +377,7 @@ pub async fn collect_status_summary( ready: full.ready, reasons: full.reasons, instance_name: full.startup.instance_name, + runtime_contract: full.runtime_contract, signer_backend: MycSignerBackendStatusOutput { local_signer: full.signer_backend.local_signer.clone(), remote_session_count: full.signer_backend.remote_session_count, diff --git a/tests/operability_cli.rs b/tests/operability_cli.rs @@ -8,7 +8,7 @@ use myc::{ }; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind}; -use serde_json::Value; +use serde_json::{Value, json}; fn write_test_identity(path: &Path, secret_key: &str) { let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); @@ -79,6 +79,24 @@ fn status_summary_command_emits_machine_readable_json() { let value: Value = serde_json::from_slice(&output.stdout).expect("status json"); assert_eq!(value["status"], "unready"); assert_eq!(value["ready"], false); + assert_eq!(value["runtime_contract"]["active_profile"], "interactive_user"); + assert_eq!( + value["runtime_contract"]["allowed_profiles"], + json!(["interactive_user", "service_host", "repo_local"]) + ); + assert_eq!( + value["runtime_contract"]["default_shared_secret_backend"], + "encrypted_file" + ); + assert_eq!( + value["runtime_contract"]["allowed_shared_secret_backends"], + json!(["encrypted_file", "host_vault", "external_command", "plaintext_file"]) + ); + assert_eq!( + value["runtime_contract"]["runtime_specific_custody_modes"], + json!(["managed_account"]) + ); + assert_eq!(value["runtime_contract"]["host_vault_policy"], "desktop"); assert_eq!(value["custody"]["signer"]["backend"], "encrypted_file"); assert_eq!(value["custody"]["signer"]["resolved"], true); assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file"); @@ -177,6 +195,16 @@ fn custody_status_command_reports_role_backend_details() { let value: Value = serde_json::from_slice(&output.stdout).expect("custody status json"); assert_eq!(value["backend"], "encrypted_file"); assert_eq!(value["resolved"], true); + assert_eq!(value["default_shared_secret_backend"], "encrypted_file"); + assert_eq!( + value["allowed_shared_secret_backends"], + json!(["encrypted_file", "host_vault", "external_command", "plaintext_file"]) + ); + assert_eq!( + value["runtime_specific_custody_modes"], + json!(["managed_account"]) + ); + assert_eq!(value["host_vault_policy"], "desktop"); assert_eq!( value["identity_id"], "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"