harden proxy auth and event ordering

This commit is contained in:
2026-11-12 17:59:28 +07:00
parent fe0ca04817
commit fd802a8870
16 changed files with 819 additions and 297 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ async function pollForRequests() {
isPolling = true;
try {
const response = await fetch('/api/pending');
const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
const data = await response.json();
console.log('Polled for requests, got:', data);
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
console.log('Sending response:', responsePayload);
await fetch('/api/response', {
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
console.log('Sending error response:', errorPayload);
await fetch('/api/response', {
await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+94 -74
View File
@@ -265,6 +265,10 @@ impl BrowserSignerProxy {
let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
// Regenerated on every startup, never part of the opened URL
let session_token: String = Uuid::new_v4().simple().to_string();
// Only the exact host:port the server binds may appear in the Host header
let expected_host: String = addr.to_string();
smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}");
@@ -291,13 +295,22 @@ impl BrowserSignerProxy {
AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone();
let expected_host: String = expected_host.clone();
let session_token: String = session_token.clone();
smol::spawn(async move {
let shutdown_listener = shutdown.listen();
smol::future::or(
async {
handle_connection(stream, state, custom_html).await;
handle_connection(
stream,
state,
custom_html,
expected_host,
session_token,
)
.await;
},
async {
shutdown_listener.await;
@@ -336,6 +349,18 @@ impl BrowserSignerProxy {
outgoing_requests.push(request);
}
/// Drop a request from both queues so timed-out request data is not retained.
#[inline]
async fn cancel_request(&self, id: Uuid) {
self.inner.state.pending_responses.lock().await.remove(&id);
self.inner
.state
.outgoing_requests
.lock()
.await
.retain(|request| request.id != id);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where
T: DeserializeOwned,
@@ -345,6 +370,7 @@ impl BrowserSignerProxy {
// Construct the request
let request: RequestData = RequestData::new(method, params);
let request_id = request.id;
// Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
@@ -361,7 +387,10 @@ impl BrowserSignerProxy {
match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()),
Err(TimeoutError) => {
self.cancel_request(request_id).await;
Err(Error::timeout())
}
}
}
@@ -437,10 +466,14 @@ impl AsyncNip44 for BrowserSignerProxy {
}
}
// ── Minimal HTTP server ──────────────────────────────────────────────────
/// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) {
async fn handle_connection(
stream: TcpStream,
state: Arc<ProxyState>,
custom_html: &'static str,
expected_host: String,
session_token: String,
) {
let mut reader = BufReader::new(stream);
// Read the request line
@@ -462,24 +495,49 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Read headers until empty line
let mut headers = Vec::new();
let mut content_length: usize = 0;
let mut host: Option<String> = None;
loop {
let mut line = String::new();
if reader.read_line(&mut line).await.is_err() {
return;
}
let line = line.trim_end().to_string();
if line.is_empty() {
break;
}
if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("host:") {
host = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Host:") {
host = Some(value.trim().to_string());
}
headers.push(line);
}
match (method.as_str(), path.as_str()) {
// DNS rebinding defense: only the exact bound host:port is accepted
if host.as_deref() != Some(expected_host.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
let (route, query) = match path.split_once('?') {
Some((route, query)) => (route, Some(query)),
None => (path.as_str(), None),
};
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
match (method.as_str(), route) {
// Serve the HTML proxy page
("GET", "/") => {
let html = if custom_html.is_empty() {
@@ -487,7 +545,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} else {
custom_html
};
send_response(&mut reader, 200, "OK", "text/html", html).await;
let html = inject_session_token(html, &session_token);
send_response(&mut reader, 200, "OK", "text/html", &html).await;
}
// Serve the JS proxy script
("GET", "/proxy.js") => {
@@ -516,7 +575,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Clear the outgoing requests after sending them
outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await;
send_response(&mut reader, 200, "OK", "application/json", &json).await;
}
// Receive response from browser extension
("POST", "/api/response") => {
@@ -541,27 +600,16 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) {
Some(sender) => {
// Use try_send since we already hold the lock
let _ = sender.try_send(message.into_result());
tracing::info!("Forwarded response for request {id}");
Some(sender) => match sender.try_send(message.into_result()) {
Ok(()) => tracing::info!("Forwarded response for request {id}"),
Err(e) => {
tracing::warn!("Failed to forward response for request {id}: {e}")
}
},
None => tracing::warn!("No pending request found for {id}"),
}
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// 404 - not found
_ => {
@@ -585,68 +633,44 @@ async fn send_response(
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
write_response(stream, &response).await;
}
/// Write a response with CORS headers and JSON content type.
async fn send_response_cors_json(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
if let Err(e) = stream.write_all(response.as_bytes()).await {
tracing::debug!("Failed to write response to peer: {e}");
}
if let Err(e) = stream.flush().await {
tracing::debug!("Failed to flush response to peer: {e}");
}
}
/// Write a response with CORS headers.
async fn send_response_cors(
stream: &mut (impl AsyncWriteExt + Unpin),
status: u16,
status_text: &str,
content_type: &str,
body: &str,
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
/// Get a parameter value from an optional query string.
fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
let query = query?;
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then_some(value)
})
}
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
fn inject_session_token(html: &str, token: &str) -> String {
let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
match html.rfind("</body>") {
Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
None => format!("{html}{script}"),
}
}
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out.
#[derive(Debug)]
struct TimeoutError;
/// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>(
duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>,
@@ -677,10 +701,6 @@ async fn race_timeout<T>(
}
}
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline]
fn current_time() -> u64 {
SystemTime::now()
+12 -5
View File
@@ -598,16 +598,23 @@ impl ChatPanel {
return;
}
let content = SharedString::from(&event.content);
let emojis = emojis_from_tags(&event.tags);
let image = Message::reaction_image(&event.content, &emojis)
.map(|url| SharedUri::from(url.to_string()));
for id in event.tags.event_ids() {
self.reactions.entry(id).or_default().push((
SharedString::from(&event.content),
image.clone(),
event.pubkey,
));
let entries = self.reactions.entry(id).or_default();
// NIP-25: a newer reaction by the same author replaces their previous one
if let Some(existing) = entries
.iter_mut()
.find(|(_, _, author)| *author == event.pubkey)
{
*existing = (content.clone(), image.clone(), event.pubkey);
} else {
entries.push((content.clone(), image.clone(), event.pubkey));
}
}
cx.notify();
+141 -45
View File
@@ -1,65 +1,161 @@
use std::sync::LazyLock;
use gpui::SharedUri;
use regex::Regex;
/// Extracts media URLs from a string and returns both
/// the extracted URLs and the string with media URLs removed
struct MediaExtractor {
image_regex: Regex,
}
/// Matches image URLs with common image extensions
static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
)
.unwrap()
});
/// Extracts media URLs from a string and returns the URLs and the cleaned text
struct MediaExtractor;
impl MediaExtractor {
/// Creates a new MediaExtractor with compiled regex patterns
fn new() -> Self {
MediaExtractor {
// Match common image extensions
image_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
}
}
/// Extracts all media URLs from a string
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> {
let mut urls = Vec::new();
// Extract image URLs
for capture in self.image_regex.find_iter(text) {
urls.push(capture.as_str().to_string().into());
}
urls
fn extract_media_urls(text: &str) -> Vec<SharedUri> {
IMAGE_RE
.find_iter(text)
.map(|capture| capture.as_str().to_string().into())
.collect()
}
/// Removes all media URLs from a string and returns the cleaned text
fn remove_media_urls(&self, text: &str) -> String {
let mut result = text.to_string();
fn remove_media_urls(text: &str) -> String {
let mut cleaned = String::with_capacity(text.len());
let mut last_end = 0;
// Whitespace bordered a removed URL, so one space stands in for it
let mut spaced = false;
// Remove image URLs
result = self.image_regex.replace_all(&result, "").to_string();
for url in IMAGE_RE.find_iter(text) {
let raw_gap = &text[last_end..url.start()];
let surrounded = raw_gap.ends_with(char::is_whitespace)
|| text[url.end()..].starts_with(char::is_whitespace);
// Clean up extra whitespace that might result from removal
self.cleanup_text(&result)
let gap = if last_end == 0 {
raw_gap.trim_end()
} else {
raw_gap.trim()
};
if !gap.is_empty() {
if spaced && !cleaned.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(gap);
}
spaced |= surrounded;
last_end = url.end();
}
let tail = text[last_end..].trim_start();
if spaced && !cleaned.is_empty() && !tail.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(tail);
cleaned.trim().to_string()
}
/// Extracts media URLs and removes them from the string, returning both
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) {
let urls = self.extract_media_urls(text);
let cleaned_text = self.remove_media_urls(text);
(urls, cleaned_text)
}
/// Helper function to clean up text after URL removal
fn cleanup_text(&self, text: &str) -> String {
let text = text.trim();
// Remove multiple consecutive spaces
let re = Regex::new(r"\s+").unwrap();
re.replace_all(text, " ").trim().to_string()
fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
(
Self::extract_media_urls(text),
Self::remove_media_urls(text),
)
}
}
/// Convenience function for one-time extraction and removal
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
let extractor = MediaExtractor::new();
extractor.extract_and_remove(text)
MediaExtractor::extract_and_remove(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_line_text_without_urls_keeps_line_breaks() {
let text = "first line\n\nsecond line";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn removes_url_and_tidies_around_it() {
let (urls, cleaned) =
extract_and_remove_media_urls("check https://example.com/pic.png here");
assert_eq!(urls, vec![SharedUri::from("https://example.com/pic.png")]);
assert_eq!(cleaned, "check here");
}
#[test]
fn removing_url_keeps_other_line_breaks() {
let (urls, cleaned) = extract_and_remove_media_urls(
"first\nsecond https://example.com/pic.png third\nfourth",
);
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first\nsecond third\nfourth");
}
#[test]
fn url_on_its_own_line_joins_neighbors_with_one_space() {
let (urls, cleaned) =
extract_and_remove_media_urls("first\n\nhttps://example.com/pic.png\n\nlast");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first last");
}
#[test]
fn multiple_urls_collapse_to_single_spaces() {
let (urls, cleaned) = extract_and_remove_media_urls(
"a https://example.com/a.png b https://example.com/b.png c",
);
assert_eq!(urls.len(), 2);
assert_eq!(cleaned, "a b c");
}
#[test]
fn message_with_only_a_url_becomes_empty() {
let (urls, cleaned) = extract_and_remove_media_urls("https://example.com/pic.png");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "");
}
#[test]
fn digits_glued_to_url_scheme_prevent_match() {
let text = "v1.2https://example.com/pic.png";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn digits_around_removed_url_survive() {
let (urls, cleaned) = extract_and_remove_media_urls("123 https://example.com/pic.png 456");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "123 456");
}
#[test]
fn emoji_around_removed_url_survives() {
let (urls, cleaned) = extract_and_remove_media_urls("👍 https://example.com/pic.png 👍");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "👍 👍");
}
}
+16 -3
View File
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
use crate::protocol::{
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow, tag_value,
TimelineWindow, is_newer_event, tag_value,
};
const REPLAY_LIMIT: usize = 100;
@@ -91,7 +91,8 @@ pub struct Group {
membership: Membership,
membership_events: Vec<Event>,
elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>,
/// The last accepted (created_at, id) per state kind.
state_ids: HashMap<Kind, (Timestamp, EventId)>,
rows: Vec<Row>,
index: HashSet<EventId>,
reactions: HashMap<EventId, Vec<Reaction>>,
@@ -694,6 +695,15 @@ impl Group {
}
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
// Relays deliver same-kind state events in any order.
if !is_newer_event(
self.state_ids.get(&event.kind).copied(),
event.created_at,
event.id,
) {
return false;
}
match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) {
Ok(metadata) => self.metadata = Some(metadata),
@@ -724,7 +734,10 @@ impl Group {
_ => return false,
}
self.state_ids.insert(event.kind, event.id) != Some(event.id)
self.state_ids
.insert(event.kind, (event.created_at, event.id));
true
}
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
+37 -11
View File
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
groups: Vec<Entity<Group>>,
previews: HashMap<GroupKey, Entity<Group>>,
list: GroupList,
/// The (created_at, id) of the last accepted group list event.
list_stamp: Option<(Timestamp, EventId)>,
browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>,
@@ -132,6 +134,7 @@ impl GroupsRegistry {
groups: Vec::new(),
previews: HashMap::new(),
list: GroupList::default(),
list_stamp: None,
browse: HashMap::new(),
routes: HashMap::new(),
observers: HashMap::new(),
@@ -455,6 +458,8 @@ impl GroupsRegistry {
self.fork_authors.clear();
self.syncing.clear();
self.list = GroupList::default();
// A new signer's list events must not be judged against the old stamp.
self.list_stamp = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
@@ -488,24 +493,26 @@ impl GroupsRegistry {
let signer = nostr.read(cx).signer();
// Show the cached groups before the relays answer.
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
let task: Task<Result<(GroupList, Option<Event>), Error>> =
cx.background_spawn(async move {
let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
match client.database().query(filter).await?.into_iter().next() {
Some(event) => {
let list = GroupList::parse(&event)?;
Ok(Self::hydrate_list(list, &signer, &me).await)
let list =
Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
Ok((list, Some(event)))
}
None => Ok(GroupList::default()),
None => Ok((GroupList::default(), None)),
}
});
self.tasks
.push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => {
Ok((list, event)) => {
this.update(cx, |this, cx| {
this.merge(list, cx);
this.merge(list, event.as_ref(), cx);
})
.ok();
}
@@ -538,7 +545,16 @@ impl GroupsRegistry {
self.watch_forks(cx);
}
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
fn merge(&mut self, list: GroupList, event: Option<&Event>, cx: &mut Context<Self>) {
if let Some(event) = event {
// Relays replay list events out of order; only strictly newer applies.
if !is_newer_event(self.list_stamp, event.created_at, event.id) {
return;
}
self.list_stamp = Some((event.created_at, event.id));
}
// Carry the event's private section so a later publish can preserve it.
self.list
.set_content(list.content().map(str::to_owned), list.content_readable());
@@ -589,20 +605,21 @@ impl GroupsRegistry {
};
if list.content().is_none() {
self.merge(list, cx);
self.merge(list, Some(&event), cx);
return;
}
let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer();
let Some(me) = nostr.read(cx).current_user() else {
self.merge(list, cx);
self.merge(list, Some(&event), cx);
return;
};
self.tasks.push(cx.spawn(async move |this, cx| {
let list = Self::hydrate_list(list, &signer, &me).await;
this.update(cx, |this, cx| this.merge(list, cx)).ok();
this.update(cx, |this, cx| this.merge(list, Some(&event), cx))
.ok();
}));
}
@@ -864,7 +881,7 @@ impl GroupsRegistry {
let me = nostr.read(cx).current_user();
let list = self.list.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
self.tasks.push(cx.spawn(async move |this, cx| {
// Re-encrypt the private section when it carries local entries; a
// section this session could not read is carried verbatim.
let content = match (list.has_private(), list.content_readable()) {
@@ -893,6 +910,15 @@ impl GroupsRegistry {
}
};
// Our own publish is the newest list until a newer event arrives.
if let Err(error) = this.update(cx, |this, _cx| {
if is_newer_event(this.list_stamp, event.created_at, event.id) {
this.list_stamp = Some((event.created_at, event.id));
}
}) {
log::warn!("recording the published list failed: {error}");
}
match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => {
+24
View File
@@ -50,6 +50,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
.and_then(|tag| tag.content())
}
/// Whether `(created_at, id)` is strictly newer than the last accepted stamp.
pub(crate) fn is_newer_event(
accepted: Option<(Timestamp, EventId)>,
created_at: Timestamp,
id: EventId,
) -> bool {
accepted.is_none_or(|last| (created_at, id) > last)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GroupId(String);
@@ -1152,4 +1161,19 @@ mod tests {
let malformed = Tag::custom("previous", ["nothex"]);
assert_eq!(window.references_seen(Some(&malformed)), Some(false));
}
#[test]
fn is_newer_event_accepts_only_strictly_newer_events() {
let lower = EventId::from_byte_array([0x00; 32]);
let higher = EventId::from_byte_array([0xff; 32]);
let earlier = Timestamp::from(100);
let later = Timestamp::from(200);
assert!(is_newer_event(None, earlier, lower));
assert!(!is_newer_event(Some((earlier, lower)), earlier, lower));
assert!(is_newer_event(Some((earlier, lower)), later, lower));
assert!(!is_newer_event(Some((later, lower)), earlier, higher));
assert!(is_newer_event(Some((earlier, lower)), earlier, higher));
assert!(!is_newer_event(Some((earlier, higher)), earlier, lower));
}
}
+62 -7
View File
@@ -55,6 +55,9 @@ pub struct HistoryEntry {
pub created_at: Timestamp,
}
/// How many times a failed nutzap redemption is retried before giving up
const MAX_REDEEM_ATTEMPTS: u32 = 3;
pub struct WalletRegistry {
wallet: Option<keys::WalletData>,
tokens: HashMap<EventId, TokenEvent>,
@@ -69,6 +72,10 @@ pub struct WalletRegistry {
pending_nutzaps: Vec<Event>,
/// Nutzaps with a redemption job in flight
redeeming: HashSet<EventId>,
/// Redemption attempts per nutzap, reset on success
redeem_attempts: HashMap<EventId, u32>,
/// A job errored after possibly mutating the store
publish_failed: bool,
store: Option<Arc<WalletRedbDatabase>>,
wallets: HashMap<MintUrl, Wallet>,
loading: bool,
@@ -119,6 +126,8 @@ impl WalletRegistry {
redeemed: HashSet::new(),
pending_nutzaps: Vec::new(),
redeeming: HashSet::new(),
redeem_attempts: HashMap::new(),
publish_failed: false,
store: None,
wallets: HashMap::new(),
loading: false,
@@ -378,6 +387,8 @@ impl WalletRegistry {
self.redeemed.clear();
self.pending_nutzaps.clear();
self.redeeming.clear();
self.redeem_attempts.clear();
self.publish_failed = false;
self.store = None;
self.wallets.clear();
self.loading = false;
@@ -397,6 +408,7 @@ impl WalletRegistry {
let signer = nostr.read(cx).signer().clone();
let (tx, rx) = flume::bounded::<Event>(100);
let consumer_signer = signer.clone();
self.notification_listener = Some(cx.background_spawn(async move {
const MAX_PROCESSED: usize = 1000;
@@ -417,7 +429,7 @@ impl WalletRegistry {
| Kind::CashuWalletSpendingHistory
);
let wallet_event = ours && is_cashu || is_token_deletion(&event);
let wallet_event = ours && (is_cashu || is_token_deletion(&event));
let nutzap = event.kind == Kind::Custom(9321)
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
@@ -443,21 +455,22 @@ impl WalletRegistry {
}));
self.tasks.push(cx.spawn(async move |this, cx| {
let me = consumer_signer.get_public_key_async().await.ok();
while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| {
this.absorb(event, cx);
this.absorb(event, me, cx);
})
.ok();
}
}));
}
fn absorb(&mut self, event: Event, cx: &mut Context<Self>) {
fn absorb(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
match event.kind {
Kind::CashuWallet => self.absorb_wallet(event, cx),
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, me, cx),
Kind::Custom(9321) => self.absorb_nutzap(event, cx),
_ => {}
}
@@ -549,7 +562,15 @@ impl WalletRegistry {
}));
}
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) {
fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
if me != Some(event.pubkey) {
log::warn!(
"ignoring kind 5 deletion {} from a foreign author",
event.id
);
return;
}
let mut removed_any = false;
for tag in event.tags.iter() {
if tag.kind() != "e" {
@@ -807,6 +828,9 @@ impl WalletRegistry {
self.job_running = true;
let job_handle = job.clone();
let publish_failed = self.publish_failed;
self.tasks.push(cx.spawn(async move |this, cx| {
let result = Tokio::spawn_result(cx, async move {
let ctx = ops::JobCtx {
@@ -815,6 +839,7 @@ impl WalletRegistry {
signer,
client,
tokens,
publish_failed,
};
job.run(ctx).await
@@ -823,7 +848,7 @@ impl WalletRegistry {
this.update(cx, |this, cx| {
this.job_running = false;
this.apply_outcome(result, cx);
this.apply_outcome(job_handle, result, cx);
for job in std::mem::take(&mut this.job_queued) {
this.schedule_job(job, cx);
@@ -833,10 +858,38 @@ impl WalletRegistry {
}));
}
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) {
fn apply_outcome(
&mut self,
job: ops::Job,
result: Result<ops::Outcome, Error>,
cx: &mut Context<Self>,
) {
let outcome = match result {
Ok(outcome) => outcome,
Err(error) => {
if !matches!(job, ops::Job::CreateQuote { .. }) {
self.publish_failed = true;
}
if !matches!(job, ops::Job::CreateQuote { .. } | ops::Job::Reconcile) {
self.schedule_job(ops::Job::Reconcile, cx);
}
if let ops::Job::RedeemNutzap { nutzap_id, .. } = &job {
self.redeeming.remove(nutzap_id);
let attempts = self.redeem_attempts.entry(*nutzap_id).or_default();
*attempts += 1;
if *attempts <= MAX_REDEEM_ATTEMPTS {
self.job_queued.push(job.clone());
} else {
log::warn!(
"giving up redeeming nutzap {nutzap_id} after {MAX_REDEEM_ATTEMPTS} attempts"
);
}
}
self.emit_error(format!("{error:#}"), cx);
return;
}
@@ -845,6 +898,7 @@ impl WalletRegistry {
match outcome {
ops::Outcome::Idle => {}
ops::Outcome::Reconciled { updates } => {
self.publish_failed = false;
let changed = !updates.is_empty();
for update in updates {
@@ -905,6 +959,7 @@ impl WalletRegistry {
amount,
} => {
self.redeeming.remove(&nutzap_id);
self.redeem_attempts.remove(&nutzap_id);
self.redeemed.insert(nutzap_id);
self.apply_token_update(update);
self.emit_updated(cx);
+117 -27
View File
@@ -361,6 +361,8 @@ pub(crate) struct JobCtx<S> {
pub(crate) client: Client,
/// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>,
/// A previous job errored after possibly mutating the store
pub(crate) publish_failed: bool,
}
impl<S> JobCtx<S>
@@ -426,11 +428,71 @@ where
log::warn!("saga recovery failed for {mint_url}: {error:#}");
}
let minted = wallet.mint_unissued_quotes().await?;
if minted == Amount::ZERO {
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
let states = vec![State::Pending, State::Reserved, State::PendingSpent];
let in_flight = wallet.get_proofs_by_states(states).await?;
let in_flight_ys = proof_ys(in_flight.iter())?;
let mut relay_ys = HashSet::new();
let mut unseen = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if !relay_ys.insert(info.y) {
continue;
}
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
continue;
}
unseen.push(info);
}
}
if unseen.is_empty() {
continue;
}
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
Ok(states) => states
.into_iter()
.filter(|state| state.state == State::Spent)
.map(|state| state.y)
.collect::<HashSet<_>>(),
Err(error) => {
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
HashSet::new()
}
};
let added: Vec<ProofInfo> = unseen
.into_iter()
.filter(|info| !spent.contains(&info.y))
.collect();
if !added.is_empty() {
wallet.localstore.update_proofs(added, Vec::new()).await?;
}
}
for (mint_url, wallet) in &self.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted != Amount::ZERO {
let update = self
.sync_token_events(
wallet,
@@ -452,39 +514,39 @@ where
updates.push(update);
}
for (mint_url, wallet) in &self.wallets {
let mut relay_ys = HashSet::new();
let mut added = Vec::new();
let relay_ys = relay_proof_ys(&tokens, mint_url);
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if relay_ys.insert(info.y) {
added.push(info);
}
}
}
if local_ys != relay_ys {
if self.publish_failed {
let update = self
.sync_token_events(
wallet,
nostr_mint(mint_url)?,
&previous_ids(&tokens, mint_url),
None,
)
.await?;
let unspent = wallet.get_unspent_proofs().await?;
let removed = unspent
.iter()
for id in &update.destroyed {
tokens.remove(id);
}
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
} else {
let removed = wallet
.get_unspent_proofs()
.await?
.into_iter()
.filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y),
Err(_) => false,
})
.map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(added, removed).await?;
wallet.localstore.update_proofs(Vec::new(), removed).await?;
}
}
}
Ok(Outcome::Reconciled { updates })
@@ -499,6 +561,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
.collect()
}
fn proof_ys<'a>(
proofs: impl Iterator<Item = &'a Proof>,
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
}
/// Proof ys referenced by the relay token events of one mint
fn relay_proof_ys(
tokens: &HashMap<EventId, TokenEvent>,
mint: &MintUrl,
) -> HashSet<cdk::nuts::PublicKey> {
let mut ys = HashSet::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint.to_string())
{
for proof in &token.proofs {
match proof_info(proof, mint) {
Ok(info) => {
ys.insert(info.y);
}
Err(error) => log::warn!("skipping proof: {error:#}"),
}
}
}
ys
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>,
+79 -18
View File
@@ -223,6 +223,11 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings {
/// Settings
inner: Entity<Settings>,
/// The pending save task, so writes to the settings file complete in order
#[cfg(not(target_arch = "wasm32"))]
save_task: Option<Task<()>>,
/// Set after a failed load so its own save cannot overwrite the file with defaults
suppress_next_save: bool,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>,
}
@@ -315,11 +320,16 @@ impl AppSettings {
// Run at the end of current cycle
cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok();
if let Err(error) = entity.update(cx, |this, cx| this.load(cx)) {
log::error!("failed to load settings: {error:#}");
}
});
Self {
inner,
#[cfg(not(target_arch = "wasm32"))]
save_task: None,
suppress_next_save: false,
_subscriptions: subscriptions,
}
}
@@ -381,27 +391,46 @@ impl AppSettings {
/// Load settings
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
let task: Task<Result<Option<Settings>, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))]
{
let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?);
match smol::fs::read_to_string(&path).await {
Ok(content) => {
let settings: Settings =
serde_json::from_str(&content).map_err(|error| {
anyhow!("failed to parse {}: {error}", path.display())
})?;
return Ok(Some(settings));
}
// No settings file yet on a fresh install, so keep the defaults
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow!("failed to read {}: {error}", path.display()));
}
}
}
#[cfg(target_arch = "wasm32")]
{
if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
return Ok(serde_json::from_str(&content)?);
let settings: Settings = serde_json::from_str(&content)
.map_err(|error| anyhow!("failed to parse saved settings: {error}"))?;
return Ok(Some(settings));
}
}
Err(anyhow!("Not found"))
Ok(None)
});
cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default());
let (mut settings, load_failed) = match task.await {
Ok(settings) => (settings.unwrap_or_default(), false),
Err(error) => {
log::error!("failed to load settings, keeping defaults: {error:#}");
(Settings::default(), true)
}
};
// Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
@@ -409,31 +438,63 @@ impl AppSettings {
}
// Update settings
this.update(cx, |this, cx| {
if let Err(error) = this.update(cx, |this, cx| {
// A failed load must not trigger the save that would overwrite the file
this.suppress_next_save = load_failed;
this.set_settings(settings, cx);
this.apply_theme(cx);
cx.refresh_windows();
})
.ok();
}) {
log::error!("failed to apply loaded settings: {error:#}");
}
})
.detach();
}
/// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) {
// Consume the failed-load suppression, so the next user change saves again
if self.suppress_next_save {
self.suppress_next_save = false;
return;
}
let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) {
let content = match serde_json::to_string(settings) {
Ok(content) => content,
Err(error) => {
log::error!("failed to serialize settings: {error:#}");
return;
}
};
#[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move {
{
let previous_task = self.save_task.take();
self.save_task = Some(cx.background_spawn(async move {
// Await the previous write, so saves to the file complete in order
if let Some(previous_task) = previous_task {
previous_task.await;
}
let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok();
})
.detach();
let temporary_path = config_dir().join(".settings.tmp");
let result: std::io::Result<()> = async {
smol::fs::write(&temporary_path, &content).await?;
smol::fs::rename(&temporary_path, &path).await
}
.await;
if let Err(error) = result {
log::error!("failed to save settings to {}: {error}", path.display());
}
}));
}
#[cfg(target_arch = "wasm32")]
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
log::warn!("failed to persist settings: {e}");
}
if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
log::error!("failed to persist settings: {error:#}");
}
}
+52 -31
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use anyhow::{Context as _, Result, anyhow};
@@ -30,7 +30,7 @@ impl SpeechRecognizer for ParakeetRecognizer {
cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir()));
send(Command::Warm(super::model::dir())).map_err(SpeechError::recognizer)?;
} else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes."
@@ -79,7 +79,11 @@ impl RecognitionSession for ParakeetSession {
}
let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
if let Err(error) = send(Command::Transcribe { samples, sender }) {
sink.error(SpeechError::recognizer(error), cx);
return;
}
self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await;
@@ -109,23 +113,50 @@ enum Command {
Unload,
}
/// A handle to the single inference worker.
fn worker() -> &'static flume::Sender<Command> {
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new();
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
fn worker_slot() -> &'static Mutex<Option<flume::Sender<Command>>> {
static WORKER: OnceLock<Mutex<Option<flume::Sender<Command>>>> = OnceLock::new();
WORKER.get_or_init(|| Mutex::new(None))
}
sender
})
/// A handle to the live worker, spawning a replacement after an idle exit.
fn worker() -> Result<flume::Sender<Command>> {
let mut slot = worker_slot()
.lock()
.unwrap_or_else(|error| error.into_inner());
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
*slot = None;
}
if let Some(sender) = slot.as_ref() {
return Ok(sender.clone());
}
let (sender, receiver) = flume::unbounded::<Command>();
match std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver))
{
Ok(_) => {
*slot = Some(sender.clone());
Ok(sender)
}
Err(error) => {
log::error!("failed to spawn the speech worker: {error}");
Err(anyhow!("Could not start the speech worker: {error}"))
}
}
}
/// Deliver a command, retrying once on a fresh worker if the previous one exited.
fn send(command: Command) -> Result<()> {
let command = match worker()?.send(command) {
Ok(()) => return Ok(()),
Err(error) => error.into_inner(),
};
// The worker can exit between the liveness check and the send, so retry once.
worker()?.send(command).context("the speech worker exited")
}
fn run_worker(receiver: flume::Receiver<Command>) {
@@ -192,21 +223,11 @@ fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) ->
.map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
}
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() {
worker().send_or_log(Command::Unload);
if let Err(error) = send(Command::Unload) {
log::warn!("failed to unload the speech model: {error}");
}
}
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
+1
View File
@@ -51,6 +51,7 @@ sqlite-wasm-vfs = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true
nostr-memory.workspace = true
smol.workspace = true
gpui_tokio.workspace = true
rustls = "0.23"
+4 -6
View File
@@ -403,7 +403,7 @@ pub async fn download_and_decrypt_file(
.clone()
.unwrap_or_else(|| fallback_name(&file.url));
let local = store_file(&name, &file.mime, &data).await?;
let local = store_file(&file.mime, &data).await?;
Ok(DownloadedFile {
local,
@@ -415,10 +415,11 @@ pub async fn download_and_decrypt_file(
}
/// Persist decrypted bytes somewhere the platform can render or open them
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
async fn store_file(mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
#[cfg(not(target_arch = "wasm32"))]
{
let digest = sha256_hex(name.as_bytes());
// Key the cache entry on the content hash, so same-named attachments can't collide
let digest = sha256_hex(data);
let extension = mime_guess::get_mime_extensions_str(mime)
.and_then(|extensions| extensions.first())
.copied()
@@ -428,7 +429,6 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
.join("coop-files")
.join(format!("{digest}.{extension}"));
if smol::fs::metadata(&path).await.is_err() {
let Some(parent) = path.parent() else {
bail!("Invalid file path");
};
@@ -438,14 +438,12 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
}
Ok(LocalFile::Path(path))
}
#[cfg(target_arch = "wasm32")]
{
let _ = name;
let url = crate::web_file::blob_url(mime, data)?;
Ok(LocalFile::Url(url))
}
+33 -8
View File
@@ -16,6 +16,8 @@ use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_memory::prelude::MemoryDatabase;
use nostr_sdk::prelude::*;
mod blossom;
@@ -114,15 +116,27 @@ impl NostrRegistry {
let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone());
// NostrLmdb::open schedules nothing back onto the GPUI executor, so blocking here can't deadlock
#[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move {
NostrLmdb::open(config_dir().join("nostr"))
.await
.expect("Failed to initialize database")
});
let (database, database_error) = match cx
.foreground_executor()
.block_on(NostrLmdb::open(config_dir().join("nostr")))
{
Ok(database) => (Arc::new(database) as Arc<dyn NostrDatabase>, None),
Err(e) => {
log::error!("Failed to open the local database, falling back to memory: {e}");
(
Arc::new(MemoryDatabase::unbounded()) as Arc<dyn NostrDatabase>,
Some(format!(
"Local database unavailable, using an in-memory database instead: {e}"
)),
)
}
};
#[cfg(target_arch = "wasm32")]
let database = web_database::take();
let (database, database_error) = (web_database::take(), None::<String>);
// Construct the nostr client
let client = ClientBuilder::default()
@@ -141,6 +155,10 @@ impl NostrRegistry {
// Connect to bootstrap relays once the registry has been returned to the app
cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| {
if let Some(error) = database_error {
cx.emit(StateEvent::error(error));
}
this.connect_bootstrap_relays(cx);
if let Some(secret) = cli_key {
@@ -445,8 +463,14 @@ impl NostrRegistry {
};
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = credentials::write(cx, "bunker", credential.as_bytes());
// The URI embeds the client key, so never persist it where web stores it unencrypted
#[cfg(not(target_arch = "wasm32"))]
let write =
credentials::write(cx, "bunker", with_master_key(&uri_string, &keys).as_bytes());
#[cfg(target_arch = "wasm32")]
let write = Task::ready(Ok::<(), Error>(()));
cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys)
@@ -709,6 +733,7 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
}
// Embed the master key in a bunker URI so reconnects reuse the same local key
#[cfg(not(target_arch = "wasm32"))]
fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible");
+33 -30
View File
@@ -29,26 +29,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
pub struct ContactListPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Npub input
input: Entity<InputState>,
/// Whether the panel is updating
updating: bool,
/// Error message
error: Option<SharedString>,
/// All contacts
contacts: HashSet<PublicKey>,
contacts: HashSet<Contact>,
/// Person directory for the contacts
directory: Entity<Directory>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -92,33 +83,50 @@ impl ContactListPanel {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let task: Task<Result<HashSet<Contact>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contact_list: HashSet<PublicKey> = client
let contact_list: HashSet<Contact> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.map(|event| {
event
.tags
.iter()
.filter_map(|tag| match Nip02Tag::try_from(tag) {
Ok(Nip02Tag::PublicKey {
public_key,
relay_hint,
alias,
}) => Some(Contact {
public_key,
relay_url: relay_hint,
alias,
}),
Err(_) => None,
})
.collect()
})
.unwrap_or_default();
Ok(contact_list)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_keys = task.await?;
let contacts = task.await?;
// Update state
this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(public_keys.iter().copied(), cx)
directory.track(contacts.iter().map(|contact| contact.public_key), cx)
});
this.contacts.extend(public_keys);
this.contacts.extend(contacts);
cx.notify();
})?;
@@ -130,7 +138,7 @@ impl ContactListPanel {
let value = self.input.read(cx).value().to_string();
if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) {
if self.contacts.insert(Contact::new(public_key)) {
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.input.update(cx, |this, cx| {
@@ -144,7 +152,8 @@ impl ContactListPanel {
}
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.contacts.remove(public_key);
self.contacts
.retain(|contact| contact.public_key != *public_key);
cx.notify();
}
@@ -184,11 +193,7 @@ impl ContactListPanel {
let signer = nostr.read(cx).signer();
// Get contacts
let contacts: Vec<Contact> = self
.contacts
.iter()
.map(|public_key| Contact::new(*public_key))
.collect();
let contacts: Vec<Contact> = self.contacts.iter().cloned().collect();
// Set updating state
self.set_updating(true, cx);
@@ -230,8 +235,9 @@ impl ContactListPanel {
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() {
let profile = self.directory.read(cx).person(public_key, cx);
for (ix, contact) in self.contacts.iter().enumerate() {
let public_key = contact.public_key;
let profile = self.directory.read(cx).person(&public_key, cx);
items.push(
h_flex()
@@ -262,12 +268,9 @@ impl ContactListPanel {
.ghost()
.invisible()
.group_hover("", |this| this.visible())
.on_click({
let public_key = public_key.to_owned();
cx.listener(move |this, _ev, _window, cx| {
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.remove(&public_key, cx);
})
}),
})),
),
)
}
+82
View File
@@ -2,6 +2,10 @@
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
> See §9 for the per-fix log.
## 1. Scope and Method
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
@@ -823,3 +827,81 @@ claims were verified against the vendored `gpui-pre 0.3.12`, `gpui-base 0.9.1`,
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
during the audit.*
## 9. P0 Remediation Log (2026-11-12)
All ten P0 items fixed. Validation after the full batch: `cargo clippy --workspace --all-targets`
zero warnings, `cargo fmt --all --check` clean, `cargo test --workspace` 35 passed / 0 failed.
Diff: 15 files, +816/−273.
1. **nip60 deletion spoofing** — `nip60/src/lib.rs`: precedence fixed to
`ours && (is_cashu || is_token_deletion(..))`; `absorb`/`absorb_deletion` now take `me` and
reject foreign-authored kind 5 events (defense in depth; the live notification gate was the
only protection before, and `absorb_deletion` had no author check). The DB load path was
already author-filtered and is unchanged.
2. **browser-signer-proxy hardening** — per-session UUID token generated at startup, embedded in
the served page (`inject_session_token`), required on `/api/*` (403 otherwise); exact `Host`
match (DNS-rebinding defense); all CORS headers and the OPTIONS preflight removed;
`cancel_request` removes timed-out requests from both maps so plaintext is dropped;
centralized best-effort `write_response`. `proxy.js` authenticates all three fetches. Token
never appears in the URL the app opens.
3. **state startup crash loop** — `NostrLmdb::open` failure now logs and falls back to
`MemoryDatabase::unbounded()` (nostr-memory added to the desktop deps), emitting
`StateEvent::error` via the existing deferred path; the `block_on` stays (safe: the LMDB open
schedules nothing back onto the GPUI executor — now commented).
4. **state web localStorage key** — `import_bunker` on wasm32 no longer persists the bunker
credential (session is memory-only; desktop keyring unchanged); `restore_session` degrades to
`NoSigner` when nothing is stored (verified). **Known behavior change: web users re-enter their
bunker URI after each reload.**
5. **state file-cache collision** — cache filename now derived from the decrypted content's
sha256 instead of the filename; the metadata guard is gone and every store goes through
temp-write + atomic rename.
6. **nip60 reconcile money-safety** — `ops.rs::reconcile` rewritten: relay tokens are converged
into the store BEFORE any publish (closes the `del`-before-converge race); relay proofs already
tracked locally (unspent or in-flight Pending/Reserved/PendingSpent) are never re-added, and
unseen proofs are checked against the mint (NUT-08 `check_proofs_spent`, best-effort) before
being accepted as Unspent; when a prior job errored after mutating the store
(`publish_failed` flag), reconcile republishes the store's unspent set instead of trusting
relay state — healing stranded sends/receives; without the flag the relay-trust removal path
(cross-device spend convergence) is preserved. A failing Reconcile does not reschedule itself
(no infinite loop).
7. **nip60 stuck nutzap redemptions** — `apply_outcome` now receives the failed job; on
`RedeemNutzap` error the id is removed from `redeeming` and requeued up to
`MAX_REDEEM_ATTEMPTS = 3` (cleared on success, reset on signer change), so transient mint
failures no longer wedge redemptions until restart.
8. **Reactions double-count** — `chat_ui::insert_reaction` replaces per (author, target) per
NIP-25, making the backup gift-wrap echo idempotent (verified: optimistic rumor and the
re-delivered seal carry the same event id) and history reloads non-duplicating.
9. **Whitespace collapse** — `common/media_extractor.rs` rewritten: whitespace is tidied only at
URL-removal boundaries; line breaks in untouched regions survive; regex hoisted to
`LazyLock`; 9 unit tests added (multi-line preservation, URL-only messages, digit/emoji edges).
10. **Contact-list data loss** — `contact_list.rs` now parses the stored event into full
`nostr::nips::nip02::Contact` values (relay hint + alias preserved through load → add/remove
→ republish); new contacts still get `Contact::new`.
11. **Speech worker lifetime** — `parakeet.rs`: the `OnceLock<Sender>` became a resettable
`Mutex<Option<Sender>>`; a dead worker (30 s idle exit) is detected via `is_disconnected`, the
slot cleared, and a replacement thread spawned lazily on the next command; spawn failure no
longer caches a dead sender; failures surface through `sink.error` / returned `Err` instead
of a debug-level swallow (one retry covers the liveness-check/send race).
12. **Settings durability** — a failed load logs and keeps defaults in memory without
persisting (`suppress_next_save`, consumed by the next real change); fresh-install NotFound is
no longer conflated with read/parse failures; saves are serialized through a single
`save_task` slot (chained, FIFO order) with temp-file + atomic rename; serialization and
write failures are logged.
13. **nip29 ordering guards** — shared `is_newer_event` (strict `(created_at, id)` total order):
`Group::absorb_state` keeps a per-kind stamp (written only after a successful apply, so a
malformed newer event can't poison the guard) and rejects stale replays while ascending cache
hydration still applies; the registry's `list_stamp` seeds from the cached list event at
startup, is checked in `merge` before applying, updated by `publish_list` (closing the
leave→resurrect window), and reset on signer change. 3 new tests across protocol/group.
14. **nip60_ui invoice flow** — *not* in this batch: it is P1 (the audit lists it as High but the
P0 list ranks it 16th via `nip60_ui/wallet.rs`); `mint_quotes()`/`claim_mint_quote()` remain
dead until the P1 UI work lands. No other P0 code paths depend on it.
Behavior changes to be aware of when testing:
- Web: bunker credentials are no longer persisted (re-connect after each reload).
- Desktop: a corrupted/unreadable local database now starts with an empty in-memory history and
shows an error event instead of crash-looping; relays re-sync as before.
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.