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 | ++++++++++++++ |
| M | src/app/runtime.rs | | | 16 | ++++++++++------ |
| M | src/cli.rs | | | 51 | ++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | src/config.rs | | | 213 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/control.rs | | | 144 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | src/lib.rs | | | 2 | ++ |
| A | src/policy.rs | | | 741 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/transport/nip46.rs | | | 535 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
| M | tests/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?;