Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions crates/buzz-ws-client/src/connection.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
use std::collections::VecDeque;
use std::time::Duration;

use crate::event_signer::EventSigner;
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, Keys, Tag};
use nostr::{Event, EventBuilder, RelayUrl, Tag};
use serde_json::{json, Value};
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tracing::debug;

use crate::error::WsClientError;
use crate::message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage};
use crate::message::{parse_relay_message, OkResponse, RelayMessage};

type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;

Expand All @@ -36,7 +37,7 @@ impl NostrWsConnection {
/// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event.
pub async fn connect_authenticated(
url: &str,
keys: &Keys,
keys: &(impl EventSigner + ?Sized),
auth_tag: Option<&Tag>,
) -> Result<Self, WsClientError> {
let mut conn = Self::connect(url).await?;
Expand Down Expand Up @@ -69,14 +70,25 @@ impl NostrWsConnection {
/// Pass `auth_tag` to include a NIP-OA authorization tag in the AUTH event.
pub async fn authenticate(
&mut self,
keys: &Keys,
keys: &(impl EventSigner + ?Sized),
auth_tag: Option<&Tag>,
) -> Result<(), WsClientError> {
let challenge = self
.wait_for_auth_challenge(Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS))
.await?;

let auth_event = build_auth_event(&challenge, &self.relay_url, keys, auth_tag)?;
let url =
RelayUrl::parse(&self.relay_url).map_err(|e| WsClientError::Url(e.to_string()))?;
let builder = EventBuilder::auth(&challenge, url);
let builder = if let Some(tag) = auth_tag {
builder.tags([tag.clone()])
} else {
builder
};
let auth_event = keys
.sign(builder.build(keys.public_key()))
.await
.map_err(WsClientError::EventBuilder)?;
let event_id = auth_event.id.to_hex();

self.send_raw(&json!(["AUTH", auth_event])).await?;
Expand Down Expand Up @@ -277,7 +289,7 @@ impl NostrWsConnection {
pub async fn publish_event(
relay_url: &str,
event: Event,
keys: &Keys,
keys: &(impl EventSigner + ?Sized),
auth_tag: Option<&Tag>,
timeout_secs: u64,
) -> Result<OkResponse, WsClientError> {
Expand Down
181 changes: 181 additions & 0 deletions crates/buzz-ws-client/src/event_signer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//! Event signing only: construction, credentials, lifecycle and publication belong to callers.

use std::{future::Future, pin::Pin};

use nostr::{Event, Keys, PublicKey, UnsignedEvent};

/// An identity capable of signing an exact, already-constructed Nostr event.
///
/// This deliberately exposes no secret export, encryption, login or relay operations.
/// Callers must capture one signer for the lifetime of an operation.
pub trait EventSigner: Send + Sync {
/// The public key bound to this signer snapshot.
fn public_key(&self) -> PublicKey;

/// Sign without changing the event's public key, timestamp, tags, kind or content.
fn sign(
&self,
event: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, String>> + Send + '_>>;
}

/// An in-process signer backed by local keys. Key management remains outside the signer.
#[derive(Clone)]
pub struct LocalEventSigner {
keys: Keys,
}

impl LocalEventSigner {
/// Capture the supplied local identity, without generating or looking up keys.
pub fn new(keys: Keys) -> Self {
Self { keys }
}
}

impl EventSigner for LocalEventSigner {
fn public_key(&self) -> PublicKey {
self.keys.public_key()
}

fn sign(
&self,
event: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, String>> + Send + '_>> {
Box::pin(async move {
if event.pubkey != self.public_key() {
return Err("unsigned event does not match the signing identity".to_string());
}
event.sign_with_keys(&self.keys).map_err(|e| e.to_string())
})
}
}

// Compatibility for explicit-key callers. Generic consumers see only EventSigner;
// operations requiring a local secret continue to require Keys explicitly.
impl EventSigner for Keys {
fn public_key(&self) -> PublicKey {
Keys::public_key(self)
}

fn sign(
&self,
event: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, String>> + Send + '_>> {
Box::pin(async move { LocalEventSigner::new(self.clone()).sign(event).await })
}
}

#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Kind, Tag, Timestamp};

#[tokio::test]
async fn signs_exact_event_like_existing_local_path() {
let keys = Keys::generate();
let signer = LocalEventSigner::new(keys.clone());
let builder = EventBuilder::new(Kind::Custom(40002), "exact 🐝\ncontent")
.tags([
Tag::parse(["h", "channel"]).unwrap(),
Tag::parse(["x", "a", "b"]).unwrap(),
])
.custom_created_at(Timestamp::from(1700000000));
let legacy = builder.clone().sign_with_keys(&keys).unwrap();
let event = signer
.sign(builder.build(signer.public_key()))
.await
.unwrap();
assert_eq!(event.id, legacy.id);
assert_eq!(event.pubkey, legacy.pubkey);
assert_eq!(event.created_at, legacy.created_at);
assert_eq!(event.kind, legacy.kind);
assert_eq!(event.tags, legacy.tags);
assert_eq!(event.content, legacy.content);
event.verify().unwrap();
}

#[tokio::test]
async fn rejects_another_author_without_rewriting() {
let signer = LocalEventSigner::new(Keys::generate());
let event =
EventBuilder::new(Kind::TextNote, "unchanged").build(Keys::generate().public_key());
assert!(signer.sign(event).await.is_err());
}
}

#[cfg(test)]
mod transport_tests {
use super::*;
use crate::NostrWsConnection;
use futures_util::{SinkExt, StreamExt};
use nostr::Tag;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::{accept_async, tungstenite::Message};

struct AsyncSigner(LocalEventSigner);
impl EventSigner for AsyncSigner {
fn public_key(&self) -> PublicKey {
self.0.public_key()
}
fn sign(
&self,
event: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, String>> + Send + '_>> {
Box::pin(async move {
tokio::task::yield_now().await;
self.0.sign(event).await
})
}
}

#[tokio::test]
async fn nip42_awaits_signer_and_preserves_auth_wire_shape() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
let expected_url = url.clone();
let signer = AsyncSigner(LocalEventSigner::new(Keys::generate()));
let public_key = signer.public_key();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_async(stream).await.unwrap();
socket
.send(Message::Text(
json!(["AUTH", "challenge"]).to_string().into(),
))
.await
.unwrap();
let frame = socket.next().await.unwrap().unwrap().into_text().unwrap();
let frame: serde_json::Value = serde_json::from_str(&frame).unwrap();
assert_eq!(frame[0], "AUTH"); // Not EVENT: the signer never publishes.
let event: Event = serde_json::from_value(frame[1].clone()).unwrap();
event.verify().unwrap();
assert_eq!(event.pubkey, public_key);
assert_eq!(event.kind.as_u16(), 22242);
assert_eq!(event.content, "");
let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect();
assert_eq!(
tags,
vec![
vec!["challenge".to_string(), "challenge".to_string()],
vec!["relay".to_string(), expected_url],
vec!["auth".to_string(), "test-token".to_string()]
]
);
socket
.send(Message::Text(
json!(["OK", event.id.to_hex(), true, ""])
.to_string()
.into(),
))
.await
.unwrap();
});
let auth = Tag::parse(["auth", "test-token"]).unwrap();
let connection = NostrWsConnection::connect_authenticated(&url, &signer, Some(&auth))
.await
.unwrap();
server.await.unwrap();
drop(connection);
}
}
1 change: 1 addition & 0 deletions crates/buzz-ws-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod connection;
pub mod error;
pub mod event_signer;
pub mod message;

pub use connection::{publish_event, NostrWsConnection};
Expand Down
14 changes: 13 additions & 1 deletion desktop/src-tauri/src/app_state_accessors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ impl AppState {
}
}

/// Return the active identity keys if they are in a signable state.
/// Local-secret capability: return the active identity keys in a signable state.
///
/// Backup, pairing, encryption and agent provisioning require this capability;
/// generic event consumers should capture `event_signer` instead.
///
/// Returns `Err` when the identity is in a lost state (`identity_lost`
/// — ephemeral key, user must re-import their nsec) or when the keyring
Expand All @@ -67,6 +70,15 @@ impl AppState {
.map(|k| k.clone())
}

/// Capture the active event signer after the same recovery checks as local keys.
/// Secret export, encryption, pairing and provisioning must use `signing_keys`.
pub fn event_signer(
&self,
) -> Result<buzz_ws_client_pkg::event_signer::LocalEventSigner, String> {
self.signing_keys()
.map(buzz_ws_client_pkg::event_signer::LocalEventSigner::new)
}

/// Emit the current huddle state to the frontend via Tauri event.
///
/// Acquires both locks (app_handle + huddle_state), clones a snapshot,
Expand Down
10 changes: 6 additions & 4 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::event_signing::EventBuilderSigning;
use nostr::{
nips::nip44, Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp, ToBech32,
};
Expand Down Expand Up @@ -139,9 +140,9 @@ pub async fn sign_event(
tags: Vec<Vec<String>>,
state: State<'_, AppState>,
) -> Result<String, String> {
let keys = state.signing_keys()?;
let keys = state.event_signer()?;

tauri::async_runtime::spawn_blocking(move || {
tauri::async_runtime::spawn(async move {
let nostr_tags = tags
.into_iter()
.map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}")))
Expand All @@ -153,13 +154,14 @@ pub async fn sign_event(
}

let event = builder
.sign_with_keys(&keys)
.sign_with_event_signer(&keys)
.await
.map_err(|error| format!("sign failed: {error}"))?;

Ok(event.as_json())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
.map_err(|e| format!("spawn failed: {e}"))?
}

#[tauri::command]
Expand Down
Loading
Loading