Skip to main content

vector_core/
nip07.rs

1//! NIP-07 browser signer — a `window.nostr` extension (Alby, nos2x, ...) in
2//! the page hosting a web build.
3//!
4//! Like NIP-55 the account keeps nothing secret on this device; the identity
5//! key stays in the extension. The page owns `window.nostr` while vector-core
6//! may run elsewhere (a worker), so [`Nip07Backend`] is a request/response
7//! hook the host registers: it forwards each call to the page and resolves
8//! with the extension's answer.
9//!
10//! Only extensions with NIP-44 qualify: gift-wrapped DMs need it.
11
12use std::sync::{LazyLock, OnceLock};
13
14use nostr_sdk::prelude::*;
15
16use crate::signer::{BoxedFuture, SignerError};
17
18/// The host transport to the page's `window.nostr`.
19///
20/// `method` is the NIP-07 call (`getPublicKey`, `signEvent`, `nip44.encrypt`,
21/// `nip44.decrypt`, `nip04.encrypt`, `nip04.decrypt`), `params` its arguments
22/// as a JSON array; the result is whatever the extension resolved with.
23pub trait Nip07Backend: Send + Sync + 'static {
24    fn request(
25        &self,
26        method: &'static str,
27        params: serde_json::Value,
28    ) -> BoxedFuture<'static, Result<serde_json::Value, String>>;
29}
30
31static NIP07_BACKEND: OnceLock<Box<dyn Nip07Backend>> = OnceLock::new();
32
33/// Register the page transport. Only a web host has one.
34pub fn set_nip07_backend(backend: Box<dyn Nip07Backend>) {
35    let _ = NIP07_BACKEND.set(backend);
36}
37
38#[inline]
39pub fn nip07_backend() -> Option<&'static dyn Nip07Backend> {
40    NIP07_BACKEND.get().map(|b| b.as_ref())
41}
42
43/// Extensions prompt per request; a deep sync would otherwise stack thousands.
44const NIP07_MAX_CONCURRENT_OPS: usize = 8;
45
46static NIP07_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
47    LazyLock::new(|| tokio::sync::Semaphore::new(NIP07_MAX_CONCURRENT_OPS));
48
49async fn call(method: &'static str, params: serde_json::Value) -> Result<serde_json::Value, SignerError> {
50    let backend = nip07_backend().ok_or("no browser signer available")?;
51    let _permit = NIP07_SEMAPHORE
52        .acquire()
53        .await
54        .map_err(|_| SignerError::from("browser signer queue closed"))?;
55    backend.request(method, params).await.map_err(SignerError::from)
56}
57
58async fn call_str(method: &'static str, params: serde_json::Value) -> Result<String, SignerError> {
59    match call(method, params).await? {
60        serde_json::Value::String(s) => Ok(s),
61        other => Err(SignerError::from(format!("browser signer returned {other} for {method}"))),
62    }
63}
64
65/// Ask the extension for its identity. Used at pairing and re-authorization.
66pub async fn nip07_get_public_key() -> Result<PublicKey, String> {
67    let pk = call_str("getPublicKey", serde_json::json!([])).await.map_err(|e| e.to_string())?;
68    PublicKey::parse(&pk).map_err(|e| format!("browser signer returned an invalid pubkey: {e}"))
69}
70
71/// A `VectorSigner` over the page's extension. Cheap to clone.
72#[derive(Debug, Clone)]
73pub struct Nip07Signer {
74    user_pubkey: PublicKey,
75}
76
77impl Nip07Signer {
78    pub fn new(user_pubkey: PublicKey) -> Self {
79        Self { user_pubkey }
80    }
81}
82
83impl AsyncGetPublicKey for Nip07Signer {
84    type Error = SignerError;
85
86    // Cached: resolved on every hot path, and an extension may prompt for it.
87    fn get_public_key_async(&self) -> BoxedFuture<'_, Result<PublicKey, Self::Error>> {
88        Box::pin(async move { Ok(self.user_pubkey) })
89    }
90}
91
92impl AsyncSignEvent for Nip07Signer {
93    type Error = SignerError;
94
95    fn sign_event_async(&self, unsigned: UnsignedEvent) -> BoxedFuture<'_, Result<Event, Self::Error>> {
96        Box::pin(async move {
97            let template = serde_json::json!({
98                "kind": unsigned.kind.as_u16(),
99                "created_at": unsigned.created_at.as_secs(),
100                "tags": unsigned.tags,
101                "content": unsigned.content,
102                "pubkey": self.user_pubkey.to_hex(),
103            });
104            let signed = call("signEvent", serde_json::json!([template])).await?;
105            accept_signed(&unsigned, self.user_pubkey, signed)
106        })
107    }
108}
109
110/// Take the extension's signed event only if it is exactly the one we asked
111/// for, by our identity. Fails closed: a different extension account, or an
112/// edited event, would fork the wire under an id nothing downstream expects.
113fn accept_signed(unsigned: &UnsignedEvent, me: PublicKey, signed: serde_json::Value) -> Result<Event, SignerError> {
114    let event: Event = serde_json::from_value(signed).map_err(SignerError::backend)?;
115    if event.pubkey != me {
116        return Err(SignerError::from(format!(
117            "browser signer signed as {} (expected {})",
118            event.pubkey.to_hex(),
119            me.to_hex()
120        )));
121    }
122    if event.kind != unsigned.kind
123        || event.created_at != unsigned.created_at
124        || event.content != unsigned.content
125        || event.tags != unsigned.tags
126    {
127        return Err(SignerError::from("browser signer altered the event it signed"));
128    }
129    event.verify().map_err(SignerError::backend)?;
130    Ok(event)
131}
132
133impl AsyncNip04 for Nip07Signer {
134    type Error = SignerError;
135
136    fn nip04_encrypt_async<'a>(
137        &'a self,
138        public_key: &'a PublicKey,
139        content: &'a str,
140    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
141        Box::pin(async move { call_str("nip04.encrypt", serde_json::json!([public_key.to_hex(), content])).await })
142    }
143
144    fn nip04_decrypt_async<'a>(
145        &'a self,
146        public_key: &'a PublicKey,
147        content: &'a str,
148    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
149        Box::pin(async move { call_str("nip04.decrypt", serde_json::json!([public_key.to_hex(), content])).await })
150    }
151}
152
153impl AsyncNip44 for Nip07Signer {
154    type Error = SignerError;
155
156    fn nip44_encrypt_async<'a>(
157        &'a self,
158        public_key: &'a PublicKey,
159        content: &'a str,
160    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
161        Box::pin(async move { call_str("nip44.encrypt", serde_json::json!([public_key.to_hex(), content])).await })
162    }
163
164    fn nip44_decrypt_async<'a>(
165        &'a self,
166        public_key: &'a PublicKey,
167        payload: &'a str,
168    ) -> BoxedFuture<'a, Result<String, Self::Error>> {
169        Box::pin(async move { call_str("nip44.decrypt", serde_json::json!([public_key.to_hex(), payload])).await })
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::event_ext::FinalizeUnsignedWithId;
177
178    fn unsigned(pk: PublicKey, content: &str) -> UnsignedEvent {
179        EventBuilder::new(Kind::TextNote, content)
180            .tag(Tag::hashtag("vector"))
181            .finalize_unsigned_with_id(pk)
182    }
183
184    #[test]
185    fn accepts_exactly_the_event_asked_for() {
186        let keys = Keys::generate();
187        let asked = unsigned(keys.public_key(), "hi");
188        let signed = asked.clone().finalize(&keys).unwrap();
189        let event = accept_signed(&asked, keys.public_key(), serde_json::to_value(&signed).unwrap()).unwrap();
190        assert_eq!(Some(event.id), asked.id);
191    }
192
193    #[test]
194    fn rejects_another_identity_an_edit_or_a_bad_signature() {
195        let me = Keys::generate();
196        let asked = unsigned(me.public_key(), "hi");
197
198        let other = Keys::generate();
199        let foreign = unsigned(other.public_key(), "hi").finalize(&other).unwrap();
200        assert!(accept_signed(&asked, me.public_key(), serde_json::to_value(&foreign).unwrap()).is_err());
201
202        let edited = unsigned(me.public_key(), "hello").finalize(&me).unwrap();
203        assert!(accept_signed(&asked, me.public_key(), serde_json::to_value(&edited).unwrap()).is_err());
204
205        let mut forged = serde_json::to_value(asked.clone().finalize(&me).unwrap()).unwrap();
206        let sig = forged["sig"].as_str().unwrap().to_string();
207        let flipped = if sig.starts_with('0') { format!("1{}", &sig[1..]) } else { format!("0{}", &sig[1..]) };
208        forged["sig"] = serde_json::json!(flipped);
209        assert!(accept_signed(&asked, me.public_key(), forged).is_err());
210    }
211
212    #[tokio::test]
213    async fn no_backend_is_a_clean_error() {
214        if nip07_backend().is_some() {
215            return;
216        }
217        let keys = Keys::generate();
218        let signer = Nip07Signer::new(keys.public_key());
219        assert_eq!(signer.get_public_key_async().await.unwrap(), keys.public_key());
220        assert!(signer.sign_event_async(unsigned(keys.public_key(), "hi")).await.is_err());
221    }
222}