From 487e6d634a738dc0dc003af93f6cd609e9c3861c Mon Sep 17 00:00:00 2001 From: spacesops Date: Wed, 1 Oct 2025 15:59:40 -0400 Subject: [PATCH 1/3] ignore NOTES --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 512bd55..cbc7b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ target .idea .DS_store .zone + +NOTES.md From d87788af659117d692a84f5fb67dd72a3b883fa2 Mon Sep 17 00:00:00 2001 From: spacesops Date: Thu, 2 Oct 2025 02:03:55 -0400 Subject: [PATCH 2/3] adapt sign to export nsec --- .gitignore | 1 + client/src/bin/space-cli.rs | 42 ++++++++++++++++++++++++++++++++++ client/src/rpc.rs | 21 +++++++++++++++++ client/src/wallets.rs | 8 +++++++ wallet/src/lib.rs | 45 +++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+) diff --git a/.gitignore b/.gitignore index cbc7b0c..dda3700 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ target .zone NOTES.md +temp/ diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 3e89fe6..00e39c1 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -294,6 +294,17 @@ enum Commands { #[arg(long)] skip_anchor: bool, }, + /// Export a space's Nostr nsec key + #[command(name = "exportspacensec")] + ExportSpaceNsec { + /// The space to use for exporting the Nostr nsec key + space: String, + /// The DNS zone file path (omit for stdin) + input: Option, + /// Skip including bundled Merkle proof in the event. + #[arg(long)] + skip_anchor: bool, + }, /// Updates the Merkle trust path for space-anchored Nostr events #[command(name = "refreshanchor")] RefreshAnchor { @@ -453,6 +464,26 @@ impl SpaceCli { Ok(result) } + + async fn export_space_nsec( + &self, + space: String, + event: NostrEvent, + anchor: bool, + most_recent: bool, + ) -> Result { + let mut result = self + .client + .wallet_sign_event(&self.wallet, &space, event) + .await?; + + if anchor { + result = self.add_anchor(result, most_recent).await? + } + + Ok(result) + } + async fn add_anchor( &self, mut event: NostrEvent, @@ -924,6 +955,17 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client space, input, skip_anchor, + } => { + let update = encode_dns_update(&space, input) + .map_err(|e| ClientError::Custom(format!("Parse error: {}", e)))?; + let result = cli.export_space_nsec(space, update, !skip_anchor, false).await?; + + println!("{}", serde_json::to_string(&result).expect("result")); + } + Commands::ExportSpaceNsec { + space, + input, + skip_anchor, } => { let update = encode_dns_update(&space, input) .map_err(|e| ClientError::Custom(format!("Parse error: {}", e)))?; diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 447a583..2a59799 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -235,6 +235,14 @@ pub trait Rpc { event: NostrEvent, ) -> Result; + #[method(name = "walletexportnsec")] + async fn wallet_export_nsec( + &self, + wallet: &str, + space: &str, + event: NostrEvent, + ) -> Result; + #[method(name = "walletgetinfo")] async fn wallet_get_info(&self, name: &str) -> Result; @@ -916,6 +924,19 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_export_nsec( + &self, + wallet: &str, + space: &str, + event: NostrEvent, + ) -> Result { + self.wallet(&wallet) + .await? + .send_sign_event(space, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn wallet_get_info( &self, wallet: &str, diff --git a/client/src/wallets.rs b/client/src/wallets.rs index fda04a4..7cefa4e 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -214,6 +214,11 @@ pub enum WalletCommand { event: NostrEvent, resp: crate::rpc::Responder>, }, + ExportSpaceNsec { + space: String, + event: NostrEvent, + resp: crate::rpc::Responder>, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -517,6 +522,9 @@ impl RpcWallet { WalletCommand::SignEvent { space, event, resp } => { _ = resp.send(wallet.sign_event::(state, &space, event)); } + WalletCommand::ExportSpaceNsec { space, event, resp } => { + _ = resp.send(wallet.export_space_nsec::(state, &space, event)); + } } Ok(()) } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 82ea6b2..d65f13b 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -1,5 +1,6 @@ use std::{collections::BTreeMap, fmt::Debug, fs, ops::Mul, path::PathBuf, str::FromStr}; use anyhow::{anyhow, Context}; +use bech32::{Bech32, Hrp, encode}; use bdk_wallet::{ chain, chain::{ @@ -421,6 +422,50 @@ impl SpacesWallet { Ok(event) } + pub fn export_space_nsec( + &mut self, + src: &mut impl DataSource, + space: &str, + mut event: NostrEvent, + ) -> anyhow::Result { + if event.space().is_some_and(|s| s != space) { + return Err(anyhow::anyhow!("Space tag does not match specified space")); + } + + let label = SLabel::from_str(space)?; + let space_key = SpaceKey::from(H::hash(label.as_ref())); + let outpoint = match src.get_space_outpoint(&space_key)? { + None => return Err(anyhow::anyhow!("Space not found")), + Some(outpoint) => outpoint, + }; + let utxo = match self.get_utxo(outpoint) { + None => return Err(anyhow::anyhow!("Space not owned by wallet")), + Some(utxo) => utxo, + }; + + // derive taproot keypair for space XXX + let keypair = self + .get_taproot_keypair(utxo.keychain, utxo.derivation_index) // derive taproot keypair for space + .context("Could not derive taproot keypair to sign message")?; // propagate derivation errors + + // WARNING: printing private keys is insecure; do this only for debugging + let inner = keypair.to_inner(); + let secret = inner.secret_key(); + let secret_bytes = secret.secret_bytes(); + let secret_hex = hex::encode(secret_bytes); + + // Convert to nsec format (nostr bech32 encoding) + let hrp = Hrp::parse("nsec").unwrap_or_else(|_| Hrp::parse("nsec").unwrap()); + let nsec = encode::(hrp, &secret_bytes) + .unwrap_or_else(|_| "encoding_failed".to_string()); + + println!("Signing with private key (hex): {}", secret_hex); + println!("Signing with private key (nsec): {}", nsec); + + event.sign(secp256k1::Secp256k1::new(), &inner)?; // perform Schnorr signature with taproot key + Ok(event) // return the now-signed event + } + pub fn verify_event( src: &mut impl DataSource, space: &str, From c8931567fbe2f52e4cc35096e11f2f54d86376aa Mon Sep 17 00:00:00 2001 From: spacesops Date: Thu, 2 Oct 2025 14:07:16 -0400 Subject: [PATCH 3/3] exportspacensec tested --- client/src/bin/space-cli.rs | 30 ++++++++++++++++++------------ client/src/rpc.rs | 19 +++++++++++++++++++ client/src/wallets.rs | 21 +++++++++++++++++++++ wallet/src/lib.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 00e39c1..17db91a 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -299,11 +299,8 @@ enum Commands { ExportSpaceNsec { /// The space to use for exporting the Nostr nsec key space: String, - /// The DNS zone file path (omit for stdin) - input: Option, - /// Skip including bundled Merkle proof in the event. - #[arg(long)] - skip_anchor: bool, + /// Destination path to export nsec key file + path: PathBuf, }, /// Updates the Merkle trust path for space-anchored Nostr events #[command(name = "refreshanchor")] @@ -484,6 +481,15 @@ impl SpaceCli { Ok(result) } + async fn get_space_nsec_keys(&self, space: String) -> Result<(String, String), ClientError> { + let result = self + .client + .wallet_get_space_nsec_keys(&self.wallet, &space) + .await?; + + Ok(result) + } + async fn add_anchor( &self, mut event: NostrEvent, @@ -964,14 +970,14 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client } Commands::ExportSpaceNsec { space, - input, - skip_anchor, + path, } => { - let update = encode_dns_update(&space, input) - .map_err(|e| ClientError::Custom(format!("Parse error: {}", e)))?; - let result = cli.sign_event(space, update, !skip_anchor, false).await?; - - println!("{}", serde_json::to_string(&result).expect("result")); + let (secret_hex, nsec) = cli.get_space_nsec_keys(space).await?; + + let content = format!("secret_hex: {}\nnsec: {}", secret_hex, nsec); + fs::write(path, content).map_err(|e| { + ClientError::Custom(format!("Could not save to path: {}", e.to_string())) + })?; } Commands::RefreshAnchor { input, diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 2a59799..d90b44e 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -243,6 +243,13 @@ pub trait Rpc { event: NostrEvent, ) -> Result; + #[method(name = "walletgetspacenseckeys")] + async fn wallet_get_space_nsec_keys( + &self, + wallet: &str, + space: &str, + ) -> Result<(String, String), ErrorObjectOwned>; + #[method(name = "walletgetinfo")] async fn wallet_get_info(&self, name: &str) -> Result; @@ -937,6 +944,18 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_get_space_nsec_keys( + &self, + wallet: &str, + space: &str, + ) -> Result<(String, String), ErrorObjectOwned> { + self.wallet(&wallet) + .await? + .send_get_space_nsec_keys(space) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn wallet_get_info( &self, wallet: &str, diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 7cefa4e..9138f6c 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -219,6 +219,10 @@ pub enum WalletCommand { event: NostrEvent, resp: crate::rpc::Responder>, }, + GetSpaceNsecKeys { + space: String, + resp: crate::rpc::Responder>, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -525,6 +529,9 @@ impl RpcWallet { WalletCommand::ExportSpaceNsec { space, event, resp } => { _ = resp.send(wallet.export_space_nsec::(state, &space, event)); } + WalletCommand::GetSpaceNsecKeys { space, resp } => { + _ = resp.send(wallet.get_space_nsec_keys::(state, &space)); + } } Ok(()) } @@ -1482,6 +1489,20 @@ impl RpcWallet { resp_rx.await? } + pub async fn send_get_space_nsec_keys( + &self, + space: &str, + ) -> anyhow::Result<(String, String)> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::GetSpaceNsecKeys { + space: space.to_string(), + resp, + }) + .await?; + resp_rx.await? + } + pub async fn send_list_transactions( &self, count: usize, diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index d65f13b..2fa49e6 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -466,6 +466,40 @@ impl SpacesWallet { Ok(event) // return the now-signed event } + pub fn get_space_nsec_keys( + &mut self, + src: &mut impl DataSource, + space: &str, + ) -> anyhow::Result<(String, String)> { + let label = SLabel::from_str(space)?; + let space_key = SpaceKey::from(H::hash(label.as_ref())); + let outpoint = match src.get_space_outpoint(&space_key)? { + None => return Err(anyhow::anyhow!("Space not found")), + Some(outpoint) => outpoint, + }; + let utxo = match self.get_utxo(outpoint) { + None => return Err(anyhow::anyhow!("Space not owned by wallet")), + Some(utxo) => utxo, + }; + + // derive taproot keypair for space + let keypair = self + .get_taproot_keypair(utxo.keychain, utxo.derivation_index) + .context("Could not derive taproot keypair")?; + + let inner = keypair.to_inner(); + let secret = inner.secret_key(); + let secret_bytes = secret.secret_bytes(); + let secret_hex = hex::encode(secret_bytes); + + // Convert to nsec format (nostr bech32 encoding) + let hrp = Hrp::parse("nsec").unwrap_or_else(|_| Hrp::parse("nsec").unwrap()); + let nsec = encode::(hrp, &secret_bytes) + .unwrap_or_else(|_| "encoding_failed".to_string()); + + Ok((secret_hex, nsec)) + } + pub fn verify_event( src: &mut impl DataSource, space: &str,