cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit b4c8b4680f1fabffc58c6629f9dbceb77cf55611
parent d734e0fb82ddaa87d37d8ba1f1106dfbd761b81d
Author: triesap <tyson@radroots.org>
Date:   Wed, 24 Jun 2026 05:06:08 +0000

cli: harden Myc signer readiness

- match Myc NIP-46 permissions exactly by event kind
- keep profile sync writes fail-closed without sign_event:0
- share managed account reference matching across status and signing
- cover Myc readiness and binding regressions

Diffstat:
MCargo.lock | 1+
Msrc/runtime/sdk.rs | 14++++++++++----
Msrc/runtime/signer.rs | 104++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
3 files changed, 101 insertions(+), 18 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3933,6 +3933,7 @@ dependencies = [ "serde_json", "sha2", "sqlx", + "tokio", "uuid", ] diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -286,10 +286,7 @@ fn myc_nip46_signer_input( ))); } if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() { - let matched_account = actor_account_id - .is_some_and(|account_id| managed_account_ref == account_id) - || managed_account_ref == actor_pubkey; - if !matched_account { + if !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) { return Err(RuntimeError::Config(format!( "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" ))); @@ -322,6 +319,15 @@ fn myc_nip46_signer_input( }) } +pub(crate) fn myc_managed_account_ref_matches( + managed_account_ref: &str, + actor_account_id: Option<&str>, + actor_pubkey: &str, +) -> bool { + actor_account_id.is_some_and(|account_id| managed_account_ref == account_id) + || managed_account_ref == actor_pubkey +} + async fn signer_provider( config: &RuntimeConfig, signer_input: CliSdkSignerInput, diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -4,7 +4,7 @@ use crate::runtime::account::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolut use crate::runtime::config::{ CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, }; -use crate::runtime::sdk::MYC_NIP46_SESSION_SECRET_SERVICE; +use crate::runtime::sdk::{MYC_NIP46_SESSION_SECRET_SERVICE, myc_managed_account_ref_matches}; use crate::view::runtime::{ IdentityPublicView, LocalSignerStatusView, MycStatusView, SignerBindingStatusView, SignerStatusView, SignerWriteKindReadinessView, @@ -219,11 +219,27 @@ fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { } fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { - let account_resolution = match crate::runtime::account::resolve_account_resolution(config) { - Ok(resolution) => crate::runtime::account::account_resolution_view(&resolution), - Err(_) => empty_account_resolution_view(), - }; - let readiness = myc_binding_readiness(config, None); + let (account_resolution, actor_account_id, actor_pubkey) = + match crate::runtime::account::resolve_account_resolution(config) { + Ok(resolution) => { + let actor_account_id = resolution + .resolved_account + .as_ref() + .map(|account| account.record.account_id.to_string()); + let actor_pubkey = resolution + .resolved_account + .as_ref() + .map(|account| account.record.public_identity.public_key_hex.clone()); + ( + crate::runtime::account::account_resolution_view(&resolution), + actor_account_id, + actor_pubkey, + ) + } + Err(_) => (empty_account_resolution_view(), None, None), + }; + let readiness = + myc_binding_readiness(config, actor_account_id.as_deref(), actor_pubkey.as_deref()); SignerStatusView { mode: config.signer.backend.as_str().to_owned(), state: if readiness.ready { @@ -367,6 +383,7 @@ struct MycBindingReadiness { fn myc_binding_readiness( config: &RuntimeConfig, + actor_account_id: Option<&str>, actor_pubkey: Option<&str>, ) -> MycBindingReadiness { let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else { @@ -394,9 +411,9 @@ fn myc_binding_readiness( if let (Some(managed_account_ref), Some(actor_pubkey)) = (binding.managed_account_ref.as_deref(), actor_pubkey) { - if managed_account_ref != actor_pubkey { + if !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) { reasons.push(format!( - "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor pubkey `{actor_pubkey}`" + "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" )); } } @@ -506,16 +523,16 @@ fn myc_write_kind_readiness( cli_write_kinds() .iter() .map(|kind| { - let event_kind = kind.event_kind.to_string(); + let permission = sign_event_permission_for_kind(kind.event_kind); let permission = permissions .iter() - .find(|permission| permission.contains(event_kind.as_str())) + .find(|configured| configured.as_str() == permission.as_str()) .cloned() - .unwrap_or_else(|| format!("sign_event:{event_kind}")); + .unwrap_or(permission); let permission_ready = ready && permissions .iter() - .any(|permission| permission.contains(event_kind.as_str())); + .any(|configured| configured == &permission); SignerWriteKindReadinessView { command: kind.command.to_owned(), event_kind: kind.event_kind, @@ -536,11 +553,16 @@ fn myc_write_kind_readiness( .collect() } +fn sign_event_permission_for_kind(event_kind: u32) -> String { + format!("sign_event:{event_kind}") +} + #[cfg(test)] mod tests { use super::{ - KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, cli_write_kinds, + KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, + KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE, cli_write_kinds, + myc_managed_account_ref_matches, myc_write_kind_readiness, sign_event_permission_for_kind, }; const RESERVED_ORDER_KIND_3431: u32 = 3431; @@ -626,4 +648,58 @@ mod tests { assert_eq!(cancel.event_kind, KIND_ORDER_CANCELLATION); assert_ne!(cancel.event_kind, RESERVED_ORDER_KIND_3431); } + + #[test] + fn myc_write_readiness_requires_exact_permissions() { + let readiness = myc_write_kind_readiness(true, None); + let sync = readiness + .iter() + .find(|kind| kind.command == "sync.push") + .expect("sync readiness"); + + assert_eq!(sync.event_kind, KIND_PROFILE); + assert_eq!(sync.permission, "sign_event:0"); + assert!(!sync.ready); + assert_eq!( + sync.reason.as_deref(), + Some("SDK Myc signer permission is not configured for this event kind") + ); + + for (command, event_kind) in [ + ("farm.publish", KIND_FARM), + ("listing.publish", KIND_LISTING), + ("order.submit", KIND_ORDER_REQUEST), + ] { + let entry = readiness + .iter() + .find(|kind| kind.command == command) + .expect("product write readiness"); + + assert_eq!(entry.permission, sign_event_permission_for_kind(event_kind)); + assert!(entry.ready, "{command} should be ready"); + assert_eq!(entry.reason, None); + } + } + + #[test] + fn myc_managed_account_ref_matches_actor_account_id_or_pubkey() { + let actor_account_id = Some("acct_farmer_market"); + let actor_pubkey = "02d67b520cb0b835a5ca6ddf78bf3bbfe636d31a523050efc01bf8cb0c680da09e"; + + assert!(myc_managed_account_ref_matches( + "acct_farmer_market", + actor_account_id, + actor_pubkey, + )); + assert!(myc_managed_account_ref_matches( + actor_pubkey, + actor_account_id, + actor_pubkey, + )); + assert!(!myc_managed_account_ref_matches( + "acct_other", + actor_account_id, + actor_pubkey, + )); + } }