commit 7d09767e52a1000067783ae27e0cf929b74841ca
parent 39b618c2b97454c5a66afd9e53ba68b120a8b1dd
Author: triesap <tyson@radroots.org>
Date: Wed, 24 Jun 2026 06:25:53 +0000
cli: harden Myc signer readiness
- use typed NIP-46 permissions for write readiness
- keep unsupported sync profile signing fail-closed
- fail managed account status when no actor resolves
- cover typed permission and unresolved binding regressions
Diffstat:
2 files changed, 127 insertions(+), 18 deletions(-)
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -14,11 +14,13 @@ use radroots_events::kinds::{
KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE,
};
use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus;
+use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
RadrootsNostrSignerCapability,
};
use radroots_sdk::radroots_sdk_myc_nip46_product_permission_strings;
+use std::str::FromStr;
use url::Url;
const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc";
@@ -408,13 +410,25 @@ fn myc_binding_readiness(
if let Err(reason) = validate_myc_target(binding.target.as_str()) {
reasons.push(reason);
}
- if let (Some(managed_account_ref), Some(actor_pubkey)) =
- (binding.managed_account_ref.as_deref(), 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 account or pubkey"
- ));
+ if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() {
+ let managed_account_matches = actor_pubkey
+ .map(|actor_pubkey| {
+ myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey)
+ })
+ .unwrap_or_else(|| {
+ actor_account_id.is_some_and(|account_id| managed_account_ref == account_id)
+ });
+ if !managed_account_matches {
+ let reason = if actor_account_id.is_none() && actor_pubkey.is_none() {
+ format!(
+ "signer.remote_nip46 managed_account_ref `{managed_account_ref}` cannot be evaluated because no actor account or pubkey resolved"
+ )
+ } else {
+ format!(
+ "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey"
+ )
+ };
+ reasons.push(reason);
}
}
let signer_session_ref = binding.signer_session_ref.clone();
@@ -519,20 +533,43 @@ fn myc_write_kind_readiness(
ready: bool,
reason: Option<String>,
) -> Vec<SignerWriteKindReadinessView> {
- let permissions = radroots_sdk_myc_nip46_product_permission_strings();
+ myc_write_kind_readiness_for_permissions(ready, reason, sdk_myc_nip46_product_permissions())
+}
+
+fn sdk_myc_nip46_product_permissions() -> Result<RadrootsNostrConnectPermissions, String> {
+ RadrootsNostrConnectPermissions::from_str(
+ radroots_sdk_myc_nip46_product_permission_strings()
+ .join(",")
+ .as_str(),
+ )
+ .map_err(|error| format!("SDK Myc signer permissions are invalid: {error}"))
+}
+
+fn myc_write_kind_readiness_for_permissions(
+ ready: bool,
+ reason: Option<String>,
+ permissions: Result<RadrootsNostrConnectPermissions, String>,
+) -> Vec<SignerWriteKindReadinessView> {
+ let permissions = match permissions {
+ Ok(permissions) => permissions,
+ Err(error) => {
+ return cli_write_kinds()
+ .iter()
+ .map(|kind| SignerWriteKindReadinessView {
+ command: kind.command.to_owned(),
+ event_kind: kind.event_kind,
+ permission: sign_event_permission_for_kind(kind.event_kind),
+ ready: false,
+ reason: Some(error.clone()),
+ })
+ .collect();
+ }
+ };
cli_write_kinds()
.iter()
.map(|kind| {
let permission = sign_event_permission_for_kind(kind.event_kind);
- let permission = permissions
- .iter()
- .find(|configured| configured.as_str() == permission.as_str())
- .cloned()
- .unwrap_or(permission);
- let permission_ready = ready
- && permissions
- .iter()
- .any(|configured| configured == &permission);
+ let permission_ready = ready && permissions.allows_sign_event_kind(kind.event_kind);
SignerWriteKindReadinessView {
command: kind.command.to_owned(),
event_kind: kind.event_kind,
@@ -562,7 +599,11 @@ mod tests {
use super::{
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,
+ myc_managed_account_ref_matches, myc_write_kind_readiness,
+ myc_write_kind_readiness_for_permissions, sign_event_permission_for_kind,
+ };
+ use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
};
const RESERVED_ORDER_KIND_3431: u32 = 3431;
@@ -682,6 +723,31 @@ mod tests {
}
#[test]
+ fn myc_write_readiness_uses_typed_kind_permissions() {
+ let readiness = myc_write_kind_readiness_for_permissions(
+ true,
+ None,
+ Ok(RadrootsNostrConnectPermissions::from(vec![
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ format!("kind:{KIND_LISTING}"),
+ ),
+ ])),
+ );
+ let listing = readiness
+ .iter()
+ .find(|kind| kind.command == "listing.publish")
+ .expect("listing readiness");
+ let farm = readiness
+ .iter()
+ .find(|kind| kind.command == "farm.publish")
+ .expect("farm readiness");
+
+ assert!(listing.ready);
+ assert!(!farm.ready);
+ }
+
+ #[test]
fn myc_managed_account_ref_matches_actor_account_id_or_pubkey() {
let actor_account_id = Some("acct_farmer_market");
let actor_pubkey = "02d67b520cb0b835a5ca6ddf78bf3bbfe636d31a523050efc01bf8cb0c680da09e";
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -662,6 +662,49 @@ fn myc_signer_status_reports_missing_binding() {
assert_no_removed_command_reference(&value, &["signer", "status", "get"]);
}
+#[test]
+fn myc_signer_status_fails_closed_when_managed_account_is_unresolved() {
+ let sandbox = RadrootsCliSandbox::new();
+ let missing_myc = sandbox.root().join("bin/missing-myc");
+ let remote_signer = identity_public(91);
+ sandbox.write_app_config(&format!(
+ r#"[signer]
+backend = "myc"
+
+[myc]
+executable = "{}"
+
+[[capability_binding]]
+capability = "signer.remote_nip46"
+provider = "myc"
+target_kind = "explicit_endpoint"
+target = "bunker://{}?relay=wss%3A%2F%2Frelay.example"
+managed_account_ref = "acct_missing"
+signer_session_ref = "session_missing"
+"#,
+ toml_string(missing_myc.display().to_string().as_str()),
+ remote_signer.public_key_hex,
+ ));
+
+ let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+
+ assert_eq!(value["result"]["mode"], "myc");
+ assert_eq!(value["result"]["state"], "unconfigured");
+ assert_eq!(value["result"]["binding"]["state"], "unconfigured");
+ assert_eq!(value["result"]["myc"]["ready"], false);
+ assert_contains(
+ &value["result"]["reason"],
+ "managed_account_ref `acct_missing` cannot be evaluated",
+ );
+ assert!(
+ value["result"]["write_kinds"]
+ .as_array()
+ .expect("write kinds")
+ .iter()
+ .all(|kind| kind["ready"] == false)
+ );
+}
+
#[cfg(unix)]
#[test]
fn myc_signer_status_does_not_invoke_configured_executable() {