myc

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

commit 7468327d2fb286871a1f3c4fe3bf84b42aa4f2de
parent d4b19949492e07ec5ec4c3767961b3052eef37d7
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 14:56:49 +0000

custody: add identity operation contract

Diffstat:
Msrc/custody.rs | 294++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtests/nip46_e2e.rs | 3++-
2 files changed, 203 insertions(+), 94 deletions(-)

diff --git a/src/custody.rs b/src/custody.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use nostr::nips::nip44::Version; use nostr::nips::{nip04, nip44}; -use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; +use radroots_identity::{RadrootsIdentity, RadrootsIdentityId, RadrootsIdentityPublic}; use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrPublicKey, }; @@ -20,7 +20,9 @@ use crate::error::MycError; #[derive(Clone)] pub struct MycActiveIdentity { - identity: Arc<RadrootsIdentity>, + public_identity: RadrootsIdentityPublic, + public_key: RadrootsNostrPublicKey, + operations: Arc<dyn MycIdentityOperations>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] @@ -105,6 +107,128 @@ enum MycIdentityProviderBackend { }, } +trait MycIdentityOperations: Send + Sync { + fn nostr_client(&self) -> RadrootsNostrClient; + fn nostr_client_owned(&self) -> RadrootsNostrClient; + fn sign_event_builder( + &self, + builder: RadrootsNostrEventBuilder, + operation: &str, + ) -> Result<RadrootsNostrEvent, MycError>; + fn sign_unsigned_event( + &self, + unsigned_event: nostr::UnsignedEvent, + operation: &str, + ) -> Result<nostr::Event, MycError>; + fn nip04_encrypt( + &self, + public_key: &RadrootsNostrPublicKey, + plaintext: String, + ) -> Result<String, MycError>; + fn nip04_decrypt( + &self, + public_key: &RadrootsNostrPublicKey, + ciphertext: &str, + ) -> Result<String, MycError>; + fn nip44_encrypt( + &self, + public_key: &RadrootsNostrPublicKey, + plaintext: String, + ) -> Result<String, MycError>; + fn nip44_decrypt( + &self, + public_key: &RadrootsNostrPublicKey, + ciphertext: &str, + ) -> Result<String, MycError>; +} + +struct MycLoadedIdentityOperations { + identity: Arc<RadrootsIdentity>, +} + +impl MycLoadedIdentityOperations { + fn new(identity: RadrootsIdentity) -> Self { + Self { + identity: Arc::new(identity), + } + } +} + +impl MycIdentityOperations for MycLoadedIdentityOperations { + fn nostr_client(&self) -> RadrootsNostrClient { + RadrootsNostrClient::from_identity(self.identity.as_ref()) + } + + fn nostr_client_owned(&self) -> RadrootsNostrClient { + RadrootsNostrClient::from_identity_owned((*self.identity).clone()) + } + + fn sign_event_builder( + &self, + builder: RadrootsNostrEventBuilder, + operation: &str, + ) -> Result<RadrootsNostrEvent, MycError> { + builder + .sign_with_keys(self.identity.keys()) + .map_err(|error| { + MycError::InvalidOperation(format!("failed to sign {operation} event: {error}")) + }) + } + + fn sign_unsigned_event( + &self, + unsigned_event: nostr::UnsignedEvent, + operation: &str, + ) -> Result<nostr::Event, MycError> { + unsigned_event + .sign_with_keys(self.identity.keys()) + .map_err(|error| { + MycError::InvalidOperation(format!("failed to sign {operation}: {error}")) + }) + } + + fn nip04_encrypt( + &self, + public_key: &RadrootsNostrPublicKey, + plaintext: String, + ) -> Result<String, MycError> { + nip04::encrypt(self.identity.keys().secret_key(), public_key, plaintext) + .map_err(|error| MycError::Nip46Encrypt(error.to_string())) + } + + fn nip04_decrypt( + &self, + public_key: &RadrootsNostrPublicKey, + ciphertext: &str, + ) -> Result<String, MycError> { + nip04::decrypt(self.identity.keys().secret_key(), public_key, ciphertext) + .map_err(|error| MycError::Nip46Decrypt(error.to_string())) + } + + fn nip44_encrypt( + &self, + public_key: &RadrootsNostrPublicKey, + plaintext: String, + ) -> Result<String, MycError> { + nip44::encrypt( + self.identity.keys().secret_key(), + public_key, + plaintext, + Version::V2, + ) + .map_err(|error| MycError::Nip46Encrypt(error.to_string())) + } + + fn nip44_decrypt( + &self, + public_key: &RadrootsNostrPublicKey, + ciphertext: &str, + ) -> Result<String, MycError> { + nip44::decrypt(self.identity.keys().secret_key(), public_key, ciphertext) + .map_err(|error| MycError::Nip46Decrypt(error.to_string())) + } +} + impl MycIdentityProvider { pub fn from_source( role: impl Into<String>, @@ -253,21 +377,23 @@ impl MycIdentityProvider { pub fn resolved_status(&self, identity: &MycActiveIdentity) -> MycIdentityStatusOutput { match &self.backend { - MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status( - Ok(identity.as_identity()), - self.selected_managed_account_record_result(), - ), - _ => self.status_with_result(Ok(identity.as_identity())), + MycIdentityProviderBackend::ManagedAccount { .. } => { + self.managed_account_status(Ok(()), self.selected_managed_account_record_result()) + } + _ => self.status_with_public_identity(identity.public_identity()), } } pub fn probe_status(&self) -> MycIdentityStatusOutput { match &self.backend { MycIdentityProviderBackend::ManagedAccount { .. } => self.managed_account_status( - self.load_identity().as_ref(), + self.load_identity_public().as_ref().map(|_| ()), self.selected_managed_account_record_result(), ), - _ => self.status_with_result(self.load_identity().as_ref()), + _ => match self.load_identity_public() { + Ok(identity) => self.status_with_public_identity(&identity), + Err(error) => self.status_with_error(&error), + }, } } @@ -372,41 +498,46 @@ impl MycIdentityProvider { }) } - fn status_with_result( + fn load_identity_public(&self) -> Result<RadrootsIdentityPublic, MycError> { + self.load_identity().map(|identity| identity.to_public()) + } + + fn status_with_public_identity( &self, - result: Result<&RadrootsIdentity, &MycError>, + identity: &RadrootsIdentityPublic, ) -> MycIdentityStatusOutput { - match result { - Ok(identity) => MycIdentityStatusOutput { - backend: self.source.backend, - path: self.source.path.clone(), - keyring_account_id: self.source.keyring_account_id.clone(), - keyring_service_name: self.source.keyring_service_name.clone(), - profile_path: self.source.profile_path.clone(), - inherited_from: None, - resolved: true, - selected_account_id: None, - selected_account_label: None, - selected_account_state: None, - identity_id: Some(identity.id().to_string()), - public_key_hex: Some(identity.public_key_hex()), - error: None, - }, - Err(error) => MycIdentityStatusOutput { - backend: self.source.backend, - path: self.source.path.clone(), - keyring_account_id: self.source.keyring_account_id.clone(), - keyring_service_name: self.source.keyring_service_name.clone(), - profile_path: self.source.profile_path.clone(), - inherited_from: None, - resolved: false, - selected_account_id: None, - selected_account_label: None, - selected_account_state: None, - identity_id: None, - public_key_hex: None, - error: Some(error.to_string()), - }, + MycIdentityStatusOutput { + backend: self.source.backend, + path: self.source.path.clone(), + keyring_account_id: self.source.keyring_account_id.clone(), + keyring_service_name: self.source.keyring_service_name.clone(), + profile_path: self.source.profile_path.clone(), + inherited_from: None, + resolved: true, + selected_account_id: None, + selected_account_label: None, + selected_account_state: None, + identity_id: Some(identity.id.to_string()), + public_key_hex: Some(identity.public_key_hex.clone()), + error: None, + } + } + + fn status_with_error(&self, error: &MycError) -> MycIdentityStatusOutput { + MycIdentityStatusOutput { + backend: self.source.backend, + path: self.source.path.clone(), + keyring_account_id: self.source.keyring_account_id.clone(), + keyring_service_name: self.source.keyring_service_name.clone(), + profile_path: self.source.profile_path.clone(), + inherited_from: None, + resolved: false, + selected_account_id: None, + selected_account_label: None, + selected_account_state: None, + identity_id: None, + public_key_hex: None, + error: Some(error.to_string()), } } @@ -424,7 +555,7 @@ impl MycIdentityProvider { fn managed_account_status( &self, - identity_result: Result<&RadrootsIdentity, &MycError>, + identity_result: Result<(), &MycError>, account_result: Result<Option<RadrootsNostrAccountRecord>, MycError>, ) -> MycIdentityStatusOutput { let MycIdentityProviderBackend::ManagedAccount { @@ -433,7 +564,10 @@ impl MycIdentityProvider { manager, } = &self.backend else { - return self.status_with_result(identity_result); + return match self.load_identity_public() { + Ok(identity) => self.status_with_public_identity(&identity), + Err(error) => self.status_with_error(&error), + }; }; let (selected_account_id, selected_account_label, identity_id, public_key_hex) = @@ -651,37 +785,41 @@ impl MycIdentityProvider { impl MycActiveIdentity { pub fn new(identity: RadrootsIdentity) -> Self { + let public_identity = identity.to_public(); + let public_key = identity.public_key(); Self { - identity: Arc::new(identity), + public_identity, + public_key, + operations: Arc::new(MycLoadedIdentityOperations::new(identity)), } } pub fn id(&self) -> RadrootsIdentityId { - self.identity.id() + self.public_identity.id.clone() } pub fn public_key(&self) -> RadrootsNostrPublicKey { - self.identity.public_key() + self.public_key } pub fn public_key_hex(&self) -> String { - self.identity.public_key_hex() + self.public_identity.public_key_hex.clone() } - pub fn secret_key_hex(&self) -> String { - self.identity.secret_key_hex() + pub fn to_public(&self) -> RadrootsIdentityPublic { + self.public_identity.clone() } - pub fn to_public(&self) -> radroots_identity::RadrootsIdentityPublic { - self.identity.to_public() + pub fn public_identity(&self) -> &RadrootsIdentityPublic { + &self.public_identity } pub fn nostr_client(&self) -> RadrootsNostrClient { - RadrootsNostrClient::from_identity(self.as_identity()) + self.operations.nostr_client() } pub fn nostr_client_owned(&self) -> RadrootsNostrClient { - RadrootsNostrClient::from_identity_owned((*self.identity).clone()) + self.operations.nostr_client_owned() } pub fn sign_event_builder( @@ -689,11 +827,7 @@ impl MycActiveIdentity { builder: RadrootsNostrEventBuilder, operation: &str, ) -> Result<RadrootsNostrEvent, MycError> { - builder - .sign_with_keys(self.identity.keys()) - .map_err(|error| { - MycError::InvalidOperation(format!("failed to sign {operation} event: {error}")) - }) + self.operations.sign_event_builder(builder, operation) } pub fn sign_unsigned_event( @@ -701,11 +835,8 @@ impl MycActiveIdentity { unsigned_event: nostr::UnsignedEvent, operation: &str, ) -> Result<nostr::Event, MycError> { - unsigned_event - .sign_with_keys(self.identity.keys()) - .map_err(|error| { - MycError::InvalidOperation(format!("failed to sign {operation}: {error}")) - }) + self.operations + .sign_unsigned_event(unsigned_event, operation) } pub fn nip04_encrypt( @@ -713,12 +844,7 @@ impl MycActiveIdentity { public_key: &RadrootsNostrPublicKey, plaintext: impl Into<String>, ) -> Result<String, MycError> { - nip04::encrypt( - self.identity.keys().secret_key(), - public_key, - plaintext.into(), - ) - .map_err(|error| MycError::Nip46Encrypt(error.to_string())) + self.operations.nip04_encrypt(public_key, plaintext.into()) } pub fn nip04_decrypt( @@ -726,12 +852,8 @@ impl MycActiveIdentity { public_key: &RadrootsNostrPublicKey, ciphertext: impl AsRef<str>, ) -> Result<String, MycError> { - nip04::decrypt( - self.identity.keys().secret_key(), - public_key, - ciphertext.as_ref(), - ) - .map_err(|error| MycError::Nip46Decrypt(error.to_string())) + self.operations + .nip04_decrypt(public_key, ciphertext.as_ref()) } pub fn nip44_encrypt( @@ -739,13 +861,7 @@ impl MycActiveIdentity { public_key: &RadrootsNostrPublicKey, plaintext: impl Into<String>, ) -> Result<String, MycError> { - nip44::encrypt( - self.identity.keys().secret_key(), - public_key, - plaintext.into(), - Version::V2, - ) - .map_err(|error| MycError::Nip46Encrypt(error.to_string())) + self.operations.nip44_encrypt(public_key, plaintext.into()) } pub fn nip44_decrypt( @@ -753,16 +869,8 @@ impl MycActiveIdentity { public_key: &RadrootsNostrPublicKey, ciphertext: impl AsRef<str>, ) -> Result<String, MycError> { - nip44::decrypt( - self.identity.keys().secret_key(), - public_key, - ciphertext.as_ref(), - ) - .map_err(|error| MycError::Nip46Decrypt(error.to_string())) - } - - pub(crate) fn as_identity(&self) -> &RadrootsIdentity { - self.identity.as_ref() + self.operations + .nip44_decrypt(public_key, ciphertext.as_ref()) } } diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -1676,7 +1676,8 @@ async fn external_nostr_client_recovers_connect_response_after_restart() -> Test let response_envelope = RadrootsNostrConnectResponse::ConnectAcknowledged.into_envelope(connect_request_id)?; let response_payload = serde_json::to_string(&response_envelope)?; - let signer_identity = identity(runtime.signer_identity().secret_key_hex().as_str()); + let signer_identity = + identity("1111111111111111111111111111111111111111111111111111111111111111"); let response_ciphertext = nip44::encrypt( signer_identity.keys().secret_key(), &client_identity.public_key(),