diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b79c29..89ce01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Support signing using NIP-46 bunker - by Awiteb + +### Dependencies + +- Add `keyring`, `nostr-connect`, `nostr-keyring` and `url` to the dependencies - by Awiteb + ## [0.3.0] - 2025-07-05 ### Added diff --git a/README.md b/README.md index 34daa67..ae640bc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ details, see the following section. - [X] Gossip Model ([NIP-65]) - [X] Proof of Work ([NIP-13]) - [X] `nostr:` URI scheme, in the issue/reply content ([NIP-21]) +- [X] Signing using bunker ([NIP-46]) - [ ] Code Snippets ([NIP-C0]) - [X] In device relays and repos bookmark (`sets` command) @@ -77,3 +78,4 @@ refer to the [LICENSE](LICENSE) file for more details. [NIP-13]: https://github.com/nostr-protocol/nips/blob/master/13.md [NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md [NIP-C0]: https://github.com/nostr-protocol/nips/blob/master/C0.md +[NIP-46]: https://github.com/nostr-protocol/nips/blob/master/46.md diff --git a/src/cli/commands/config/bunker.rs b/src/cli/commands/config/bunker.rs new file mode 100644 index 0000000..0f02a92 --- /dev/null +++ b/src/cli/commands/config/bunker.rs @@ -0,0 +1,46 @@ +// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr +// Copyright (C) 2025 Awiteb +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use clap::Args; +use nostr::nips::nip46::NostrConnectURI; + +use crate::{ + cli::{CliOptions, traits::CommandRunner}, + error::{N34Error, N34Result}, +}; + + +#[derive(Args, Debug)] +pub struct BunkerArgs { + /// Nostr Connect URL for the bunker. Omit this to remove the current bunker + /// URL. + bunker_url: Option, +} + + +impl CommandRunner for BunkerArgs { + const NEED_SIGNER: bool = false; + + async fn run(self, mut options: CliOptions) -> N34Result<()> { + if let Some(ref bunker_url) = self.bunker_url + && !bunker_url.is_bunker() + { + return Err(N34Error::NotBunkerUrl); + } + options.config.bunker_url = self.bunker_url; + options.config.dump() + } +} diff --git a/src/cli/commands/config/mod.rs b/src/cli/commands/config/mod.rs index 10864e6..2cb2c45 100644 --- a/src/cli/commands/config/mod.rs +++ b/src/cli/commands/config/mod.rs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +/// `config bunker` subcommand +mod bunker; /// `config pow` subcommand mod pow; /// `config relays` subcommand @@ -21,6 +23,7 @@ mod relays; use clap::Subcommand; +use self::bunker::BunkerArgs; use self::pow::PowArgs; use self::relays::RelaysArgs; use super::CliOptions; @@ -34,10 +37,12 @@ pub enum ConfigSubcommands { /// Sets the default fallback relays if none provided. Use this relays for /// read and write. Relays(RelaysArgs), + /// Sets a URL of NIP-46 bunker server used for signing events. + Bunker(BunkerArgs), } impl CommandRunner for ConfigSubcommands { async fn run(self, options: CliOptions) -> N34Result<()> { - crate::run_command!(self, options, & Pow Relays) + crate::run_command!(self, options, & Pow Relays Bunker) } } diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index d443121..b27692c 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -28,9 +28,14 @@ pub mod repo; pub mod sets; use std::fmt; +use std::sync::Arc; +use std::time::Duration; use clap::{ArgGroup, Args, Parser}; use nostr::key::{Keys, PublicKey, SecretKey}; +use nostr::nips::nip46::NostrConnectURI; +use nostr::signer::{IntoNostrSigner, NostrSigner}; +use nostr_connect::client::NostrConnect; use self::config::ConfigSubcommands; use self::issue::IssueSubcommands; @@ -41,6 +46,8 @@ use self::sets::SetsSubcommands; use super::CliConfig; use super::types::RelayOrSet; use super::{parsers, traits::CommandRunner}; +use crate::cli::Cli; +use crate::cli::types::EchoAuthUrl; use crate::error::{N34Error, N34Result}; /// Default path used when no path is provided via command line arguments. @@ -48,12 +55,15 @@ use crate::error::{N34Error, N34Result}; /// This is a workaround since Clap doesn't support lazy evaluation of defaults. pub const DEFAULT_FALLBACK_PATH: &str = "I_DO_NOT_KNOW_WHY_CLAP_DO_NOT_SUPPORT_LAZY_DEFAULT"; +/// How long to wait for bunker response (3 minutes). +const BUNKER_TIMEOUT: Duration = Duration::from_secs(60 * 3); + /// The command-line interface options #[derive(Args, Clone)] #[clap( group( ArgGroup::new("signer") - .args(&["secret_key"]) + .args(&["secret_key", "bunker_url"]) .required(false) ) )] @@ -61,6 +71,9 @@ pub struct CliOptions { /// Your Nostr secret key #[arg(short, long)] pub secret_key: Option, + /// NIP-46 bunker url used for signing events + #[arg(short, long, value_parser=parsers::parse_bunker_url)] + pub bunker_url: Option, /// Fallbacks relay to write and read from it. Multiple relays can be /// passed. #[arg(short, long)] @@ -111,22 +124,35 @@ pub enum Commands { impl CliOptions { /// Gets the public key of the user. pub async fn pubkey(&self) -> N34Result { - if let Some(sk) = &self.secret_key { - return Ok(Keys::new(sk.clone()).public_key()); - } - unreachable!( - "This method should only be called when a signer is required. If this panic occurs, \ - it indicates a bug where the command failed to properly require a signer (which is \ - the default behavior)" - ) + let Some(signer) = self.signer()? else { + unreachable!( + "This method should only be called when a signer is required. If this panic \ + occurs, it indicates a bug where the command failed to properly require a signer \ + (which is the default behavior)" + ) + }; + + signer.get_public_key().await.map_err(N34Error::SignerError) } /// Returns the signer - pub fn signer(&self) -> Option { + pub fn signer(&self) -> N34Result>> { if let Some(sk) = &self.secret_key { - return Some(Keys::new(sk.clone())); + return Ok(Some(Keys::new(sk.clone()).into_nostr_signer())); } - None + if let Some(ref bunker_url) = self.bunker_url { + let mut nostrconnect = NostrConnect::new( + bunker_url.clone(), + Cli::n34_keypair()?, + BUNKER_TIMEOUT, + None, + ) + .expect("It's a bunker url and not a client"); + + nostrconnect.auth_url_handler(EchoAuthUrl); + return Ok(Some(nostrconnect.into_nostr_signer())); + } + Ok(None) } /// Returns an error if there are no relays. @@ -139,11 +165,10 @@ impl CliOptions { /// Returns an error if there are no signers pub fn ensure_signer(&self) -> N34Result<()> { - if self.secret_key.is_none() { - Err(N34Error::SignerRequired) - } else { - Ok(()) + if self.secret_key.is_none() && self.bunker_url.is_none() { + return Err(N34Error::SignerRequired); } + Ok(()) } } @@ -151,6 +176,7 @@ impl fmt::Debug for CliOptions { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("CliOptions") .field("secret_key", &self.secret_key.as_ref().map(|_| "*******")) + .field("bunker_url", &self.bunker_url.as_ref().map(|_| "*******")) .field("relays", &self.relays) .field("pow", &self.pow) .field("config", &self.config) diff --git a/src/cli/config.rs b/src/cli/config.rs index a053936..4e4b882 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -17,7 +17,7 @@ use std::{collections::HashSet, fs, path::PathBuf}; use nostr::{ - nips::nip19::Nip19Coordinate, + nips::{nip19::Nip19Coordinate, nip46::NostrConnectURI}, types::RelayUrl, }; @@ -62,6 +62,14 @@ pub struct CliConfig { /// List of fallback relays used if no fallback relays was provided. #[serde(skip_serializing_if = "Option::is_none")] pub fallback_relays: Option>, + /// Default Nostr bunker URL used for signing events. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "super::parsers::de_bunker_url", + serialize_with = "super::parsers::ser_bunker_url" + )] + pub bunker_url: Option, } /// A named group of repositories and relays. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c854282..f9215f1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -33,11 +33,14 @@ pub mod types; use clap::Parser; use clap_verbosity_flag::Verbosity; +use nostr::key::Keys; +use nostr_keyring::NostrKeyring; use types::RelayOrSet; pub use self::commands::*; pub use self::config::*; use self::traits::CommandRunner; +use crate::error::N34Error; use crate::error::N34Result; /// Header message, used in the help message @@ -51,6 +54,12 @@ Git repository: https://git.4rs.nl/awiteb/n34.git"#; /// Footer message, used in the help message const FOOTER: &str = r#"Please report bugs to ."#; +/// Keyring service name of n34 +const N34_KEYRING_SERVICE_NAME: &str = "n34"; + +/// Keyring entry name for the n34 keypair +const N34_KEY_PAIR_ENTRY: &str = "n34_keypair"; + /// Name of the file storing the repository address pub const NOSTR_ADDRESS_FILE: &str = "nostr-address"; @@ -75,6 +84,22 @@ impl Cli { pub async fn run(self) -> N34Result<()> { self.command.run(self.options).await } + + /// Gets the n34 keypair from the keyring or generates and stores a new one + /// if none exists. + pub fn n34_keypair() -> N34Result { + let keyring = NostrKeyring::new(N34_KEYRING_SERVICE_NAME); + + match keyring.get(N34_KEY_PAIR_ENTRY) { + Ok(keys) => Ok(keys), + Err(nostr_keyring::Error::Keyring(keyring::Error::NoEntry)) => { + let new_keys = Keys::generate(); + keyring.set(N34_KEY_PAIR_ENTRY, &new_keys)?; + Ok(new_keys) + } + Err(err) => Err(N34Error::Keyring(err)), + } + } } /// Processes the CLI configuration by applying fallback values from config if @@ -88,5 +113,11 @@ pub fn post_cli(mut cli: Cli) -> N34Result { cli.options.relays = relays.iter().cloned().map(RelayOrSet::Relay).collect(); } + if cli.options.bunker_url.is_none() + && let Some(bunker_url) = &cli.options.config.bunker_url + { + cli.options.bunker_url = Some(bunker_url.clone()); + } + Ok(cli) } diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs index e392299..dbe27c3 100644 --- a/src/cli/parsers.rs +++ b/src/cli/parsers.rs @@ -22,7 +22,10 @@ use std::{ use nostr::{ Kind, - nips::nip19::{FromBech32, Nip19Coordinate, ToBech32}, + nips::{ + nip19::{FromBech32, Nip19Coordinate, ToBech32}, + nip46::NostrConnectURI, + }, }; use serde::{Deserialize, Serialize, Serializer}; @@ -72,6 +75,15 @@ pub fn parse_config_path(config_path: &str) -> N34Result { CliConfig::load(path) } +// Parses a bunker URL and checks if it's a valid Nostr Connect URI. +// Returns an error if the URL is not a valid bunker URL. +pub fn parse_bunker_url(bunker_url: &str) -> N34Result { + match NostrConnectURI::parse(bunker_url) { + Ok(url) if url.is_bunker() => Ok(url), + _ => Err(N34Error::NotBunkerUrl), + } +} + /// Serializes a set of NIP-19 coordinates as a list of bech32 strings. pub fn ser_naddrs(naddr: &HashSet, serializer: S) -> Result where @@ -97,3 +109,26 @@ where .collect::, _>>() .map_err(serde::de::Error::custom) } + +pub fn ser_bunker_url( + bunker_url: &Option, + serializer: S, +) -> Result +where + S: Serializer, +{ + bunker_url + .as_ref() + .map(|u| u.to_string()) + .serialize(serializer) +} + +pub fn de_bunker_url<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer)? + .map(|u| parse_bunker_url(&u)) + .transpose() + .map_err(serde::de::Error::custom) +} diff --git a/src/cli/types.rs b/src/cli/types.rs index 7cecd7b..fc11e0b 100644 --- a/src/cli/types.rs +++ b/src/cli/types.rs @@ -24,8 +24,11 @@ use nostr::{ nip19::{self, FromBech32, Nip19Coordinate}, }, types::RelayUrl, + util::BoxedFuture, }; +use nostr_connect::client::AuthUrlHandler; use tokio::runtime::Handle; +use url::Url; use super::{RepoRelaySetsExt, parsers}; use crate::{ @@ -61,6 +64,18 @@ pub struct NostrEvent { pub relays: Vec, } +#[derive(Debug)] +pub struct EchoAuthUrl; + +impl AuthUrlHandler for EchoAuthUrl { + fn on_auth_url(&self, auth_url: Url) -> BoxedFuture>> { + Box::pin(async move { + println!("The bunker requires authentication. Please open this URL: {auth_url}"); + Ok(()) + }) + } +} + impl NaddrOrSet { /// Returns the naddr if `Naddr` or try to get the relays from the set. /// Returns error if the set naddrs are empty or the set not found. diff --git a/src/error.rs b/src/error.rs index 297faf3..fbaedd5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,7 +16,10 @@ use std::process::ExitCode; -use nostr::event::{Kind, builder::Error as EventBuilderError}; +use nostr::{ + event::{Kind, builder::Error as EventBuilderError}, + signer::SignerError, +}; use nostr_sdk::client::Error as ClientError; use crate::cli::ConfigError; @@ -42,6 +45,10 @@ pub type N34Result = Result; pub enum N34Error { #[error("IO: {0}")] Io(#[from] std::io::Error), + #[error("Signer Error: {0}")] + SignerError(#[from] SignerError), + #[error("Keyring error: {0}")] + Keyring(#[from] nostr_keyring::Error), #[error("{0}")] Config(#[from] ConfigError), #[error("No editor specified in the `EDITOR` environment variable")] @@ -85,7 +92,8 @@ pub enum N34Error { #[error("One naddr is required for this command")] EmptyNaddrs, #[error( - "This command requires a signer to sign events. Use `--secret-key` to provide a signer" + "This command requires a signer to sign events. Use `--secret-key` or `--bunker-url` to \ + provide a signer" )] SignerRequired, #[error( @@ -124,6 +132,8 @@ pub enum N34Error { RevisionRootNotFound, #[error("Invalid status for the issue/patch: {0}")] InvalidStatus(String), + #[error("Not valid bunker URL")] + NotBunkerUrl, } impl N34Error { diff --git a/src/nostr_utils/mod.rs b/src/nostr_utils/mod.rs index 8bf8e04..74724e3 100644 --- a/src/nostr_utils/mod.rs +++ b/src/nostr_utils/mod.rs @@ -64,7 +64,7 @@ pub struct ContentDetails { /// A client for interacting with the Nostr relays pub struct NostrClient { /// The underlying Nostr client implementation - client: Client, + pub client: Client, } impl ContentDetails { @@ -112,7 +112,7 @@ impl NostrClient { pub async fn init(options: &CliOptions, relays: &[RelayUrl]) -> Self { let mut client_builder = Client::builder(); - if let Some(signer) = options.signer() { + if let Ok(Some(signer)) = options.signer() { client_builder = client_builder.signer(signer); }