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:
Awiteb
2025-07-18 21:18:26 +00:00
parent f0c20c3677
commit 4e0ecdceaf
11 changed files with 211 additions and 23 deletions

View File

@@ -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

View File

@@ -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

View 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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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);
}