feat: Support signing using NIP-46 bunker
Suggested-by: DanConwayDev <DanConwayDev@protonmail.com> Signed-off-by: Awiteb <a@4rs.nl>
This commit is contained in:
46
src/cli/commands/config/bunker.rs
Normal file
46
src/cli/commands/config/bunker.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
// n34 - A CLI to interact with NIP-34 and other stuff related to codes in nostr
|
||||
// Copyright (C) 2025 Awiteb <a@4rs.nl>
|
||||
//
|
||||
// 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 <https://gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
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<NostrConnectURI>,
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://gnu.org/licenses/gpl-3.0.html>.
|
||||
|
||||
/// `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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SecretKey>,
|
||||
/// NIP-46 bunker url used for signing events
|
||||
#[arg(short, long, value_parser=parsers::parse_bunker_url)]
|
||||
pub bunker_url: Option<NostrConnectURI>,
|
||||
/// 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<PublicKey> {
|
||||
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<impl nostr::signer::IntoNostrSigner> {
|
||||
pub fn signer(&self) -> N34Result<Option<Arc<dyn NostrSigner + 'static>>> {
|
||||
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)
|
||||
|
||||
@@ -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<Vec<RelayUrl>>,
|
||||
/// 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<NostrConnectURI>,
|
||||
}
|
||||
|
||||
/// A named group of repositories and relays.
|
||||
|
||||
@@ -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 <naddr1qqpkuve5qgsqqqqqq9g9uljgjfcyd6dm4fegk8em2yfz0c3qp3tc6mntkrrhawgrqsqqqauesksc39>."#;
|
||||
|
||||
/// 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<Keys> {
|
||||
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> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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<NostrConnectURI> {
|
||||
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<S>(naddr: &HashSet<Nip19Coordinate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
@@ -97,3 +109,26 @@ where
|
||||
.collect::<Result<HashSet<_>, _>>()
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
pub fn ser_bunker_url<S>(
|
||||
bunker_url: &Option<NostrConnectURI>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
bunker_url
|
||||
.as_ref()
|
||||
.map(|u| u.to_string())
|
||||
.serialize(serializer)
|
||||
}
|
||||
|
||||
pub fn de_bunker_url<'de, D>(deserializer: D) -> Result<Option<NostrConnectURI>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer)?
|
||||
.map(|u| parse_bunker_url(&u))
|
||||
.transpose()
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
@@ -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<RelayUrl>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EchoAuthUrl;
|
||||
|
||||
impl AuthUrlHandler for EchoAuthUrl {
|
||||
fn on_auth_url(&self, auth_url: Url) -> BoxedFuture<Result<(), Box<dyn std::error::Error>>> {
|
||||
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.
|
||||
|
||||
14
src/error.rs
14
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<T> = Result<T, N34Error>;
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user