myc

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

commit 9d5b15da7c3c3ad7d0bd0f8dd84a2631936197b3
parent 75cc248f3daba62bb5fa44451e87fe841fb637fa
Author: triesap <tyson@radroots.org>
Date:   Wed, 25 Mar 2026 20:13:12 +0000

policy: add typed client and auth controls

- add typed policy config and runtime context for client trust deny, permission ceilings, and auth timing
- apply policy decisions to connect handling, request gating, connect accept, and manual approval and authorize flows
- narrow stored requested permissions to policy-compatible sets and enforce trusted-session reauth and challenge expiry rules
- cover config, unit, and relay-backed flows and validate with cargo metadata, cargo check --locked, cargo test --locked, and cargo fmt --all --check

Diffstat:
M.env.example | 14++++++++++++++
Msrc/app/runtime.rs | 16++++++++++------
Msrc/cli.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/config.rs | 213++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/control.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/lib.rs | 2++
Asrc/policy.rs | 741+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport/nip46.rs | 535+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mtests/nip46_e2e.rs | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1858 insertions(+), 111 deletions(-)

diff --git a/.env.example b/.env.example @@ -26,6 +26,20 @@ MYC_DISCOVERY_METADATA_WEBSITE=https://radroots.org MYC_DISCOVERY_METADATA_PICTURE= MYC_POLICY_CONNECTION_APPROVAL=explicit_user +# comma-separated nostr pubkeys that should auto-connect +# MYC_POLICY_TRUSTED_CLIENT_PUBKEYS= +# comma-separated nostr pubkeys that should always be denied +# MYC_POLICY_DENIED_CLIENT_PUBKEYS= +# comma-separated permission ceiling, for example: nip44_encrypt,sign_event:1 +# MYC_POLICY_PERMISSION_CEILING= +# comma-separated sign_event kinds allowed by policy, for example: 1,7 +# MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS= +# set MYC_POLICY_AUTH_URL to enable automatic auth challenge policy for trusted sessions +# MYC_POLICY_AUTH_URL=https://myc.radroots.org/auth/challenge +MYC_POLICY_AUTH_PENDING_TTL_SECS=900 +# set these when automatic auth challenge policy should expire trusted sessions +# MYC_POLICY_AUTHORIZED_TTL_SECS=3600 +# MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 MYC_TRANSPORT_ENABLED=true MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore}; use crate::config::{MycAuditConfig, MycConfig}; use crate::error::MycError; +use crate::policy::MycPolicyContext; use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr_signer::prelude::{ @@ -45,6 +46,7 @@ pub struct MycSignerContext { signer_state_path: PathBuf, audit_dir: PathBuf, audit_config: MycAuditConfig, + policy: MycPolicyContext, connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, } @@ -65,10 +67,7 @@ impl MycRuntime { let signer = MycSignerContext::bootstrap( &paths, config.audit.clone(), - config - .policy - .connection_approval - .into_signer_approval_requirement(), + MycPolicyContext::from_config(&config.policy)?, )?; let transport = MycNostrTransport::bootstrap(&config.transport, &signer.signer_identity)?; let runtime = Self { @@ -258,10 +257,14 @@ impl MycSignerContext { self.connection_approval_requirement } + pub fn policy(&self) -> &MycPolicyContext { + &self.policy + } + fn bootstrap( paths: &MycRuntimePaths, audit_config: MycAuditConfig, - connection_approval_requirement: RadrootsNostrSignerApprovalRequirement, + policy: MycPolicyContext, ) -> Result<Self, MycError> { let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?; let user_identity = RadrootsIdentity::load_from_path_auto(&paths.user_identity_path)?; @@ -287,7 +290,8 @@ impl MycSignerContext { signer_state_path: paths.signer_state_path.clone(), audit_dir: paths.audit_dir.clone(), audit_config, - connection_approval_requirement, + connection_approval_requirement: policy.default_approval_requirement(), + policy, }) } diff --git a/src/cli.rs b/src/cli.rs @@ -281,6 +281,7 @@ pub async fn run_from_env() -> Result<(), MycError> { let connection_id = parse_connection_id(&args.connection_id)?; let manager = runtime.signer_manager()?; let granted_permissions = granted_permissions_for_approval( + runtime.signer_context().policy(), &manager.list_connections()?, &connection_id, &args.grants, @@ -449,12 +450,13 @@ fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, M } fn granted_permissions_for_approval( + policy: &crate::policy::MycPolicyContext, connections: &[RadrootsNostrSignerConnectionRecord], connection_id: &RadrootsNostrSignerConnectionId, grants: &[String], ) -> Result<RadrootsNostrConnectPermissions, MycError> { if !grants.is_empty() { - return parse_permission_values(grants); + return policy.validate_operator_grants(parse_permission_values(grants)?); } let connection = connections @@ -463,7 +465,7 @@ fn granted_permissions_for_approval( .ok_or_else(|| { MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) })?; - Ok(connection.requested_permissions.clone()) + policy.validate_operator_grants(connection.requested_permissions.clone()) } fn load_audit_output( @@ -817,7 +819,9 @@ mod tests { use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::MycConfig; - use super::{MycAuditScope, load_audit_output, summarize_audit_output}; + use super::{ + MycAuditScope, granted_permissions_for_approval, load_audit_output, summarize_audit_output, + }; use crate::app::MycRuntime; fn write_identity(path: &std::path::Path, secret_key: &str) { @@ -828,12 +832,20 @@ mod tests { } fn runtime() -> MycRuntime { + runtime_with_config(|_| {}) + } + + fn runtime_with_config<F>(configure: F) -> MycRuntime + where + F: FnOnce(&mut MycConfig), + { let temp = tempfile::tempdir().expect("tempdir").keep(); let mut config = MycConfig::default(); config.audit.default_read_limit = 2; config.paths.state_dir = PathBuf::from(&temp).join("state"); config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); + configure(&mut config); write_identity( &config.paths.signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", @@ -846,6 +858,39 @@ mod tests { } #[test] + fn granted_permissions_for_approval_respects_policy_ceiling() { + let runtime = runtime_with_config(|config| { + config.policy.permission_ceiling = "nip04_encrypt".parse().expect("permission ceiling"); + }); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + ) + .with_requested_permissions( + "nip44_encrypt".parse().expect("requested permissions"), + ), + ) + .expect("register connection"); + + let error = granted_permissions_for_approval( + runtime.signer_context().policy(), + &manager.list_connections().expect("connections"), + &connection.connection_id, + &[], + ) + .expect_err("requested permissions outside policy should be rejected"); + + assert!( + error + .to_string() + .contains("granted permissions exceed the configured policy ceiling") + ); + } + + #[test] fn audit_output_surfaces_both_request_and_operation_records() { let runtime = runtime(); let manager = runtime.signer_manager().expect("manager"); diff --git a/src/config.rs b/src/config.rs @@ -2,8 +2,10 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; +use nostr::PublicKey; use radroots_identity::DEFAULT_IDENTITY_PATH; use radroots_nostr::prelude::RadrootsNostrRelayUrl; +use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement; use serde::{Deserialize, Serialize}; use tracing_subscriber::EnvFilter; @@ -96,6 +98,7 @@ pub struct MycTransportConfig { pub enum MycConnectionApproval { NotRequired, ExplicitUser, + Deny, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -110,6 +113,14 @@ pub enum MycTransportDeliveryPolicy { #[serde(default, deny_unknown_fields)] pub struct MycPolicyConfig { pub connection_approval: MycConnectionApproval, + pub trusted_client_pubkeys: Vec<String>, + pub denied_client_pubkeys: Vec<String>, + pub permission_ceiling: RadrootsNostrConnectPermissions, + pub allowed_sign_event_kinds: Vec<u16>, + pub auth_url: Option<String>, + pub auth_pending_ttl_secs: u64, + pub auth_authorized_ttl_secs: Option<u64>, + pub reauth_after_inactivity_secs: Option<u64>, } impl Default for MycConfig { @@ -211,6 +222,14 @@ impl Default for MycPolicyConfig { fn default() -> Self { Self { connection_approval: MycConnectionApproval::ExplicitUser, + trusted_client_pubkeys: Vec::new(), + denied_client_pubkeys: Vec::new(), + permission_ceiling: RadrootsNostrConnectPermissions::default(), + allowed_sign_event_kinds: Vec::new(), + auth_url: None, + auth_pending_ttl_secs: 900, + auth_authorized_ttl_secs: None, + reauth_after_inactivity_secs: None, } } } @@ -219,7 +238,7 @@ impl MycConnectionApproval { pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement { match self { Self::NotRequired => RadrootsNostrSignerApprovalRequirement::NotRequired, - Self::ExplicitUser => RadrootsNostrSignerApprovalRequirement::ExplicitUser, + Self::ExplicitUser | Self::Deny => RadrootsNostrSignerApprovalRequirement::ExplicitUser, } } } @@ -344,6 +363,54 @@ impl MycConfig { )); } + if self.policy.auth_pending_ttl_secs == 0 { + return Err(MycError::InvalidConfig( + "policy.auth_pending_ttl_secs must be greater than zero".to_owned(), + )); + } + if self + .policy + .auth_authorized_ttl_secs + .is_some_and(|ttl| ttl == 0) + { + return Err(MycError::InvalidConfig( + "policy.auth_authorized_ttl_secs must be greater than zero when set".to_owned(), + )); + } + if self + .policy + .reauth_after_inactivity_secs + .is_some_and(|ttl| ttl == 0) + { + return Err(MycError::InvalidConfig( + "policy.reauth_after_inactivity_secs must be greater than zero when set".to_owned(), + )); + } + if (self.policy.auth_authorized_ttl_secs.is_some() + || self.policy.reauth_after_inactivity_secs.is_some()) + && self.policy.auth_url.is_none() + { + return Err(MycError::InvalidConfig( + "policy.auth_url must be set when automatic auth TTL policy is configured" + .to_owned(), + )); + } + + let trusted_client_pubkeys = + normalize_policy_client_pubkeys(&self.policy.trusted_client_pubkeys)?; + let denied_client_pubkeys = + normalize_policy_client_pubkeys(&self.policy.denied_client_pubkeys)?; + let overlap = trusted_client_pubkeys + .intersection(&denied_client_pubkeys) + .cloned() + .collect::<Vec<_>>(); + if !overlap.is_empty() { + return Err(MycError::InvalidConfig(format!( + "policy trusted and denied client pubkeys overlap: {}", + overlap.join(", ") + ))); + } + match self.transport.delivery_policy { MycTransportDeliveryPolicy::Quorum => { let Some(delivery_quorum) = self.transport.delivery_quorum else { @@ -531,6 +598,34 @@ fn apply_env_entry( config.policy.connection_approval = parse_connection_approval_env(key, value, path, line_number)?; } + "MYC_POLICY_TRUSTED_CLIENT_PUBKEYS" => { + config.policy.trusted_client_pubkeys = parse_string_list_env(value); + } + "MYC_POLICY_DENIED_CLIENT_PUBKEYS" => { + config.policy.denied_client_pubkeys = parse_string_list_env(value); + } + "MYC_POLICY_PERMISSION_CEILING" => { + config.policy.permission_ceiling = + parse_permissions_env(key, value, path, line_number)?; + } + "MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS" => { + config.policy.allowed_sign_event_kinds = + parse_u16_list_env(key, value, path, line_number)?; + } + "MYC_POLICY_AUTH_URL" => { + config.policy.auth_url = parse_optional_string_env(value); + } + "MYC_POLICY_AUTH_PENDING_TTL_SECS" => { + config.policy.auth_pending_ttl_secs = parse_u64_env(key, value, path, line_number)?; + } + "MYC_POLICY_AUTHORIZED_TTL_SECS" => { + config.policy.auth_authorized_ttl_secs = + Some(parse_u64_env(key, value, path, line_number)?); + } + "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS" => { + config.policy.reauth_after_inactivity_secs = + Some(parse_u64_env(key, value, path, line_number)?); + } "MYC_TRANSPORT_ENABLED" => { config.transport.enabled = parse_bool_env(key, value, path, line_number)?; } @@ -620,10 +715,11 @@ fn parse_connection_approval_env( match value { "not_required" => Ok(MycConnectionApproval::NotRequired), "explicit_user" => Ok(MycConnectionApproval::ExplicitUser), + "deny" => Ok(MycConnectionApproval::Deny), _ => Err(config_parse_error( path, line_number, - format!("{key} must be `not_required` or `explicit_user`"), + format!("{key} must be `not_required`, `explicit_user`, or `deny`"), )), } } @@ -655,6 +751,55 @@ fn parse_optional_string_env(value: &str) -> Option<String> { } } +fn parse_permissions_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<RadrootsNostrConnectPermissions, MycError> { + value + .parse::<RadrootsNostrConnectPermissions>() + .map_err(|error| { + config_parse_error(path, line_number, format!("{key} parse error: {error}")) + }) +} + +fn parse_u16_list_env( + key: &str, + value: &str, + path: &Path, + line_number: usize, +) -> Result<Vec<u16>, MycError> { + parse_string_list_env(value) + .into_iter() + .map(|fragment| { + fragment.parse::<u16>().map_err(|_| { + config_parse_error( + path, + line_number, + format!("{key} must contain only unsigned 16-bit integers"), + ) + }) + }) + .collect() +} + +fn normalize_policy_client_pubkeys(values: &[String]) -> Result<BTreeSet<String>, MycError> { + values + .iter() + .map(|value| { + let public_key = PublicKey::parse(value) + .or_else(|_| PublicKey::from_hex(value)) + .map_err(|_| { + MycError::InvalidConfig(format!( + "policy client pubkey `{value}` is not a valid nostr public key" + )) + })?; + Ok(public_key.to_hex()) + }) + .collect() +} + fn parse_optional_path_env(value: &str) -> Option<PathBuf> { parse_optional_string_env(value).map(PathBuf::from) } @@ -873,6 +1018,14 @@ mod tests { config.policy.connection_approval, MycConnectionApproval::ExplicitUser ); + assert!(config.policy.trusted_client_pubkeys.is_empty()); + assert!(config.policy.denied_client_pubkeys.is_empty()); + assert!(config.policy.permission_ceiling.is_empty()); + assert!(config.policy.allowed_sign_event_kinds.is_empty()); + assert!(config.policy.auth_url.is_none()); + assert_eq!(config.policy.auth_pending_ttl_secs, 900); + assert_eq!(config.policy.auth_authorized_ttl_secs, None); + assert_eq!(config.policy.reauth_after_inactivity_secs, None); assert_eq!(config.audit.default_read_limit, 200); assert_eq!(config.audit.max_active_file_bytes, 262_144); assert_eq!(config.audit.max_archived_files, 8); @@ -924,6 +1077,14 @@ MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png MYC_POLICY_CONNECTION_APPROVAL=not_required +MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=1111111111111111111111111111111111111111111111111111111111111111 +MYC_POLICY_DENIED_CLIENT_PUBKEYS=2222222222222222222222222222222222222222222222222222222222222222 +MYC_POLICY_PERMISSION_CEILING=nip04_encrypt,sign_event:1 +MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS=1,7 +MYC_POLICY_AUTH_URL=https://auth.example.com/challenge +MYC_POLICY_AUTH_PENDING_TTL_SECS=300 +MYC_POLICY_AUTHORIZED_TTL_SECS=3600 +MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600 MYC_TRANSPORT_ENABLED=true MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15 MYC_TRANSPORT_RELAYS=wss://relay.example.com,wss://relay2.example.com @@ -987,6 +1148,26 @@ MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS=800 config.policy.connection_approval, MycConnectionApproval::NotRequired ); + assert_eq!( + config.policy.trusted_client_pubkeys, + vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()] + ); + assert_eq!( + config.policy.denied_client_pubkeys, + vec!["2222222222222222222222222222222222222222222222222222222222222222".to_owned()] + ); + assert_eq!( + config.policy.permission_ceiling.to_string(), + "nip04_encrypt,sign_event:1" + ); + assert_eq!(config.policy.allowed_sign_event_kinds, vec![1, 7]); + assert_eq!( + config.policy.auth_url.as_deref(), + Some("https://auth.example.com/challenge") + ); + assert_eq!(config.policy.auth_pending_ttl_secs, 300); + assert_eq!(config.policy.auth_authorized_ttl_secs, Some(3600)); + assert_eq!(config.policy.reauth_after_inactivity_secs, Some(600)); assert!(config.transport.enabled); assert_eq!(config.transport.connect_timeout_secs, 15); assert_eq!( @@ -1140,6 +1321,29 @@ MYC_UNKNOWN=nope } #[test] + fn validate_rejects_overlapping_policy_client_lists() { + let mut config = MycConfig::default(); + config.policy.trusted_client_pubkeys = + vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]; + config.policy.denied_client_pubkeys = + vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]; + + let err = config + .validate() + .expect_err("overlapping policy client lists"); + assert!(err.to_string().contains("overlap")); + } + + #[test] + fn validate_requires_auth_url_for_auth_ttl_policy() { + let mut config = MycConfig::default(); + config.policy.auth_authorized_ttl_secs = Some(60); + + let err = config.validate().expect_err("missing auth url"); + assert!(err.to_string().contains("policy.auth_url")); + } + + #[test] fn example_env_parses_and_validates() { let example = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example")) @@ -1159,6 +1363,11 @@ MYC_UNKNOWN=nope config.transport.delivery_policy, MycTransportDeliveryPolicy::Any ); + assert_eq!( + config.policy.connection_approval, + MycConnectionApproval::ExplicitUser + ); + assert_eq!(config.policy.auth_pending_ttl_secs, 900); assert_eq!(config.transport.delivery_quorum, None); assert_eq!(config.transport.publish_max_attempts, 1); assert_eq!(config.transport.publish_initial_backoff_millis, 250); diff --git a/src/control.rs b/src/control.rs @@ -33,9 +33,15 @@ pub async fn authorize_auth_challenge( runtime: &MycRuntime, connection_id: &RadrootsNostrSignerConnectionId, ) -> Result<MycAuthorizedReplayOutput, MycError> { - let outcome = runtime - .signer_manager()? - .authorize_auth_challenge(connection_id)?; + let manager = runtime.signer_manager()?; + let connection = manager.get_connection(connection_id)?.ok_or_else(|| { + MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) + })?; + runtime + .signer_context() + .policy() + .ensure_authorize_auth_challenge_allowed(&connection)?; + let outcome = manager.authorize_auth_challenge(connection_id)?; let replayed_request_id = replay_authorized_request(runtime, &outcome).await?; Ok(MycAuthorizedReplayOutput { connection: outcome.connection, @@ -74,6 +80,15 @@ pub async fn accept_client_uri( requested_permissions: client_uri.metadata.requested_permissions.clone(), }; let manager = runtime.signer_manager()?; + let Some(approval_requirement) = runtime + .signer_context() + .policy() + .approval_requirement_for_client(&client_uri.client_public_key) + else { + return Err(MycError::InvalidOperation( + "client public key denied by policy".to_owned(), + )); + }; let connection = match manager.evaluate_connect_request(client_uri.client_public_key, request)? { radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection( connection, @@ -84,22 +99,41 @@ pub async fn accept_client_uri( .to_owned(), )); } + if runtime + .signer_context() + .policy() + .approval_requirement_for_client(&connection.client_public_key) + .is_none() + { + return Err(MycError::InvalidOperation( + "client public key denied by policy".to_owned(), + )); + } connection } radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired( proposal, ) => { + let requested_permissions = runtime + .signer_context() + .policy() + .filtered_requested_permissions(&proposal.requested_permissions); let draft = proposal .into_connection_draft(runtime.user_public_identity()) + .with_requested_permissions(requested_permissions) .with_relays(preferred_relays.clone()) - .with_approval_requirement(runtime.signer_context().connection_approval_requirement()); + .with_approval_requirement(approval_requirement); let connection = manager.register_connection(draft)?; - if runtime.signer_context().connection_approval_requirement() + if approval_requirement == RadrootsNostrSignerApprovalRequirement::NotRequired { + let granted_permissions = runtime + .signer_context() + .policy() + .auto_granted_permissions(&connection.requested_permissions); let _ = manager.set_granted_permissions( &connection.connection_id, - connection.requested_permissions.clone(), + granted_permissions, )?; } connection @@ -416,3 +450,101 @@ fn merge_relays( relays.dedup_by(|left, right| left.as_str() == right.as_str()); relays } + +#[cfg(test)] +mod tests { + use super::{accept_client_uri, authorize_auth_challenge}; + use crate::app::MycRuntime; + use crate::config::{MycConfig, MycConnectionApproval}; + use radroots_identity::RadrootsIdentity; + use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectClientMetadata, RadrootsNostrConnectClientUri, RadrootsNostrConnectUri, + }; + use std::path::PathBuf; + use std::thread; + use std::time::Duration; + + fn write_identity(path: &std::path::Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); + } + + fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime + where + F: FnOnce(&mut MycConfig), + { + let temp = tempfile::tempdir().expect("tempdir").keep(); + let mut config = MycConfig::default(); + config.paths.state_dir = PathBuf::from(&temp).join("state"); + config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); + config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); + config.policy.connection_approval = approval; + config.transport.enabled = true; + config.transport.relays = vec!["ws://127.0.0.1:65500".to_owned()]; + configure(&mut config); + write_identity( + &config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + MycRuntime::bootstrap(config).expect("runtime") + } + + #[tokio::test(flavor = "current_thread")] + async fn authorize_auth_challenge_rejects_expired_pending_challenge() { + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.auth_pending_ttl_secs = 1; + }); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection( + radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + ), + ) + .expect("register connection"); + manager + .require_auth_challenge(&connection.connection_id, "https://auth.example") + .expect("require auth challenge"); + + thread::sleep(Duration::from_secs(2)); + + let error = authorize_auth_challenge(&runtime, &connection.connection_id) + .await + .expect_err("expired auth challenge should be rejected"); + assert!(error.to_string().contains("auth challenge expired")); + } + + #[tokio::test(flavor = "current_thread")] + async fn accept_client_uri_rejects_denied_client_pubkeys() { + let denied_identity = RadrootsIdentity::from_secret_key_str( + "3333333333333333333333333333333333333333333333333333333333333333", + ) + .expect("identity"); + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.denied_client_pubkeys = vec![denied_identity.public_key().to_hex()]; + }); + let uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri { + client_public_key: denied_identity.public_key(), + relays: vec![nostr::RelayUrl::parse("ws://127.0.0.1:65500").expect("relay")], + secret: "client-secret".to_owned(), + metadata: RadrootsNostrConnectClientMetadata::default(), + }) + .to_string(); + + let error = accept_client_uri(&runtime, &uri) + .await + .expect_err("denied client should be rejected"); + assert!( + error + .to_string() + .contains("client public key denied by policy") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -8,6 +8,7 @@ pub mod control; pub mod discovery; pub mod error; pub mod logging; +pub mod policy; pub mod transport; pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot}; @@ -33,6 +34,7 @@ pub use discovery::{ render_nip05_output, verify_bundle, }; pub use error::MycError; +pub use policy::{MycConnectDecision, MycPolicyContext}; pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot}; pub async fn run() -> Result<(), MycError> { diff --git a/src/policy.rs b/src/policy.rs @@ -0,0 +1,741 @@ +use std::collections::BTreeSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +use nostr::PublicKey; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, +}; +use radroots_nostr_signer::prelude::{ + RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionRecord, + RadrootsNostrSignerManager, RadrootsNostrSignerRequestDecision, +}; + +use crate::config::{MycConnectionApproval, MycPolicyConfig}; +use crate::error::MycError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MycConnectDecision { + Allow, + RequireApproval, + Deny, +} + +#[derive(Debug, Clone)] +pub struct MycPolicyContext { + default_connect_decision: MycConnectDecision, + trusted_client_pubkeys: BTreeSet<String>, + denied_client_pubkeys: BTreeSet<String>, + permission_ceiling: RadrootsNostrConnectPermissions, + allowed_sign_event_kinds: BTreeSet<u16>, + auth_url: Option<String>, + auth_pending_ttl_secs: u64, + auth_authorized_ttl_secs: Option<u64>, + reauth_after_inactivity_secs: Option<u64>, +} + +impl MycPolicyContext { + pub fn from_config(config: &MycPolicyConfig) -> Result<Self, MycError> { + Ok(Self { + default_connect_decision: match config.connection_approval { + MycConnectionApproval::NotRequired => MycConnectDecision::Allow, + MycConnectionApproval::ExplicitUser => MycConnectDecision::RequireApproval, + MycConnectionApproval::Deny => MycConnectDecision::Deny, + }, + trusted_client_pubkeys: normalize_public_key_set(&config.trusted_client_pubkeys)?, + denied_client_pubkeys: normalize_public_key_set(&config.denied_client_pubkeys)?, + permission_ceiling: normalize_permissions(config.permission_ceiling.clone()), + allowed_sign_event_kinds: config.allowed_sign_event_kinds.iter().copied().collect(), + auth_url: config.auth_url.clone(), + auth_pending_ttl_secs: config.auth_pending_ttl_secs, + auth_authorized_ttl_secs: config.auth_authorized_ttl_secs, + reauth_after_inactivity_secs: config.reauth_after_inactivity_secs, + }) + } + + pub fn default_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement { + match self.default_connect_decision { + MycConnectDecision::Allow => RadrootsNostrSignerApprovalRequirement::NotRequired, + MycConnectDecision::RequireApproval | MycConnectDecision::Deny => { + RadrootsNostrSignerApprovalRequirement::ExplicitUser + } + } + } + + pub fn connect_decision(&self, client_public_key: &PublicKey) -> MycConnectDecision { + let client_public_key_hex = client_public_key.to_hex(); + if self.denied_client_pubkeys.contains(&client_public_key_hex) { + return MycConnectDecision::Deny; + } + if self.trusted_client_pubkeys.contains(&client_public_key_hex) { + return MycConnectDecision::Allow; + } + self.default_connect_decision + } + + pub fn approval_requirement_for_client( + &self, + client_public_key: &PublicKey, + ) -> Option<RadrootsNostrSignerApprovalRequirement> { + match self.connect_decision(client_public_key) { + MycConnectDecision::Allow => Some(RadrootsNostrSignerApprovalRequirement::NotRequired), + MycConnectDecision::RequireApproval => { + Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser) + } + MycConnectDecision::Deny => None, + } + } + + pub fn auto_granted_permissions( + &self, + requested_permissions: &RadrootsNostrConnectPermissions, + ) -> RadrootsNostrConnectPermissions { + self.filtered_requested_permissions(requested_permissions) + } + + pub fn filtered_requested_permissions( + &self, + requested_permissions: &RadrootsNostrConnectPermissions, + ) -> RadrootsNostrConnectPermissions { + let mut filtered = Vec::new(); + + for permission in requested_permissions.as_slice() { + if permission.method == RadrootsNostrConnectMethod::SignEvent + && permission.parameter.is_none() + && !self.allowed_sign_event_kinds.is_empty() + { + for kind in &self.allowed_sign_event_kinds { + let candidate = RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + format!("kind:{kind}"), + ); + if self.permission_within_policy(&candidate) { + filtered.push(candidate); + } + } + continue; + } + + if self.permission_within_policy(permission) { + filtered.push(permission.clone()); + } + } + + normalize_permissions(filtered.into()) + } + + pub fn validate_operator_grants( + &self, + granted_permissions: RadrootsNostrConnectPermissions, + ) -> Result<RadrootsNostrConnectPermissions, MycError> { + let granted_permissions = normalize_permissions(granted_permissions); + let invalid_permissions = granted_permissions + .as_slice() + .iter() + .filter(|permission| !self.permission_within_policy(permission)) + .map(ToString::to_string) + .collect::<Vec<_>>(); + + if invalid_permissions.is_empty() { + Ok(granted_permissions) + } else { + Err(MycError::InvalidOperation(format!( + "granted permissions exceed the configured policy ceiling: {}", + invalid_permissions.join(", ") + ))) + } + } + + pub fn prepare_request( + &self, + manager: &RadrootsNostrSignerManager, + connection: &RadrootsNostrSignerConnectionRecord, + request_message: &RadrootsNostrConnectRequestMessage, + ) -> Result<Option<String>, MycError> { + if self.client_is_denied(&connection.client_public_key) { + return Ok(Some("client public key denied by policy".to_owned())); + } + + if let Some(reason) = self.request_denied_reason(&request_message.request) { + return Ok(Some(reason)); + } + + if connection.auth_state + == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending + && self.auth_challenge_is_expired(connection) + { + if self.request_uses_automatic_auth(connection, &request_message.request) { + manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; + } else { + return Ok(Some( + "auth challenge expired; require a new auth challenge".to_owned(), + )); + } + } else if self.should_require_fresh_auth(connection, &request_message.request) { + manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; + } + + Ok(None) + } + + pub fn ensure_authorize_auth_challenge_allowed( + &self, + connection: &RadrootsNostrSignerConnectionRecord, + ) -> Result<(), MycError> { + if connection.auth_state + == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending + && self.auth_challenge_is_expired(connection) + { + return Err(MycError::InvalidOperation( + "auth challenge expired; require a new auth challenge".to_owned(), + )); + } + Ok(()) + } + + pub fn record_policy_denied_request( + &self, + manager: &RadrootsNostrSignerManager, + connection: &RadrootsNostrSignerConnectionRecord, + request_message: &RadrootsNostrConnectRequestMessage, + reason: impl Into<String>, + ) -> Result<String, MycError> { + let reason = reason.into(); + manager.record_request( + &connection.connection_id, + &request_message.id, + request_message.request.method(), + RadrootsNostrSignerRequestDecision::Denied, + Some(reason.clone()), + )?; + Ok(reason) + } + + fn client_is_denied(&self, client_public_key: &PublicKey) -> bool { + self.denied_client_pubkeys + .contains(&client_public_key.to_hex()) + } + + fn client_is_trusted(&self, client_public_key: &PublicKey) -> bool { + self.trusted_client_pubkeys + .contains(&client_public_key.to_hex()) + } + + fn permission_within_policy(&self, permission: &RadrootsNostrConnectPermission) -> bool { + if permission.method == RadrootsNostrConnectMethod::SignEvent + && !self.allowed_sign_event_kinds.is_empty() + { + let Some(kind) = permission + .parameter + .as_deref() + .and_then(parse_sign_event_kind_parameter) + else { + return false; + }; + if !self.allowed_sign_event_kinds.contains(&kind) { + return false; + } + } + + if self.permission_ceiling.is_empty() { + return true; + } + + self.permission_ceiling + .as_slice() + .iter() + .any(|ceiling| permission_within_ceiling(permission, ceiling)) + } + + fn request_denied_reason(&self, request: &RadrootsNostrConnectRequest) -> Option<String> { + if self.permission_ceiling.is_empty() + && (self.allowed_sign_event_kinds.is_empty() + || !matches!(request, RadrootsNostrConnectRequest::SignEvent(_))) + { + return None; + } + + let required_permission = required_permission_for_request(request)?; + if self.permission_within_policy(&required_permission) { + None + } else { + Some(format!( + "request {} is outside the configured policy ceiling", + request.method() + )) + } + } + + fn request_uses_automatic_auth( + &self, + connection: &RadrootsNostrSignerConnectionRecord, + request: &RadrootsNostrConnectRequest, + ) -> bool { + self.auth_url.is_some() + && self.client_is_trusted(&connection.client_public_key) + && request_requires_auth(request) + } + + fn should_require_fresh_auth( + &self, + connection: &RadrootsNostrSignerConnectionRecord, + request: &RadrootsNostrConnectRequest, + ) -> bool { + if !self.request_uses_automatic_auth(connection, request) { + return false; + } + + if connection.auth_state + == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending + { + return false; + } + + let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else { + return true; + }; + let now_unix = now_unix_secs(); + + if self + .auth_authorized_ttl_secs + .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl)) + { + return true; + } + + self.reauth_after_inactivity_secs.is_some_and(|ttl| { + let Some(last_request_at_unix) = connection.last_request_at_unix else { + return false; + }; + now_unix > last_request_at_unix.saturating_add(ttl) + }) + } + + fn auth_challenge_is_expired(&self, connection: &RadrootsNostrSignerConnectionRecord) -> bool { + let Some(auth_challenge) = connection.auth_challenge.as_ref() else { + return false; + }; + now_unix_secs() + > auth_challenge + .required_at_unix + .saturating_add(self.auth_pending_ttl_secs) + } + + fn auth_url(&self) -> Result<&str, MycError> { + self.auth_url.as_deref().ok_or_else(|| { + MycError::InvalidOperation( + "automatic auth policy requires policy.auth_url to be configured".to_owned(), + ) + }) + } +} + +fn normalize_permissions( + permissions: RadrootsNostrConnectPermissions, +) -> RadrootsNostrConnectPermissions { + let mut permissions = permissions.into_vec(); + permissions.sort(); + permissions.dedup(); + permissions.into() +} + +fn normalize_public_key_set(values: &[String]) -> Result<BTreeSet<String>, MycError> { + values + .iter() + .map(|value| normalize_public_key_hex(value)) + .collect() +} + +fn normalize_public_key_hex(value: &str) -> Result<String, MycError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(MycError::InvalidConfig( + "policy client pubkeys must not contain empty values".to_owned(), + )); + } + let public_key = PublicKey::parse(trimmed) + .or_else(|_| PublicKey::from_hex(trimmed)) + .map_err(|_| { + MycError::InvalidConfig(format!( + "policy client pubkey `{trimmed}` is not a valid nostr public key" + )) + })?; + Ok(public_key.to_hex()) +} + +fn required_permission_for_request( + request: &RadrootsNostrConnectRequest, +) -> Option<RadrootsNostrConnectPermission> { + match request { + RadrootsNostrConnectRequest::Connect { .. } + | RadrootsNostrConnectRequest::GetPublicKey + | RadrootsNostrConnectRequest::Ping => None, + RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { + Some(RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + format!("kind:{}", unsigned_event.kind.as_u16()), + )) + } + RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + ), + RadrootsNostrConnectRequest::Nip04Decrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Decrypt), + ), + RadrootsNostrConnectRequest::Nip44Encrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), + ), + RadrootsNostrConnectRequest::Nip44Decrypt { .. } => Some( + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Decrypt), + ), + RadrootsNostrConnectRequest::SwitchRelays => Some(RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::SwitchRelays, + )), + RadrootsNostrConnectRequest::Custom { method, .. } => { + Some(RadrootsNostrConnectPermission::new(method.clone())) + } + } +} + +fn permission_within_ceiling( + permission: &RadrootsNostrConnectPermission, + ceiling: &RadrootsNostrConnectPermission, +) -> bool { + if permission.method != ceiling.method { + return false; + } + + match ( + &permission.method, + permission.parameter.as_deref(), + ceiling.parameter.as_deref(), + ) { + (RadrootsNostrConnectMethod::SignEvent, _, None) => true, + (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(ceiling_parameter)) => { + sign_event_parameter_eq(parameter, ceiling_parameter) + } + (RadrootsNostrConnectMethod::SignEvent, None, Some(_)) => false, + (_, _, None) => true, + (_, Some(parameter), Some(ceiling_parameter)) => parameter == ceiling_parameter, + (_, None, Some(_)) => false, + } +} + +fn sign_event_parameter_eq(left: &str, right: &str) -> bool { + parse_sign_event_kind_parameter(left) == parse_sign_event_kind_parameter(right) +} + +fn parse_sign_event_kind_parameter(value: &str) -> Option<u16> { + value + .strip_prefix("kind:") + .unwrap_or(value) + .parse::<u16>() + .ok() +} + +fn request_requires_auth(request: &RadrootsNostrConnectRequest) -> bool { + !matches!( + request, + RadrootsNostrConnectRequest::Connect { .. } + | RadrootsNostrConnectRequest::GetPublicKey + | RadrootsNostrConnectRequest::Ping + ) +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::{MycConnectDecision, MycPolicyContext}; + use crate::config::{MycConnectionApproval, MycPolicyConfig}; + use nostr::PublicKey; + use radroots_identity::RadrootsIdentity; + use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, + }; + use radroots_nostr_signer::prelude::{ + RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState, + RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerManager, + }; + use serde_json::json; + use std::thread; + use std::time::Duration; + + fn public_key(hex: &str) -> PublicKey { + PublicKey::parse(hex).expect("public key") + } + + fn identity(secret_key: &str) -> RadrootsIdentity { + RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") + } + + fn in_memory_manager() -> RadrootsNostrSignerManager { + let manager = RadrootsNostrSignerManager::new_in_memory(); + manager + .set_signer_identity( + identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .to_public(), + ) + .expect("set signer identity"); + manager + } + + fn register_connection( + manager: &RadrootsNostrSignerManager, + client_public_key: PublicKey, + ) -> radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionRecord { + manager + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + client_public_key, + identity("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .to_public(), + ) + .with_requested_permissions( + vec![RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + )] + .into(), + ) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), + ) + .expect("register connection") + } + + fn unsigned_event(kind: u16) -> nostr::UnsignedEvent { + serde_json::from_value(json!({ + "pubkey": public_key("1111111111111111111111111111111111111111111111111111111111111111").to_hex(), + "created_at": 1, + "kind": kind, + "tags": [], + "content": "hello" + })) + .expect("unsigned event") + } + + #[test] + fn connect_decision_prefers_deny_then_trust_then_default() { + let mut config = MycPolicyConfig::default(); + config.connection_approval = MycConnectionApproval::ExplicitUser; + config.trusted_client_pubkeys = + vec!["2222222222222222222222222222222222222222222222222222222222222222".to_owned()]; + config.denied_client_pubkeys = + vec!["3333333333333333333333333333333333333333333333333333333333333333".to_owned()]; + let policy = MycPolicyContext::from_config(&config).expect("policy"); + + assert_eq!( + policy.connect_decision(&public_key( + "2222222222222222222222222222222222222222222222222222222222222222" + )), + MycConnectDecision::Allow + ); + assert_eq!( + policy.connect_decision(&public_key( + "3333333333333333333333333333333333333333333333333333333333333333" + )), + MycConnectDecision::Deny + ); + assert_eq!( + policy.connect_decision(&public_key( + "4444444444444444444444444444444444444444444444444444444444444444" + )), + MycConnectDecision::RequireApproval + ); + } + + #[test] + fn auto_granted_permissions_apply_policy_ceiling_and_kind_limits() { + let mut config = MycPolicyConfig::default(); + config.permission_ceiling = vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ), + ] + .into(); + config.allowed_sign_event_kinds = vec![1]; + let policy = MycPolicyContext::from_config(&config).expect("policy"); + + let requested_permissions: RadrootsNostrConnectPermissions = vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent), + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:2", + ), + ] + .into(); + let filtered = policy.auto_granted_permissions(&requested_permissions); + + assert_eq!(filtered.to_string(), "sign_event:kind:1,nip04_encrypt"); + } + + #[test] + fn request_denied_reason_applies_sign_event_kind_limits() { + let mut config = MycPolicyConfig::default(); + config.allowed_sign_event_kinds = vec![1]; + let policy = MycPolicyContext::from_config(&config).expect("policy"); + let manager = in_memory_manager(); + let connection = register_connection( + &manager, + public_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ); + + let denied = policy + .prepare_request( + &manager, + &connection, + &RadrootsNostrConnectRequestMessage::new( + "request-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event(2)), + ), + ) + .expect("prepare request"); + + assert_eq!( + denied, + Some("request sign_event is outside the configured policy ceiling".to_owned()) + ); + } + + #[test] + fn validate_operator_grants_rejects_out_of_policy_permissions() { + let mut config = MycPolicyConfig::default(); + config.permission_ceiling = + RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Nip04Encrypt, + )]); + let policy = MycPolicyContext::from_config(&config).expect("policy"); + + let error = policy + .validate_operator_grants( + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Nip44Encrypt, + )] + .into(), + ) + .expect_err("grant outside ceiling"); + assert!( + error + .to_string() + .contains("granted permissions exceed the configured policy ceiling") + ); + } + + #[test] + fn prepare_request_requires_fresh_auth_after_authorized_ttl() { + let client_public_key = + public_key("2222222222222222222222222222222222222222222222222222222222222222"); + let mut config = MycPolicyConfig::default(); + config.trusted_client_pubkeys = vec![client_public_key.to_hex()]; + config.auth_url = Some("https://auth.example".to_owned()); + config.auth_authorized_ttl_secs = Some(1); + let policy = MycPolicyContext::from_config(&config).expect("policy"); + let manager = in_memory_manager(); + let connection = register_connection(&manager, client_public_key); + + manager + .require_auth_challenge(&connection.connection_id, "https://auth.example") + .expect("require auth challenge"); + manager + .authorize_auth_challenge(&connection.connection_id) + .expect("authorize auth challenge"); + thread::sleep(Duration::from_secs(2)); + + let connection = manager + .get_connection(&connection.connection_id) + .expect("connection lookup") + .expect("connection"); + let denied = policy + .prepare_request( + &manager, + &connection, + &RadrootsNostrConnectRequestMessage::new( + "request-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)), + ), + ) + .expect("prepare request"); + + assert_eq!(denied, None); + let updated_connection = manager + .get_connection(&connection.connection_id) + .expect("connection lookup") + .expect("connection"); + assert_eq!( + updated_connection.auth_state, + RadrootsNostrSignerAuthState::Pending + ); + assert_eq!( + updated_connection + .auth_challenge + .expect("auth challenge") + .auth_url, + "https://auth.example/" + ); + } + + #[test] + fn prepare_request_requires_fresh_auth_after_inactivity() { + let client_public_key = + public_key("2323232323232323232323232323232323232323232323232323232323232323"); + let mut config = MycPolicyConfig::default(); + config.trusted_client_pubkeys = vec![client_public_key.to_hex()]; + config.auth_url = Some("https://auth.example".to_owned()); + config.reauth_after_inactivity_secs = Some(1); + let policy = MycPolicyContext::from_config(&config).expect("policy"); + let manager = in_memory_manager(); + let connection = register_connection(&manager, client_public_key); + + manager + .require_auth_challenge(&connection.connection_id, "https://auth.example") + .expect("require auth challenge"); + manager + .authorize_auth_challenge(&connection.connection_id) + .expect("authorize auth challenge"); + manager + .record_request( + &connection.connection_id, + "request-0", + RadrootsNostrConnectMethod::SignEvent, + radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed, + None, + ) + .expect("record request"); + thread::sleep(Duration::from_secs(2)); + + let connection = manager + .get_connection(&connection.connection_id) + .expect("connection lookup") + .expect("connection"); + let denied = policy + .prepare_request( + &manager, + &connection, + &RadrootsNostrConnectRequestMessage::new( + "request-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)), + ), + ) + .expect("prepare request"); + + assert_eq!(denied, None); + let updated_connection = manager + .get_connection(&connection.connection_id) + .expect("connection lookup") + .expect("connection"); + assert_eq!( + updated_connection.auth_state, + RadrootsNostrSignerAuthState::Pending + ); + } +} diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -9,13 +9,14 @@ use radroots_nostr::prelude::{ RadrootsNostrTag, RadrootsNostrTimestamp, radroots_nostr_filter_tag, radroots_nostr_kind, }; use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; use radroots_nostr_signer::prelude::{ RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAction, - RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup, + RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerRequestResponseHint, + RadrootsNostrSignerSessionLookup, }; use tokio::sync::broadcast; @@ -45,6 +46,11 @@ pub(crate) enum MycNip46HandledRequest { Ignore, } +enum MycPreparedRequestEvaluation { + Denied(String), + Evaluation(RadrootsNostrSignerRequestEvaluation), +} + impl MycNip46Handler { pub fn new(signer: MycSignerContext, relays: Vec<RadrootsNostrRelayUrl>) -> Self { Self { signer, relays } @@ -157,6 +163,7 @@ impl MycNip46Handler { secret: Option<String>, ) -> Result<MycNip46HandledRequest, MycError> { let manager = self.signer.load_signer_manager()?; + let connect_decision = self.signer.policy().connect_decision(&client_public_key); if let Some(connect_secret) = secret.as_deref() { if let Some(connection) = manager.find_connection_by_connect_secret(connect_secret)? { if connection.connect_secret_is_consumed() { @@ -179,19 +186,46 @@ impl MycNip46Handler { ); return Ok(MycNip46HandledRequest::Ignore); } + if matches!(connect_decision, crate::policy::MycConnectDecision::Deny) { + return Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::Error { + result: None, + error: "client public key denied by policy".to_owned(), + }, + )); + } Ok(connect_response_outcome(&connection, secret)) } RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => { + let requested_permissions = self + .signer + .policy() + .filtered_requested_permissions(&proposal.requested_permissions); + let Some(approval_requirement) = self + .signer + .policy() + .approval_requirement_for_client(&client_public_key) + else { + return Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::Error { + result: None, + error: "client public key denied by policy".to_owned(), + }, + )); + }; let draft = proposal .into_connection_draft(self.signer.user_public_identity()) + .with_requested_permissions(requested_permissions) .with_relays(self.relays.clone()) - .with_approval_requirement(self.signer.connection_approval_requirement()); + .with_approval_requirement(approval_requirement); let connection = manager.register_connection(draft)?; - if self.signer.connection_approval_requirement() + if approval_requirement == radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement::NotRequired { - let granted_permissions = - grant_permissions_for_new_connection(connection.requested_permissions.clone()); + let granted_permissions = self + .signer + .policy() + .auto_granted_permissions(&connection.requested_permissions); let _ = manager.set_granted_permissions( &connection.connection_id, granted_permissions, @@ -212,11 +246,8 @@ impl MycNip46Handler { Err(response) => return Ok(MycNip46HandledRequest::respond(response)), }; - let manager = self.signer.load_signer_manager()?; - let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; - - match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => { + match self.evaluate_request_with_policy(&connection, request_message)? { + MycPreparedRequestEvaluation::Denied(reason) => { Ok(MycNip46HandledRequest::respond_for_connection( Some(connection.connection_id.clone()), RadrootsNostrConnectResponse::Error { @@ -225,20 +256,31 @@ impl MycNip46Handler { }, )) } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - Ok(MycNip46HandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), - )) - } - RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { - response_from_hint(&evaluation.connection, response_hint).map(|response| { - MycNip46HandledRequest::respond_for_connection( - Some(evaluation.connection.connection_id.clone()), - response, - ) - }) - } + MycPreparedRequestEvaluation::Evaluation(evaluation) => match evaluation.action { + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )) + } + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + )) + } + RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { + response_from_hint(&evaluation.connection, response_hint).map(|response| { + MycNip46HandledRequest::respond_for_connection( + Some(evaluation.connection.connection_id.clone()), + response, + ) + }) + } + }, } } @@ -253,11 +295,8 @@ impl MycNip46Handler { Err(response) => return Ok(MycNip46HandledRequest::respond(response)), }; - let manager = self.signer.load_signer_manager()?; - let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; - - match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => { + match self.evaluate_request_with_policy(&connection, request_message)? { + MycPreparedRequestEvaluation::Denied(reason) => { Ok(MycNip46HandledRequest::respond_for_connection( Some(connection.connection_id.clone()), RadrootsNostrConnectResponse::Error { @@ -266,20 +305,31 @@ impl MycNip46Handler { }, )) } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - Ok(MycNip46HandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), - )) - } - RadrootsNostrSignerRequestAction::Allowed { .. } => { - self.sign_event_response(unsigned_event).map(|response| { - MycNip46HandledRequest::respond_for_connection( + MycPreparedRequestEvaluation::Evaluation(evaluation) => match evaluation.action { + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(MycNip46HandledRequest::respond_for_connection( Some(connection.connection_id.clone()), - response, - ) - }) - } + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )) + } + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + )) + } + RadrootsNostrSignerRequestAction::Allowed { .. } => { + self.sign_event_response(unsigned_event).map(|response| { + MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + response, + ) + }) + } + }, } } @@ -294,11 +344,8 @@ impl MycNip46Handler { Err(response) => return Ok(MycNip46HandledRequest::respond(response)), }; - let manager = self.signer.load_signer_manager()?; - let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; - - match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => { + match self.evaluate_request_with_policy(&connection, request_message)? { + MycPreparedRequestEvaluation::Denied(reason) => { Ok(MycNip46HandledRequest::respond_for_connection( Some(connection.connection_id.clone()), RadrootsNostrConnectResponse::Error { @@ -307,23 +354,59 @@ impl MycNip46Handler { }, )) } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { - Ok(MycNip46HandledRequest::respond_for_connection( - Some(connection.connection_id.clone()), - RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), - )) - } - RadrootsNostrSignerRequestAction::Allowed { .. } => { - self.crypto_response(request).map(|response| { - MycNip46HandledRequest::respond_for_connection( + MycPreparedRequestEvaluation::Evaluation(evaluation) => match evaluation.action { + RadrootsNostrSignerRequestAction::Denied { reason } => { + Ok(MycNip46HandledRequest::respond_for_connection( Some(connection.connection_id.clone()), - response, - ) - }) - } + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + )) + } + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { + Ok(MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + )) + } + RadrootsNostrSignerRequestAction::Allowed { .. } => { + self.crypto_response(request).map(|response| { + MycNip46HandledRequest::respond_for_connection( + Some(connection.connection_id.clone()), + response, + ) + }) + } + }, } } + fn evaluate_request_with_policy( + &self, + connection: &RadrootsNostrSignerConnectionRecord, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<MycPreparedRequestEvaluation, MycError> { + let manager = self.signer.load_signer_manager()?; + if let Some(reason) = + self.signer + .policy() + .prepare_request(&manager, connection, &request_message)? + { + let reason = self.signer.policy().record_policy_denied_request( + &manager, + connection, + &request_message, + reason, + )?; + return Ok(MycPreparedRequestEvaluation::Denied(reason)); + } + + Ok(MycPreparedRequestEvaluation::Evaluation( + manager.evaluate_request(&connection.connection_id, request_message)?, + )) + } + fn lookup_connection( &self, client_public_key: RadrootsNostrPublicKey, @@ -656,15 +739,6 @@ fn connect_response_outcome( } } -fn grant_permissions_for_new_connection( - requested_permissions: RadrootsNostrConnectPermissions, -) -> RadrootsNostrConnectPermissions { - let mut granted = requested_permissions.into_vec(); - granted.sort(); - granted.dedup(); - granted.into() -} - fn response_from_hint( connection: &RadrootsNostrSignerConnectionRecord, hint: RadrootsNostrSignerRequestResponseHint, @@ -701,6 +775,7 @@ mod tests { RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, }; + use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionRecord; use serde_json::json; use crate::app::MycRuntime; @@ -716,15 +791,23 @@ mod tests { } fn runtime() -> MycRuntime { + runtime_with_config(MycConnectionApproval::NotRequired, |_| {}) + } + + fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime + where + F: FnOnce(&mut MycConfig), + { let temp = tempfile::tempdir().expect("tempdir").keep(); let mut config = MycConfig::default(); config.paths.state_dir = temp.join("state"); config.paths.signer_identity_path = temp.join("signer.json"); config.paths.user_identity_path = temp.join("user.json"); - config.policy.connection_approval = MycConnectionApproval::NotRequired; + config.policy.connection_approval = approval; config.transport.enabled = true; config.transport.connect_timeout_secs = 15; config.transport.relays = vec!["wss://relay.example.com".to_owned()]; + configure(&mut config); write_identity( &config.paths.signer_identity_path, "1111111111111111111111111111111111111111111111111111111111111111", @@ -737,24 +820,7 @@ mod tests { } fn runtime_with_explicit_approval() -> MycRuntime { - let temp = tempfile::tempdir().expect("tempdir").keep(); - let mut config = MycConfig::default(); - config.paths.state_dir = temp.join("state"); - config.paths.signer_identity_path = temp.join("signer.json"); - config.paths.user_identity_path = temp.join("user.json"); - config.policy.connection_approval = MycConnectionApproval::ExplicitUser; - config.transport.enabled = true; - config.transport.connect_timeout_secs = 15; - config.transport.relays = vec!["wss://relay.example.com".to_owned()]; - write_identity( - &config.paths.signer_identity_path, - "1111111111111111111111111111111111111111111111111111111111111111", - ); - write_identity( - &config.paths.user_identity_path, - "2222222222222222222222222222222222222222222222222222222222222222", - ); - MycRuntime::bootstrap(config).expect("runtime") + runtime_with_config(MycConnectionApproval::ExplicitUser, |_| {}) } fn handler(runtime: &MycRuntime) -> MycNip46Handler { @@ -765,9 +831,11 @@ mod tests { } fn client_keys() -> Keys { - let secret = - SecretKey::from_hex("3333333333333333333333333333333333333333333333333333333333333333") - .expect("secret"); + client_keys_from_hex("3333333333333333333333333333333333333333333333333333333333333333") + } + + fn client_keys_from_hex(secret_key: &str) -> Keys { + let secret = SecretKey::from_hex(secret_key).expect("secret"); Keys::new(secret) } @@ -775,7 +843,14 @@ mod tests { handler: &MycNip46Handler, request: RadrootsNostrConnectRequestMessage, ) -> nostr::Event { - let client_keys = client_keys(); + request_event_with_client_keys(handler, request, &client_keys()) + } + + fn request_event_with_client_keys( + handler: &MycNip46Handler, + request: RadrootsNostrConnectRequestMessage, + client_keys: &Keys, + ) -> nostr::Event { let payload = serde_json::to_string(&request).expect("serialize request"); let ciphertext = nip44::encrypt( client_keys.secret_key(), @@ -798,7 +873,7 @@ mod tests { .tags(vec![RadrootsNostrTag::public_key( handler.signer.signer_identity().public_key(), )]) - .sign_with_keys(&client_keys) + .sign_with_keys(client_keys) .expect("sign request") } @@ -840,6 +915,20 @@ mod tests { .expect("connect"); } + fn connection_for( + runtime: &MycRuntime, + client_public_key: PublicKey, + ) -> RadrootsNostrSignerConnectionRecord { + runtime + .signer_manager() + .expect("manager") + .find_connections_by_client_public_key(&client_public_key) + .expect("connections") + .into_iter() + .next() + .expect("connection") + } + #[test] fn parse_and_build_nip46_envelopes_roundtrip() { let runtime = runtime(); @@ -909,6 +998,47 @@ mod tests { } #[test] + fn denied_clients_are_rejected_without_registration() { + let denied_client_keys = client_keys_from_hex( + "4444444444444444444444444444444444444444444444444444444444444444", + ); + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.denied_client_pubkeys = vec![denied_client_keys.public_key().to_hex()]; + }); + let handler = handler(&runtime); + + let response = handler + .handle_request_response( + denied_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: Default::default(), + }, + ), + ) + .expect("connect response"); + + assert_eq!( + response, + RadrootsNostrConnectResponse::Error { + result: None, + error: "client public key denied by policy".to_owned(), + } + ); + assert!( + runtime + .signer_manager() + .expect("manager") + .list_connections() + .expect("connections") + .is_empty() + ); + } + + #[test] fn existing_unconsumed_connect_secret_can_still_retry_after_failed_publish() { let runtime = runtime(); let handler = handler(&runtime); @@ -1047,6 +1177,223 @@ mod tests { } #[test] + fn trusted_clients_auto_grant_only_policy_allowed_permissions() { + let trusted_client_keys = client_keys_from_hex( + "4545454545454545454545454545454545454545454545454545454545454545", + ); + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; + config.policy.permission_ceiling = vec![ + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), + sign_event_permission(1), + ] + .into(); + config.policy.allowed_sign_event_kinds = vec![1]; + }); + let handler = handler(&runtime); + + let response = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: vec![ + RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Nip04Encrypt, + ), + RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::SignEvent, + ), + sign_event_permission(7), + ] + .into(), + }, + ), + ) + .expect("connect response"); + + assert_eq!(response, RadrootsNostrConnectResponse::ConnectAcknowledged); + let connection = connection_for(&runtime, trusted_client_keys.public_key()); + assert_eq!( + connection.granted_permissions().to_string(), + "sign_event:kind:1,nip04_encrypt" + ); + assert_eq!( + connection.requested_permissions.to_string(), + "sign_event:kind:1,nip04_encrypt" + ); + } + + #[test] + fn trusted_client_requires_auth_again_after_authorized_ttl() { + let trusted_client_keys = client_keys_from_hex( + "5656565656565656565656565656565656565656565656565656565656565656", + ); + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; + config.policy.permission_ceiling = vec![sign_event_permission(1)].into(); + config.policy.allowed_sign_event_kinds = vec![1]; + config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); + config.policy.auth_authorized_ttl_secs = Some(1); + }); + let handler = handler(&runtime); + + let _ = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: vec![sign_event_permission(1)].into(), + }, + ), + ) + .expect("connect"); + + let first = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "first", + )), + ), + ) + .expect("first sign request"); + assert_eq!( + first, + RadrootsNostrConnectResponse::AuthUrl("https://auth.example/challenge".to_owned()) + ); + + let connection = connection_for(&runtime, trusted_client_keys.public_key()); + runtime + .signer_manager() + .expect("manager") + .authorize_auth_challenge(&connection.connection_id) + .expect("authorize auth challenge"); + + let second = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-2", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "second", + )), + ), + ) + .expect("second sign request"); + assert!(matches!( + second, + RadrootsNostrConnectResponse::SignedEvent(_) + )); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let third = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-3", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "third", + )), + ), + ) + .expect("third sign request"); + assert_eq!( + third, + RadrootsNostrConnectResponse::AuthUrl("https://auth.example/challenge".to_owned()) + ); + } + + #[test] + fn trusted_client_requires_auth_again_after_inactivity() { + let trusted_client_keys = client_keys_from_hex( + "5757575757575757575757575757575757575757575757575757575757575757", + ); + let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { + config.policy.trusted_client_pubkeys = vec![trusted_client_keys.public_key().to_hex()]; + config.policy.permission_ceiling = vec![sign_event_permission(1)].into(); + config.policy.allowed_sign_event_kinds = vec![1]; + config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); + config.policy.reauth_after_inactivity_secs = Some(1); + }); + let handler = handler(&runtime); + + let _ = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: None, + requested_permissions: vec![sign_event_permission(1)].into(), + }, + ), + ) + .expect("connect"); + + let first = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-1", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "first", + )), + ), + ) + .expect("first sign request"); + assert_eq!( + first, + RadrootsNostrConnectResponse::AuthUrl("https://auth.example/challenge".to_owned()) + ); + + let connection = connection_for(&runtime, trusted_client_keys.public_key()); + runtime + .signer_manager() + .expect("manager") + .authorize_auth_challenge(&connection.connection_id) + .expect("authorize auth challenge"); + + std::thread::sleep(std::time::Duration::from_secs(2)); + + let second = handler + .handle_request_response( + trusted_client_keys.public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-sign-2", + RadrootsNostrConnectRequest::SignEvent(unsigned_event( + runtime.user_identity().public_key(), + 1, + "second", + )), + ), + ) + .expect("second sign request"); + assert_eq!( + second, + RadrootsNostrConnectResponse::AuthUrl("https://auth.example/challenge".to_owned()) + ); + } + + #[test] fn base_methods_return_spec_results_after_connect() { let runtime = runtime(); let handler = handler(&runtime); diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -680,6 +680,69 @@ async fn wait_for_operation_audit_count( } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn live_listener_rejects_denied_clients_without_registering_connection() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let client_identity = + identity("7777777777777777777777777777777777777777777777777777777777777777"); + let test_runtime = MycTestRuntime::new_with_transport_config( + &[relay.url()], + MycConnectionApproval::ExplicitUser, + |config| { + config.policy.denied_client_pubkeys = vec![client_identity.public_key().to_hex()]; + }, + ); + let runtime = test_runtime.runtime.clone(); + let signer_public_key = runtime.signer_identity().public_key(); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let service_runtime = runtime.clone(); + let listener_task = tokio::spawn(async move { + service_runtime + .run_until(async { + let _ = shutdown_rx.await; + }) + .await + }); + + relay.wait_for_subscription_count(1).await?; + + let request_event = build_request_event( + &client_identity, + signer_public_key, + connect_request_message("denied-connect", signer_public_key, "denied-secret"), + Timestamp::now().as_secs(), + ); + publish_event(relay.url(), &request_event).await?; + + let response_events = relay + .wait_for_published_events_by_author(signer_public_key, 1) + .await?; + let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); + assert_eq!(response.id, "denied-connect"); + let parsed = radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: Some("denied-secret".to_owned()), + requested_permissions: Default::default(), + } + .method(), + response, + )?; + assert_eq!( + parsed, + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::Error { + result: None, + error: "client public key denied by policy".to_owned(), + } + ); + assert!(runtime.signer_manager()?.list_connections()?.is_empty()); + + let _ = shutdown_tx.send(()); + listener_task.await??; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn live_listener_consumes_connect_secret_only_after_successful_publish() -> TestResult<()> { let relay = TestRelay::spawn().await?; let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); @@ -812,6 +875,196 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() - } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let client_identity = + identity("7878787878787878787878787878787878787878787878787878787878787878"); + let test_runtime = MycTestRuntime::new_with_transport_config( + &[relay.url()], + MycConnectionApproval::ExplicitUser, + |config| { + config.policy.trusted_client_pubkeys = vec![client_identity.public_key().to_hex()]; + config.policy.permission_ceiling = "sign_event:1".parse().expect("permission ceiling"); + config.policy.allowed_sign_event_kinds = vec![1]; + config.policy.auth_url = Some("https://auth.example/challenge".to_owned()); + config.policy.auth_authorized_ttl_secs = Some(1); + }, + ); + let runtime = test_runtime.runtime.clone(); + let signer_public_key = runtime.signer_identity().public_key(); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let service_runtime = runtime.clone(); + let listener_task = tokio::spawn(async move { + service_runtime + .run_until(async { + let _ = shutdown_rx.await; + }) + .await + }); + + relay.wait_for_subscription_count(1).await?; + + let connect_request = build_request_event( + &client_identity, + signer_public_key, + RadrootsNostrConnectRequestMessage::new( + "trusted-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: None, + requested_permissions: "sign_event:1".parse().expect("requested permissions"), + }, + ), + Timestamp::now().as_secs(), + ); + publish_event(relay.url(), &connect_request).await?; + let response_events = relay + .wait_for_published_events_by_author(signer_public_key, 1) + .await?; + let connect_response = + decrypt_response(&client_identity, signer_public_key, &response_events[0]); + let connect_parsed = + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: signer_public_key, + secret: None, + requested_permissions: "sign_event:1".parse().expect("requested permissions"), + } + .method(), + connect_response, + )?; + assert_eq!( + connect_parsed, + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::ConnectAcknowledged + ); + + let sign_request = |request_id: &str, created_at_unix| { + build_request_event( + &client_identity, + signer_public_key, + RadrootsNostrConnectRequestMessage::new( + request_id, + RadrootsNostrConnectRequest::SignEvent( + serde_json::from_value(serde_json::json!({ + "pubkey": runtime.user_identity().public_key().to_hex(), + "created_at": created_at_unix, + "kind": 1, + "tags": [], + "content": request_id + })) + .expect("unsigned event"), + ), + ), + created_at_unix, + ) + }; + + publish_event( + relay.url(), + &sign_request("trusted-sign-1", Timestamp::now().as_secs()), + ) + .await?; + let response_events = relay + .wait_for_published_events_by_author(signer_public_key, 2) + .await?; + let first_auth = decrypt_response(&client_identity, signer_public_key, &response_events[1]); + let first_auth = radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::SignEvent( + serde_json::from_value(serde_json::json!({ + "pubkey": runtime.user_identity().public_key().to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": 1, + "tags": [], + "content": "trusted-sign-1" + })) + .expect("unsigned event"), + ) + .method(), + first_auth, + )?; + assert_eq!( + first_auth, + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::AuthUrl( + "https://auth.example/challenge".to_owned() + ) + ); + + let connection = runtime + .signer_manager()? + .list_connections()? + .into_iter() + .next() + .expect("connection"); + let replayed = control::authorize_auth_challenge(&runtime, &connection.connection_id).await?; + assert_eq!( + replayed.replayed_request_id.as_deref(), + Some("trusted-sign-1") + ); + + let response_events = relay + .wait_for_published_events_by_author(signer_public_key, 3) + .await?; + let replay_response = + decrypt_response(&client_identity, signer_public_key, &response_events[2]); + let replay_parsed = + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::SignEvent( + serde_json::from_value(serde_json::json!({ + "pubkey": runtime.user_identity().public_key().to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": 1, + "tags": [], + "content": "trusted-sign-1" + })) + .expect("unsigned event"), + ) + .method(), + replay_response, + )?; + assert!(matches!( + replay_parsed, + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::SignedEvent(_) + )); + + sleep(Duration::from_secs(2)).await; + + publish_event( + relay.url(), + &sign_request("trusted-sign-2", Timestamp::now().as_secs()), + ) + .await?; + let response_events = relay + .wait_for_published_events_by_author(signer_public_key, 4) + .await?; + let second_auth = decrypt_response(&client_identity, signer_public_key, &response_events[3]); + let second_auth = radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::SignEvent( + serde_json::from_value(serde_json::json!({ + "pubkey": runtime.user_identity().public_key().to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": 1, + "tags": [], + "content": "trusted-sign-2" + })) + .expect("unsigned event"), + ) + .method(), + second_auth, + )?; + assert_eq!( + second_auth, + radroots_nostr_connect::prelude::RadrootsNostrConnectResponse::AuthUrl( + "https://auth.example/challenge".to_owned() + ) + ); + + let _ = shutdown_tx.send(()); + listener_task.await??; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn connect_accept_retries_without_consuming_secret_until_publish_succeeds() -> TestResult<()> { let relay = TestRelay::spawn().await?;