use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{LazyLock, RwLock};
use std::time::Duration;
use nostr_sdk::prelude::*;
use nostr_connect::prelude::{AuthUrlHandler, NostrConnect, NostrConnectUri};
pub type BoxedFuture<'a, T> =
std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignerError(String);
impl SignerError {
#[inline]
pub fn backend<E>(e: E) -> Self
where
E: core::fmt::Display,
{
Self(e.to_string())
}
}
impl core::fmt::Display for SignerError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
impl core::error::Error for SignerError {}
impl From<&str> for SignerError {
#[inline]
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for SignerError {
#[inline]
fn from(s: String) -> Self {
Self(s)
}
}
pub trait VectorSigner:
AsyncGetPublicKey<Error = SignerError>
+ AsyncSignEvent<Error = SignerError>
+ AsyncNip04<Error = SignerError>
+ AsyncNip44<Error = SignerError>
{
}
impl<T> VectorSigner for T
where
T: ?Sized
+ AsyncGetPublicKey<Error = SignerError>
+ AsyncSignEvent<Error = SignerError>
+ AsyncNip04<Error = SignerError>
+ AsyncNip44<Error = SignerError>,
{
}
#[derive(Debug, Clone)]
pub enum ActiveSigner {
Local(crate::crypto::GuardedSigner),
Bunker(WatchedBunkerSigner),
Nip55(crate::nip55::Nip55Signer),
Keys(Keys),
}
macro_rules! dispatch {
($self:ident, $method:ident $(, $arg:expr)*) => {
match $self {
ActiveSigner::Local(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
ActiveSigner::Bunker(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
ActiveSigner::Nip55(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
ActiveSigner::Keys(s) => s.$method($($arg),*).await.map_err(SignerError::backend),
}
};
}
impl AsyncGetPublicKey for ActiveSigner {
type Error = SignerError;
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
Box::pin(async move { dispatch!(self, get_public_key_async) })
}
}
impl AsyncSignEvent for ActiveSigner {
type Error = SignerError;
fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
Box::pin(async move { dispatch!(self, sign_event_async, unsigned) })
}
}
impl AsyncNip04 for ActiveSigner {
type Error = SignerError;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { dispatch!(self, nip04_encrypt_async, public_key, content) })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { dispatch!(self, nip04_decrypt_async, public_key, encrypted_content) })
}
}
impl AsyncNip44 for ActiveSigner {
type Error = SignerError;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { dispatch!(self, nip44_encrypt_async, public_key, content) })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { dispatch!(self, nip44_decrypt_async, public_key, payload) })
}
}
#[cfg(test)]
static TEST_SIGNER: LazyLock<RwLock<Option<ActiveSigner>>> =
LazyLock::new(|| RwLock::new(None));
#[cfg(test)]
pub(crate) fn set_test_signer(signer: Option<ActiveSigner>) {
if let Ok(mut g) = TEST_SIGNER.write() {
*g = signer;
}
}
pub fn active_signer() -> Result<ActiveSigner, String> {
#[cfg(test)]
if let Some(s) = TEST_SIGNER.read().ok().and_then(|g| g.clone()) {
return Ok(s);
}
match signer_kind() {
SignerKind::Bunker => {
let inner = bunker_signer()
.ok_or("bunker account has no live signer (not yet connected)")?;
Ok(ActiveSigner::Bunker(WatchedBunkerSigner::new(inner)))
}
SignerKind::Nip55 => {
let pk = crate::state::my_public_key().ok_or("no active identity")?;
Ok(ActiveSigner::Nip55(crate::nip55::Nip55Signer::new(pk)))
}
SignerKind::Local => {
let keys = crate::state::MY_SECRET_KEY
.to_keys()
.ok_or("no signer available (no local key)")?;
if let Some(pk) = crate::state::my_public_key() {
if keys.public_key() != pk {
return Err("local key does not match the active identity (remote-signer account with no live signer)".to_string());
}
return Ok(ActiveSigner::Local(crate::crypto::GuardedSigner::new(pk)));
}
Ok(ActiveSigner::Keys(keys))
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum SignerKind {
Local = 0,
Bunker = 1,
Nip55 = 2,
}
impl SignerKind {
#[inline]
pub fn as_setting_str(self) -> &'static str {
match self {
SignerKind::Local => "local",
SignerKind::Bunker => "bunker",
SignerKind::Nip55 => "nip55",
}
}
#[inline]
pub fn from_setting_str(s: &str) -> Self {
match s {
"bunker" => SignerKind::Bunker,
"nip55" => SignerKind::Nip55,
_ => SignerKind::Local,
}
}
}
static SIGNER_KIND: AtomicU8 = AtomicU8::new(SignerKind::Local as u8);
#[inline]
pub fn signer_kind() -> SignerKind {
match SIGNER_KIND.load(Ordering::Acquire) {
1 => SignerKind::Bunker,
2 => SignerKind::Nip55,
_ => SignerKind::Local,
}
}
#[inline]
pub fn set_signer_kind(kind: SignerKind) {
SIGNER_KIND.store(kind as u8, Ordering::Release);
}
#[inline]
pub fn is_bunker() -> bool {
signer_kind() == SignerKind::Bunker
}
#[inline]
pub fn is_keyless() -> bool {
signer_kind() != SignerKind::Local
}
pub static BUNKER_SIGNER: LazyLock<RwLock<Option<NostrConnect>>> =
LazyLock::new(|| RwLock::new(None));
#[inline]
pub fn bunker_signer() -> Option<NostrConnect> {
BUNKER_SIGNER.read().ok().and_then(|g| g.as_ref().cloned())
}
#[inline]
pub fn set_bunker_signer(signer: NostrConnect) {
if let Ok(mut g) = BUNKER_SIGNER.write() {
*g = Some(signer);
}
}
#[inline]
pub fn take_bunker_signer() -> Option<NostrConnect> {
BUNKER_SIGNER.write().ok().and_then(|mut g| g.take())
}
pub fn parse_bunker_relays(bunker_url: &str) -> Vec<String> {
match NostrConnectUri::parse(bunker_url) {
Ok(NostrConnectUri::Bunker { relays, .. }) => {
relays.into_iter().map(|r| r.to_string()).collect()
}
_ => Vec::new(),
}
}
pub fn parse_bunker_remote_pubkey(bunker_url: &str) -> Result<String, String> {
let uri = NostrConnectUri::parse(bunker_url)
.map_err(|e| format!("Invalid bunker URL: {}", e))?;
match uri {
NostrConnectUri::Bunker { remote_signer_public_key, .. } => {
Ok(remote_signer_public_key.to_hex().to_ascii_lowercase())
}
NostrConnectUri::Client { .. } => {
Err("Client-initiated URIs not supported here; use a bunker:// URL".into())
}
}
}
pub const VECTOR_APP_NAME: &str = "Vector";
pub const VECTOR_APP_URL: &str = "https://vectorapp.io";
pub const VECTOR_APP_ICON: &str = "https://raw.githubusercontent.com/VectorPrivacy/Vector/master/src-tauri/icons/icon.png";
pub const VECTOR_NIP46_PERMS: &[&str] = &[
"get_public_key",
"sign_event",
"nip04_encrypt",
"nip04_decrypt",
"nip44_encrypt",
"nip44_decrypt",
];
pub fn vector_metadata() -> NostrConnectMetadata {
let mut md = NostrConnectMetadata::new(VECTOR_APP_NAME);
if let Ok(url) = Url::parse(VECTOR_APP_URL) {
md = md.url(url);
}
if let Ok(icon) = Url::parse(VECTOR_APP_ICON) {
md = md.icons(vec![icon]);
}
md
}
pub fn build_nostrconnect_uri(
client_pubkey: PublicKey,
relays: Vec<RelayUrl>,
) -> NostrConnectUri {
NostrConnectUri::Client {
public_key: client_pubkey,
relays,
metadata: vector_metadata(),
secret: random_connect_secret(),
}
}
fn random_connect_secret() -> String {
use ::rand::RngCore;
let mut bytes = [0u8; 16];
::rand::rngs::OsRng.fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn build_nostrconnect_session(
client_keys: Keys,
relays: Vec<RelayUrl>,
timeout: Duration,
) -> Result<(NostrConnect, String), String> {
let uri = build_nostrconnect_uri(client_keys.public_key(), relays);
let mut uri_string = uri.to_string();
let perms = VECTOR_NIP46_PERMS.join(",");
if !perms.is_empty() {
uri_string.push_str("&perms=");
uri_string.push_str(&perms);
}
let mut nc = NostrConnect::new(uri, client_keys, timeout, None)
.map_err(|e| format!("Bunker init failed: {}", e))?;
nc.auth_url_handler(VectorAuthUrlHandler);
Ok((nc, uri_string))
}
pub fn build_bunker_signer(
bunker_url: &str,
client_keys: Keys,
timeout: Duration,
) -> Result<NostrConnect, String> {
let uri = NostrConnectUri::parse(bunker_url)
.map_err(|e| format!("Invalid bunker URL: {}", e))?;
NostrConnect::new(uri, client_keys, timeout, None)
.map_err(|e| format!("Bunker init failed: {}", e))
}
pub async fn prewarm_bunker(signer: &NostrConnect) -> Result<PublicKey, String> {
signer
.get_public_key_async()
.await
.map_err(|e| format!("Bunker prewarm failed: {}", e))
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u8)]
pub enum BunkerConnectionState {
Idle = 0,
Connecting = 1,
Online = 2,
Offline = 3,
}
impl BunkerConnectionState {
pub fn as_label(self) -> &'static str {
match self {
BunkerConnectionState::Idle => "idle",
BunkerConnectionState::Connecting => "connecting",
BunkerConnectionState::Online => "online",
BunkerConnectionState::Offline => "offline",
}
}
}
static BUNKER_STATE: AtomicU8 = AtomicU8::new(BunkerConnectionState::Idle as u8);
#[inline]
pub fn bunker_state() -> BunkerConnectionState {
match BUNKER_STATE.load(Ordering::Acquire) {
1 => BunkerConnectionState::Connecting,
2 => BunkerConnectionState::Online,
3 => BunkerConnectionState::Offline,
_ => BunkerConnectionState::Idle,
}
}
pub fn set_bunker_state(new_state: BunkerConnectionState) {
let prev = BUNKER_STATE.swap(new_state as u8, Ordering::AcqRel);
if prev == new_state as u8 {
return;
}
crate::traits::emit_event_json(
"bunker_state",
serde_json::json!({ "state": new_state.as_label() }),
);
}
#[derive(Debug, Clone)]
pub struct WatchedBunkerSigner {
inner: NostrConnect,
session: crate::state::SessionGuard,
}
impl WatchedBunkerSigner {
pub fn new(inner: NostrConnect) -> Self {
Self { inner, session: crate::state::SessionGuard::capture() }
}
#[inline]
fn flip(&self, state: BunkerConnectionState) {
if self.session.is_valid() {
set_bunker_state(state);
}
}
#[cfg(test)]
pub(crate) fn session_generation_for_test(&self) -> u64 {
self.session.generation()
}
}
impl WatchedBunkerSigner {
#[inline]
fn watch<T, E>(&self, res: Result<T, E>) -> Result<T, SignerError>
where
E: core::fmt::Display,
{
match res {
Ok(v) => {
self.flip(BunkerConnectionState::Online);
Ok(v)
}
Err(e) => {
self.flip(BunkerConnectionState::Offline);
Err(SignerError::backend(e))
}
}
}
}
impl AsyncGetPublicKey for WatchedBunkerSigner {
type Error = SignerError;
fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
Box::pin(async move { self.watch(self.inner.get_public_key_async().await) })
}
}
impl AsyncSignEvent for WatchedBunkerSigner {
type Error = SignerError;
fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
Box::pin(async move { self.watch(self.inner.sign_event_async(unsigned).await) })
}
}
impl AsyncNip04 for WatchedBunkerSigner {
type Error = SignerError;
fn nip04_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { self.watch(self.inner.nip04_encrypt_async(public_key, content).await) })
}
fn nip04_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
encrypted_content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move {
self.watch(self.inner.nip04_decrypt_async(public_key, encrypted_content).await)
})
}
}
impl AsyncNip44 for WatchedBunkerSigner {
type Error = SignerError;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { self.watch(self.inner.nip44_encrypt_async(public_key, content).await) })
}
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> BoxedFuture<'a, Result<String, Self::Error>> {
Box::pin(async move { self.watch(self.inner.nip44_decrypt_async(public_key, payload).await) })
}
}
#[derive(Debug, Clone, Default)]
pub struct VectorAuthUrlHandler;
impl AuthUrlHandler for VectorAuthUrlHandler {
fn on_auth_url<'a>(&'a self, auth_url: Url) -> BoxedFuture<'a, std::result::Result<(), nostr_connect::error::Error>> {
Box::pin(async move {
crate::traits::emit_event_json(
"bunker_auth_url",
serde_json::json!({ "url": auth_url.to_string() }),
);
Ok(())
})
}
}
pub async fn attempt_bunker_login(
bunker_url: &str,
client_keys: Keys,
timeout: Duration,
) -> Result<PublicKey, String> {
set_bunker_state(BunkerConnectionState::Connecting);
let mut nc = match build_bunker_signer(bunker_url, client_keys, timeout) {
Ok(nc) => nc,
Err(e) => {
set_bunker_state(BunkerConnectionState::Offline);
return Err(e);
}
};
nc.auth_url_handler(VectorAuthUrlHandler);
match prewarm_bunker(&nc).await {
Ok(remote_pk) => {
if let Some(old) = take_bunker_signer() {
tokio::spawn(async move { let _ = old.shutdown().await; });
}
set_bunker_signer(nc);
set_bunker_state(BunkerConnectionState::Online);
Ok(remote_pk)
}
Err(e) => {
set_bunker_state(BunkerConnectionState::Offline);
Err(e)
}
}
}
pub fn drain_bunker_state() -> Option<NostrConnect> {
set_signer_kind(SignerKind::Local);
set_bunker_state(BunkerConnectionState::Idle);
take_bunker_signer()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setting_roundtrip() {
assert_eq!(SignerKind::from_setting_str("local"), SignerKind::Local);
assert_eq!(SignerKind::from_setting_str("bunker"), SignerKind::Bunker);
assert_eq!(SignerKind::from_setting_str("nip55"), SignerKind::Nip55);
assert_eq!(SignerKind::Local.as_setting_str(), "local");
assert_eq!(SignerKind::Bunker.as_setting_str(), "bunker");
assert_eq!(SignerKind::Nip55.as_setting_str(), "nip55");
assert_eq!(SignerKind::from_setting_str(""), SignerKind::Local);
assert_eq!(SignerKind::from_setting_str("garbage"), SignerKind::Local);
}
#[test]
fn atomic_state_round_trips_and_drains() {
set_signer_kind(SignerKind::Local);
set_bunker_state(BunkerConnectionState::Idle);
set_signer_kind(SignerKind::Bunker);
assert_eq!(signer_kind(), SignerKind::Bunker);
assert!(is_bunker());
assert!(is_keyless());
set_signer_kind(SignerKind::Local);
assert_eq!(signer_kind(), SignerKind::Local);
assert!(!is_bunker());
assert!(!is_keyless());
set_signer_kind(SignerKind::Nip55);
assert_eq!(signer_kind(), SignerKind::Nip55);
assert!(!is_bunker());
assert!(is_keyless());
set_signer_kind(SignerKind::Local);
set_signer_kind(SignerKind::Bunker);
set_bunker_state(BunkerConnectionState::Online);
let drained = drain_bunker_state();
assert!(drained.is_none());
assert_eq!(signer_kind(), SignerKind::Local);
assert_eq!(bunker_state(), BunkerConnectionState::Idle);
let drained_again = drain_bunker_state();
assert!(drained_again.is_none());
assert_eq!(signer_kind(), SignerKind::Local);
assert_eq!(bunker_state(), BunkerConnectionState::Idle);
}
#[test]
fn bunker_state_label_covers_all_variants() {
assert_eq!(BunkerConnectionState::Idle.as_label(), "idle");
assert_eq!(BunkerConnectionState::Connecting.as_label(), "connecting");
assert_eq!(BunkerConnectionState::Online.as_label(), "online");
assert_eq!(BunkerConnectionState::Offline.as_label(), "offline");
}
#[test]
fn parse_bunker_relays_returns_relays_from_bunker_uri() {
let signer_keys = Keys::generate();
let r1 = RelayUrl::parse("wss://relay1.example").unwrap();
let r2 = RelayUrl::parse("wss://relay2.example").unwrap();
let uri = NostrConnectUri::Bunker {
remote_signer_public_key: signer_keys.public_key(),
relays: vec![r1.clone(), r2.clone()],
secret: None,
};
let relays = parse_bunker_relays(&uri.to_string());
assert_eq!(relays.len(), 2);
assert!(relays.iter().any(|r| r.contains("relay1.example")));
assert!(relays.iter().any(|r| r.contains("relay2.example")));
}
#[test]
fn parse_bunker_relays_returns_empty_on_invalid_input() {
assert!(parse_bunker_relays("").is_empty());
assert!(parse_bunker_relays("not a url").is_empty());
assert!(parse_bunker_relays("http://example.com").is_empty());
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let client_uri = build_nostrconnect_uri(client_keys.public_key(), vec![relay]);
assert!(parse_bunker_relays(&client_uri.to_string()).is_empty(),
"client URI must not surface as a bunker relay list");
}
#[test]
fn parse_bunker_remote_pubkey_invalid_url() {
assert!(parse_bunker_remote_pubkey("not a url").is_err());
assert!(parse_bunker_remote_pubkey("").is_err());
assert!(parse_bunker_remote_pubkey("http://example.com").is_err());
}
#[test]
fn parse_bunker_remote_pubkey_rejects_client_uri() {
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let uri = build_nostrconnect_uri(client_keys.public_key(), vec![relay]);
let err = parse_bunker_remote_pubkey(&uri.to_string())
.expect_err("client URI must be rejected");
assert!(err.contains("Client-initiated"), "unexpected error: {}", err);
}
#[test]
fn parse_bunker_remote_pubkey_normalizes_lowercase() {
let signer_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let uri = NostrConnectUri::Bunker {
remote_signer_public_key: signer_keys.public_key(),
relays: vec![relay],
secret: None,
};
let parsed = parse_bunker_remote_pubkey(&uri.to_string())
.expect("valid bunker URI");
assert_eq!(parsed, signer_keys.public_key().to_hex().to_ascii_lowercase());
assert_eq!(parsed, parsed.to_ascii_lowercase(),
"callers may compare with == — output must already be lowercase");
}
#[test]
fn vector_metadata_carries_app_name_and_icon() {
let md = vector_metadata();
let json = serde_json::to_string(&md).expect("metadata serializes");
assert!(json.contains(VECTOR_APP_NAME),
"metadata must include app name for the signer's approval prompt; got {}", json);
assert!(json.contains("vectorapp.io"),
"metadata must reference the app URL for the signer's 'More info' link");
}
#[test]
fn nip46_perms_list_excludes_get_private_key() {
for perm in VECTOR_NIP46_PERMS {
assert!(!perm.contains("get_private_key"),
"VECTOR_NIP46_PERMS must never include get_private_key (found: {})", perm);
assert!(!perm.contains("private_key"),
"perm string looks dangerous: {}", perm);
}
}
#[test]
fn build_nostrconnect_session_appends_perms_query_param() {
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let (_nc, uri) = build_nostrconnect_session(
client_keys,
vec![relay],
std::time::Duration::from_secs(1),
).expect("session builds");
assert!(uri.contains("perms="),
"URI must carry perms query param so signers can scope the pairing; got: {}", uri);
for perm in VECTOR_NIP46_PERMS {
assert!(uri.contains(perm),
"URI missing permission '{}': {}", perm, uri);
}
assert!(!uri.contains("get_private_key"),
"URI must never request get_private_key: {}", uri);
}
#[test]
fn build_nostrconnect_session_rejects_empty_uri() {
let client_keys = Keys::generate();
let session = build_nostrconnect_session(
client_keys,
vec![],
std::time::Duration::from_secs(1),
);
let _ = session;
}
#[test]
fn watched_signer_session_gate_and_state_transitions() {
use crate::state::{bump_session_generation, current_session_generation};
let client_keys = Keys::generate();
let relay = RelayUrl::parse("wss://relay.example").unwrap();
let signer_keys = Keys::generate();
let uri = NostrConnectUri::Bunker {
remote_signer_public_key: signer_keys.public_key(),
relays: vec![relay],
secret: None,
};
let nc = NostrConnect::new(
uri,
client_keys,
std::time::Duration::from_secs(1),
None,
).expect("NostrConnect builds");
let gen_before = current_session_generation();
let watched = WatchedBunkerSigner::new(nc);
assert_eq!(watched.session_generation_for_test(), gen_before,
"WatchedBunkerSigner must capture the live session generation at construction");
set_bunker_state(BunkerConnectionState::Idle);
watched.flip(BunkerConnectionState::Online);
assert_eq!(bunker_state(), BunkerConnectionState::Online,
"flip with valid session must update bunker_state");
bump_session_generation();
set_bunker_state(BunkerConnectionState::Online);
watched.flip(BunkerConnectionState::Offline);
assert_eq!(bunker_state(), BunkerConnectionState::Online,
"flip with stale session must be a no-op");
set_bunker_state(BunkerConnectionState::Idle);
}
}