diff --git a/.gitignore b/.gitignore index 512bd55..dda3700 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ target .idea .DS_store .zone + +NOTES.md +temp/ diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 3e89fe6..17db91a 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -294,6 +294,14 @@ 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, + /// Destination path to export nsec key file + path: PathBuf, + }, /// Updates the Merkle trust path for space-anchored Nostr events #[command(name = "refreshanchor")] RefreshAnchor { @@ -453,6 +461,35 @@ 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 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, @@ -927,10 +964,21 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client } => { 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?; + let result = cli.export_space_nsec(space, update, !skip_anchor, false).await?; println!("{}", serde_json::to_string(&result).expect("result")); } + Commands::ExportSpaceNsec { + space, + path, + } => { + 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, prefer_recent, diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 447a583..d90b44e 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -235,6 +235,21 @@ pub trait Rpc { event: NostrEvent, ) -> Result; + #[method(name = "walletexportnsec")] + async fn wallet_export_nsec( + &self, + wallet: &str, + space: &str, + 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; @@ -916,6 +931,31 @@ 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_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 fda04a4..9138f6c 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -214,6 +214,15 @@ pub enum WalletCommand { event: NostrEvent, resp: crate::rpc::Responder>, }, + ExportSpaceNsec { + space: String, + event: NostrEvent, + resp: crate::rpc::Responder>, + }, + GetSpaceNsecKeys { + space: String, + resp: crate::rpc::Responder>, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -517,6 +526,12 @@ 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)); + } + WalletCommand::GetSpaceNsecKeys { space, resp } => { + _ = resp.send(wallet.get_space_nsec_keys::(state, &space)); + } } Ok(()) } @@ -1474,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 82ea6b2..2fa49e6 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,84 @@ 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 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,