diff --git a/locales/de.yml b/locales/de.yml index 8f59d4f..d2b72fc 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -737,6 +737,7 @@ goblin: sent: "%{domain} wird für diese Sitzung vertraut" failed: "%{domain} konnte nicht erreicht werden. Die Sitzung wurde nicht gestartet." notice_volume: "Eine vertrauenswürdige Seite signiert sehr viel." + notice_decrypt: "Eine vertrauenswürdige Seite liest deine Nachrichten." money: title: "Bestätigen" headline: "In %{domain} bestätigen?" diff --git a/locales/en.yml b/locales/en.yml index fabeeee..16f4508 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -737,6 +737,7 @@ goblin: sent: "Trusting %{domain} for this session" failed: "Could not reach %{domain}. The session was not started." notice_volume: "A trusted site is signing a lot." + notice_decrypt: "A trusted site is reading your messages." money: title: "Confirm" headline: "Confirm in %{domain}?" diff --git a/locales/fr.yml b/locales/fr.yml index 86b378f..46dbfbe 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -737,6 +737,7 @@ goblin: sent: "Confiance accordée à %{domain} pour cette session" failed: "Impossible de joindre %{domain}. La session n'a pas démarré." notice_volume: "Un site de confiance signe beaucoup." + notice_decrypt: "Un site de confiance lit vos messages." money: title: "Confirmer" headline: "Confirmer dans %{domain} ?" diff --git a/locales/ru.yml b/locales/ru.yml index acd9e9c..994f518 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -737,6 +737,7 @@ goblin: sent: "Доверие %{domain} на эту сессию" failed: "Не удалось связаться с %{domain}. Сессия не начата." notice_volume: "Доверенный сайт подписывает очень много." + notice_decrypt: "Доверенный сайт читает ваши сообщения." money: title: "Подтвердить" headline: "Подтвердить в %{domain}?" diff --git a/locales/tr.yml b/locales/tr.yml index 18d601f..82d330f 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -737,6 +737,7 @@ goblin: sent: "%{domain} bu oturum için güveniliyor" failed: "%{domain} sitesine ulaşılamadı. Oturum başlatılmadı." notice_volume: "Güvenilen bir site çok fazla imzalıyor." + notice_decrypt: "Güvenilen bir site mesajlarınızı okuyor." money: title: "Onayla" headline: "%{domain} içinde onaylansın mı?" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 5ea29d1..77835d9 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -737,6 +737,7 @@ goblin: sent: "已在本次会话中信任 %{domain}" failed: "无法连接 %{domain}。会话未启动。" notice_volume: "某个受信任网站签署量过大。" + notice_decrypt: "某个受信任网站正在读取您的消息。" money: title: "确认" headline: "在 %{domain} 中确认?" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 279bae8..8159f23 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -1098,16 +1098,15 @@ impl GoblinWalletView { Modal::close(); } } - // A session "signing a lot" notice surfaces as the quiet toast. - if wallet - .nostr_service() - .and_then(|s| s.take_session_notice()) - .is_some() - { - self.login_toast = Some(( - t!("goblin.trust.notice_volume").to_string(), - std::time::Instant::now(), - )); + // A session volume notice surfaces as the quiet toast, with honest wording + // per capability: heavy DM reading is called out as reading, not signing. + if let Some(kind) = wallet.nostr_service().and_then(|s| s.take_session_notice()) { + let text = if kind == "reading" { + t!("goblin.trust.notice_decrypt").to_string() + } else { + t!("goblin.trust.notice_volume").to_string() + }; + self.login_toast = Some((text, std::time::Instant::now())); } // Draw the trust and money modals. if Modal::opened() == Some(TRUST_MODAL) { @@ -7357,7 +7356,18 @@ impl GoblinWalletView { ) } } - ChannelOp::Encrypt(_) => t!("goblin.money.encrypt_desc").to_string(), + ChannelOp::Encrypt(e) => { + // Order DMs are where payment agreements live: show the inspected + // plaintext (escaped + truncated exactly like the sign path), so + // the user sees WHAT they are agreeing to pay, not a blind label. + let (preview, _) = crate::nostr::authuri::content_preview(&e.plaintext); + let preview = crate::nostr::authuri::escape_for_display(&preview); + if preview.trim().is_empty() { + t!("goblin.money.encrypt_desc").to_string() + } else { + format!("{}: {}", t!("goblin.money.encrypt_desc"), preview) + } + } }; let mut approve = false; let mut decline = false; diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 0358d2d..74811ab 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -290,7 +290,7 @@ impl NostrService { let mut sessions = self.sessions.write(); if let Some(s) = sessions.iter_mut().find(|s| s.domain == domain) { s.end(); - end_event = s.session_end_event(now).ok(); + end_event = s.session_end_event(now, "revoked").ok(); } sessions.retain(|s| s.domain != domain); } @@ -1543,6 +1543,9 @@ async fn run_service(svc: Arc, wallet: Wallet) { // Authorize Sessions (v2): when the session set changed, re-subscribe // the encrypted channel and publish `session-open` for new sessions; // then sign/decline any money-tier prompts the user answered. + if svc.has_sessions() { + sweep_expired_sessions(&svc, &client).await; + } if svc.sessions_dirty.swap(false, Ordering::SeqCst) { resubscribe_channel(&client, &svc).await; announce_new_sessions(&svc, &client).await; @@ -2068,6 +2071,7 @@ async fn handle_channel(svc: &Arc, client: &Client, event: &Event) let mut publish: Option = None; let mut money: Option = None; let mut notice = false; + let mut decrypt_notice = false; let mut ended = false; { let mut sessions = svc.sessions.write(); @@ -2099,44 +2103,63 @@ async fn handle_channel(svc: &Arc, client: &Client, event: &Event) .into_iter() .find(|h| h.keys.public_key() == s.identity_pubkey) .map(|h| h.keys); - let (served, op) = match (msg_type.as_deref(), &keys) { - (Some("sign"), Some(keys)) => match serde_json::from_value::(val) { - Ok(req) => ( - session::serve(s, &req, keys, now), - Some(session::ChannelOp::Sign(req)), - ), - Err(_) => return, - }, - (Some("encrypt"), Some(keys)) => { - match serde_json::from_value::(val) { - Ok(e) => ( - session::serve_encrypt(s, &e, keys, now), - Some(session::ChannelOp::Encrypt(e)), - ), - Err(_) => return, + match keys { + Some(keys) => { + let (served, op) = match msg_type.as_deref() { + Some("sign") => match serde_json::from_value::(val) { + Ok(req) => ( + session::serve(s, &req, &keys, now), + Some(session::ChannelOp::Sign(req)), + ), + Err(_) => return, + }, + Some("encrypt") => { + match serde_json::from_value::(val) { + Ok(e) => ( + session::serve_encrypt(s, &e, &keys, now), + Some(session::ChannelOp::Encrypt(e)), + ), + Err(_) => return, + } + } + Some("decrypt") => { + match serde_json::from_value::(val) { + Ok(d) => (session::serve_decrypt(s, &d, &keys, now), None), + Err(_) => return, + } + } + _ => return, + }; + notice = served.notify_high_volume; + decrypt_notice = served.notify_decrypt_volume; + if served.money_pending { + if let Some(op) = op { + money = Some(PendingMoney { + domain: s.domain.clone(), + site_session_pubkey: s.site_session_pubkey, + identity_pubkey: s.identity_pubkey, + op, + }); + } + } else if let Some(json) = served.response { + publish = s.wrap_channel_event(&json, now).ok(); } } - (Some("decrypt"), Some(keys)) => { - match serde_json::from_value::(val) { - Ok(d) => (session::serve_decrypt(s, &d, keys, now), None), - Err(_) => return, + None => { + // Identity no longer held mid-session: answer identity_mismatch + // so the site fails fast (re-login) instead of waiting out its + // request timeout. + if let (Some(op_type), Some(id)) = + (msg_type.as_deref(), val.get("id").and_then(|i| i.as_str())) + { + let json = session::refusal_json( + op_type, + id, + session::SignError::IdentityMismatch, + ); + publish = s.wrap_channel_event(&json, now).ok(); } } - // Identity no longer held: cannot act; drop silently (the session - // will end and the site re-grants). - _ => return, - }; - notice = served.notify_high_volume; - if served.money_pending { - if let Some(op) = op { - money = Some(PendingMoney { - domain: s.domain.clone(), - identity_pubkey: s.identity_pubkey, - op, - }); - } - } else if let Some(json) = served.response { - publish = s.wrap_channel_event(&json, now).ok(); } } } @@ -2144,7 +2167,10 @@ async fn handle_channel(svc: &Arc, client: &Client, event: &Event) svc.sessions.write().retain(|s| !s.ended); svc.sessions_dirty.store(true, Ordering::SeqCst); } - if notice { + // Distinct notices: heavy silent signing vs heavy DM reading (honest wording). + if decrypt_notice { + *svc.session_notice.write() = Some("reading".to_string()); + } else if notice { *svc.session_notice.write() = Some("signing".to_string()); } if let Some(p) = money { @@ -2156,6 +2182,34 @@ async fn handle_channel(svc: &Arc, client: &Client, event: &Event) } } +/// Prune sessions past their TTL or idle timeout, sending each site a courtesy +/// `session-end` with reason "expired" so it fails fast to its re-login state +/// instead of timing out request by request. Called from the loop tick. +async fn sweep_expired_sessions(svc: &Arc, client: &Client) { + let now = unix_time() as u64; + let mut end_events = Vec::new(); + { + let mut sessions = svc.sessions.write(); + for s in sessions.iter_mut() { + if !s.ended && s.is_expired(now) { + s.end(); + if let Ok(ev) = s.session_end_event(now, "expired") { + end_events.push(ev); + } + } + } + if end_events.is_empty() && sessions.iter().all(|s| !s.ended) { + return; + } + sessions.retain(|s| !s.ended); + } + svc.sessions_dirty.store(true, Ordering::SeqCst); + let urls = channel_relays(svc); + for ev in end_events { + let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await; + } +} + /// Drain the GUI's answers to money-tier prompts: sign (or decline) each and /// publish the `sign_result` on its session channel. Called from the loop tick. async fn serve_money_answers(svc: &Arc, client: &Client) { @@ -2171,9 +2225,12 @@ async fn serve_money_answers(svc: &Arc, client: &Client) { let mut publish: Option = None; { let mut sessions = svc.sessions.write(); + // Route by the session's CHANNEL key, never the display domain: two + // sessions with a lookalike domain string can never receive each + // other's approvals. if let Some(s) = sessions .iter_mut() - .find(|s| s.domain == pending.domain && !s.ended) + .find(|s| s.site_session_pubkey == pending.site_session_pubkey && !s.ended) { let keys = snapshot .iter() diff --git a/src/nostr/session.rs b/src/nostr/session.rs index 932c15e..ad179b3 100644 --- a/src/nostr/session.rs +++ b/src/nostr/session.rs @@ -73,6 +73,9 @@ pub const MAX_TAG_BYTES: usize = 8 * 1024; /// Hard: pause the session (stop serving silent, stay listed as paused). pub const RATE_SOFT_PER_MIN: usize = 60; pub const RATE_HARD_PER_5MIN: usize = 600; +/// Decrypt-specific soft cap: reading DMs is the sharpest session capability, +/// so its notice fires sooner and with honest wording ("reading your messages"). +pub const RATE_SOFT_DECRYPT_PER_MIN: usize = 30; /// Cap on the per-session replay-dedup ring. The skew window makes an /// evicted-then-replayed id already stale, so eviction cannot reopen a replay. @@ -151,13 +154,19 @@ pub fn content_commits_payment(content: &str) -> bool { } /// Keys whose presence in an order/message JSON marks a payment commitment. +/// Deliberately broad (amount/total/price catch generic order shapes): a false +/// positive costs one extra prompt, the bias the spec asks for. const PAYMENT_MARKER_KEYS: &[&str] = &[ "payment", "payment_request", "bolt11", "invoice", + "amount", "amount_sat", "amount_sats", + "msat", + "total", + "price", "payment_hash", "preimage", ]; @@ -429,12 +438,17 @@ pub struct SessionOpen { } /// The session-end envelope (either direction): the site's logout signal, or the -/// wallet announcing a unilateral end. Type only; nothing else is trusted. +/// wallet announcing a unilateral end. Only the type is trusted; `reason` is +/// display data the receiving side may show ("logout" from the site, "revoked" +/// or "expired" from the wallet). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionEnd { /// Always `"session-end"`. #[serde(rename = "type")] pub msg_type: String, + /// Why the session ended: "logout" | "revoked" | "expired". + #[serde(default)] + pub reason: String, } /// A sign response (wallet to site). On success `ok` is true and `event` carries @@ -651,6 +665,14 @@ pub struct Session { seen_order: VecDeque, /// Timestamps (unix seconds) of served silent signs, for the rate windows. silent_times: VecDeque, + /// Timestamps of served decrypts, for the decrypt-specific soft notice + /// ("this site is reading your messages"), separate from the sign counter. + decrypt_times: VecDeque, + /// Request ids whose money-tier prompt is currently awaiting the user. A + /// replayed envelope (normal when a drain overlaps the live subscription) + /// finds its id here and does NOT raise a second prompt or re-sign; the id + /// moves to `seen` when the prompt completes. + money_pending_ids: std::collections::HashSet, } /// The wallet's decision for a request, produced by [`Session::decide`] before @@ -663,6 +685,9 @@ pub enum Decision { Silent { notify_high_volume: bool }, /// Money tier: raise the per-action password prompt, never silent. MoneyPrompt, + /// This id's money prompt is already awaiting the user: do nothing (no second + /// prompt, no response; the original prompt's answer covers it). + AlreadyPending, /// Refuse and return this typed error on the channel. Refuse(SignError), /// A replayed id: return the cached response JSON verbatim. @@ -702,6 +727,8 @@ impl Session { seen: HashMap::new(), seen_order: VecDeque::new(), silent_times: VecDeque::new(), + decrypt_times: VecDeque::new(), + money_pending_ids: std::collections::HashSet::new(), } } @@ -735,6 +762,12 @@ impl Session { if let Some(cached) = self.seen.get(&req.id) { return Decision::Duplicate(cached.clone()); } + // A money prompt already awaiting the user for this id: never a second + // prompt (a re-delivered envelope is normal when a drain overlaps the + // live subscription). + if self.money_pending_ids.contains(&req.id) { + return Decision::AlreadyPending; + } // Envelope timestamp skew, checked before the inner event is examined. if abs_diff(req.ts, now) > CREATED_AT_SKEW_SECS { return Decision::Refuse(SignError::StaleRequest); @@ -767,7 +800,12 @@ impl Session { } // Tier classification runs before the silent path, from kind AND content. match classify(req.event.kind, &req.event.content) { - Tier::Money => Decision::MoneyPrompt, + Tier::Money => { + // Reserve the id the moment the prompt is raised, so a replay can + // never re-prompt or re-sign; released into `seen` on completion. + self.money_pending_ids.insert(req.id.clone()); + Decision::MoneyPrompt + } Tier::Low => { if !self.silent_kind_set.contains(&req.event.kind) { return Decision::Refuse(SignError::KindNotInSession); @@ -804,6 +842,9 @@ impl Session { if let Some(cached) = self.seen.get(id) { return Decision::Duplicate(cached.clone()); } + if self.money_pending_ids.contains(id) { + return Decision::AlreadyPending; + } if abs_diff(ts, now) > CREATED_AT_SKEW_SECS { return Decision::Refuse(SignError::StaleRequest); } @@ -814,6 +855,8 @@ impl Session { } let notify = self.count_last_minute(now) >= RATE_SOFT_PER_MIN; if escalate { + // Reserve the id so a replay never raises a second prompt. + self.money_pending_ids.insert(id.to_string()); Decision::MoneyPrompt } else { Decision::Silent { @@ -829,6 +872,9 @@ impl Session { /// signature); do NOT call it for a still-pending money prompt. pub fn remember(&mut self, id: &str, response_json: &str, counted_silent_sign: bool, now: u64) { self.last_used_at = now; + // A completed money prompt releases its pending reservation; replays of + // the id now hit the `seen` cache below instead. + self.money_pending_ids.remove(id); if counted_silent_sign { self.silent_times.push_back(now); self.trim_rate_window(now); @@ -845,6 +891,21 @@ impl Session { } } + /// Count one served decrypt toward the decrypt-specific window and report + /// whether the decrypt soft cap tripped (the "reading your messages" notice). + fn note_decrypt(&mut self, now: u64) -> bool { + let cutoff = now.saturating_sub(60); + while let Some(&front) = self.decrypt_times.front() { + if front < cutoff { + self.decrypt_times.pop_front(); + } else { + break; + } + } + self.decrypt_times.push_back(now); + self.decrypt_times.len() > RATE_SOFT_DECRYPT_PER_MIN + } + /// End the session unilaterally (wallet-side end, or on a `session-end` /// envelope). Immediate and final: any later request refuses. pub fn end(&mut self) { @@ -895,11 +956,13 @@ impl Session { } /// The `session-end` channel event: tells the site the wallet ended the - /// session (a courtesy on wallet-side revocation; the teardown is unilateral - /// regardless of delivery). - pub fn session_end_event(&self, now: u64) -> Result { + /// session, with why ("revoked" for a wallet-side end, "expired" for the + /// TTL/idle backstop). A courtesy the site displays; the teardown is + /// unilateral regardless of delivery. + pub fn session_end_event(&self, now: u64, reason: &str) -> Result { let end = SessionEnd { msg_type: "session-end".to_string(), + reason: reason.to_string(), }; let json = serde_json::to_string(&end).map_err(|e| e.to_string())?; self.wrap_channel_event(&json, now) @@ -984,8 +1047,12 @@ pub struct SessionSummary { /// modal; the GUI's answer routes back through [`complete_money`]. #[derive(Debug, Clone)] pub struct PendingMoney { - /// The trusted domain the request arrived on. + /// The trusted domain the request arrived on (display only). pub domain: String, + /// The site channel key of the session the request arrived on: the answer is + /// routed back by THIS key, never by the display domain, so two sessions with + /// a lookalike domain string can never receive each other's approvals. + pub site_session_pubkey: PublicKey, /// The signing identity for this session (looked up to act on approval). pub identity_pubkey: PublicKey, /// The operation to perform on approval (a sign, or a pay-committing encrypt). @@ -1005,13 +1072,17 @@ impl PendingMoney { /// The upshot of serving one decoded request against a session. The async loop /// acts on it and never touches classification, signing, or bookkeeping itself. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct Served { /// A `sign_result` JSON to publish back to the site on the channel, or `None` - /// when the request is a money-tier prompt still pending the user. + /// when the request is a money-tier prompt still pending the user (or a + /// replay of one). pub response: Option, /// True when the soft rate cap tripped: surface a single non-blocking notice. pub notify_high_volume: bool, + /// True when the decrypt soft cap tripped: surface the honest "reading your + /// messages" notice (distinct from the signing-volume wording). + pub notify_decrypt_volume: bool, /// True when this request needs the money-tier password prompt (the loop /// enqueues it for the GUI and publishes nothing yet). pub money_pending: bool, @@ -1025,25 +1096,19 @@ pub struct Served { /// keys, looked up by the loop from the wallet's in-memory snapshot. pub fn serve(session: &mut Session, req: &SignRequest, sign_keys: &Keys, now: u64) -> Served { match session.decide(req, now) { - Decision::Duplicate(json) => Served { - response: Some(json), - notify_high_volume: false, - money_pending: false, - }, + Decision::Duplicate(json) => served_response(json), + // A replayed money request whose prompt is still up: no second prompt, no + // response; the original prompt's answer covers this id. + Decision::AlreadyPending => Served::default(), Decision::Refuse(err) => { let json = serde_json::to_string(&SignResult::refused(&req.id, err)).unwrap_or_default(); session.remember(&req.id, &json, false, now); - Served { - response: Some(json), - notify_high_volume: false, - money_pending: false, - } + served_response(json) } Decision::MoneyPrompt => Served { - response: None, - notify_high_volume: false, money_pending: true, + ..Served::default() }, Decision::Silent { notify_high_volume } => { let result = match sign_session_event(sign_keys, &req.event, now) { @@ -1057,7 +1122,7 @@ pub fn serve(session: &mut Session, req: &SignRequest, sign_keys: &Keys, now: u6 Served { response: Some(json), notify_high_volume, - money_pending: false, + ..Served::default() } } } @@ -1147,15 +1212,15 @@ pub fn serve_encrypt(session: &mut Session, e: &EncryptRequest, keys: &Keys, now let escalate = content_commits_payment(&e.plaintext); match session.decide_crypto(&e.id, e.ts, now, escalate) { Decision::Duplicate(json) => served_response(json), + Decision::AlreadyPending => Served::default(), Decision::Refuse(err) => { let json = crypto_error("encrypt_result", &e.id, err); session.remember(&e.id, &json, false, now); served_response(json) } Decision::MoneyPrompt => Served { - response: None, - notify_high_volume: false, money_pending: true, + ..Served::default() }, Decision::Silent { notify_high_volume } => { let json = perform_encrypt(e, keys); @@ -1163,16 +1228,18 @@ pub fn serve_encrypt(session: &mut Session, e: &EncryptRequest, keys: &Keys, now Served { response: Some(json), notify_high_volume, - money_pending: false, + ..Served::default() } } } } -/// Serve a decrypt op (see [`serve_encrypt`]; decrypt never escalates). +/// Serve a decrypt op (see [`serve_encrypt`]; decrypt never escalates). Counts +/// toward its own soft window so heavy DM-reading surfaces the honest notice. pub fn serve_decrypt(session: &mut Session, d: &DecryptRequest, keys: &Keys, now: u64) -> Served { match session.decide_crypto(&d.id, d.ts, now, false) { Decision::Duplicate(json) => served_response(json), + Decision::AlreadyPending => Served::default(), Decision::Refuse(err) => { let json = crypto_error("decrypt_result", &d.id, err); session.remember(&d.id, &json, false, now); @@ -1182,13 +1249,14 @@ pub fn serve_decrypt(session: &mut Session, d: &DecryptRequest, keys: &Keys, now Decision::MoneyPrompt => { served_response(crypto_error("decrypt_result", &d.id, SignError::Refused)) } - Decision::Silent { notify_high_volume } => { + Decision::Silent { .. } => { let json = perform_decrypt(d, keys); session.remember(&d.id, &json, true, now); + let notify_decrypt = session.note_decrypt(now); Served { response: Some(json), - notify_high_volume, - money_pending: false, + notify_decrypt_volume: notify_decrypt, + ..Served::default() } } } @@ -1198,8 +1266,19 @@ pub fn serve_decrypt(session: &mut Session, d: &DecryptRequest, keys: &Keys, now fn served_response(json: String) -> Served { Served { response: Some(json), - notify_high_volume: false, - money_pending: false, + ..Served::default() + } +} + +/// The typed refusal JSON for any channel op, keyed by its request `type` (the +/// runtime uses this when it cannot even look up keys, e.g. the identity was +/// dropped mid-session, so the site fails fast instead of timing out). +pub fn refusal_json(op_type: &str, id: &str, err: SignError) -> String { + match op_type { + "encrypt" => crypto_error("encrypt_result", id, err), + "decrypt" => crypto_error("decrypt_result", id, err), + // "sign" and anything else answers in the sign_result shape. + _ => serde_json::to_string(&SignResult::refused(id, err)).unwrap_or_default(), } } @@ -1673,6 +1752,123 @@ mod tests { assert!(declined.contains("\"error\":\"user_declined\"")); } + #[test] + fn money_replay_cannot_reprompt_or_resign() { + let now = 1_751_800_000u64; + let (mut s, id) = mk_session(&[1], now); + let req = mk_req(&id, 17, "{}", now, "rp1"); + // First delivery raises the prompt. + let first = serve(&mut s, &req, &id, now); + assert!(first.money_pending); + // A replayed envelope while the prompt is still up (a drain overlapping + // the live subscription, or a deliberate replay): NO second prompt, no + // response, nothing signed. + let replay = serve(&mut s, &req, &id, now); + assert!( + !replay.money_pending, + "replay must not raise a second prompt" + ); + assert!( + replay.response.is_none(), + "replay publishes nothing while pending" + ); + assert_eq!(s.decide(&req, now), Decision::AlreadyPending); + // After completion, a replay returns the cached result verbatim, never a + // second signature. + let json = complete_money(&mut s, &ChannelOp::Sign(req.clone()), &id, true, now); + let after = serve(&mut s, &req, &id, now); + assert_eq!(after.response.as_deref(), Some(json.as_str())); + assert!(!after.money_pending); + // Same guarantee on the escalated-encrypt path. + let peer = Keys::generate(); + let e = EncryptRequest { + msg_type: "encrypt".to_string(), + id: "rp2".to_string(), + ts: now, + peer_pubkey: peer.public_key().to_hex(), + plaintext: r#"{"invoice":"lnbc1"}"#.to_string(), + }; + assert!(serve_encrypt(&mut s, &e, &id, now).money_pending); + let replay = serve_encrypt(&mut s, &e, &id, now); + assert!(!replay.money_pending && replay.response.is_none()); + let done = complete_money(&mut s, &ChannelOp::Encrypt(e.clone()), &id, false, now); + assert!(done.contains("\"error\":\"user_declined\"")); + let after = serve_encrypt(&mut s, &e, &id, now); + assert_eq!(after.response.as_deref(), Some(done.as_str())); + } + + #[test] + fn decrypt_soft_cap_fires_honest_notice() { + let now = 1_751_800_000u64; + let (mut s, id) = mk_session(&[13], now); + let peer = Keys::generate(); + let ct = + nip44::encrypt(peer.secret_key(), &id.public_key(), "m", nip44::Version::V2).unwrap(); + for i in 0..RATE_SOFT_DECRYPT_PER_MIN { + let d = DecryptRequest { + msg_type: "decrypt".to_string(), + id: format!("dn{i}"), + ts: now, + peer_pubkey: peer.public_key().to_hex(), + ciphertext: ct.clone(), + }; + let out = serve_decrypt(&mut s, &d, &id, now); + assert!( + !out.notify_decrypt_volume, + "under the cap: no notice (i={i})" + ); + } + // The next decrypt trips the decrypt-specific notice, not the sign one. + let d = DecryptRequest { + msg_type: "decrypt".to_string(), + id: "dn-over".to_string(), + ts: now, + peer_pubkey: peer.public_key().to_hex(), + ciphertext: ct, + }; + let out = serve_decrypt(&mut s, &d, &id, now); + assert!(out.notify_decrypt_volume); + assert!(!out.notify_high_volume); + } + + #[test] + fn session_end_carries_reason_and_refusal_json_shapes() { + let now = 1_751_800_000u64; + let (s, _id) = mk_session(&[1], now); + // The wallet's session-end payload carries the reason for the site to + // show. The conversation key is symmetric, so the wallet can open its own + // envelope to verify the plaintext that went on the wire. + let ev = s.session_end_event(now, "revoked").unwrap(); + let pt = open_envelope(&s.wallet_channel_sk, &s.site_session_pubkey, &ev.content).unwrap(); + assert!(pt.contains("\"reason\":\"revoked\"")); + let end = SessionEnd { + msg_type: "session-end".to_string(), + reason: "expired".to_string(), + }; + let json = serde_json::to_string(&end).unwrap(); + assert!(json.contains("\"reason\":\"expired\"")); + // Incoming site session-end without a reason still parses (serde default). + let parsed: SessionEnd = serde_json::from_str(r#"{"type":"session-end"}"#).unwrap(); + assert_eq!(parsed.reason, ""); + // refusal_json answers in the right result shape per op type. + assert!( + refusal_json("sign", "x", SignError::IdentityMismatch) + .contains("\"type\":\"sign_result\"") + ); + assert!( + refusal_json("encrypt", "x", SignError::IdentityMismatch) + .contains("\"type\":\"encrypt_result\"") + ); + assert!( + refusal_json("decrypt", "x", SignError::IdentityMismatch) + .contains("\"type\":\"decrypt_result\"") + ); + assert!( + refusal_json("sign", "x", SignError::IdentityMismatch) + .contains("\"error\":\"identity_mismatch\"") + ); + } + #[test] fn serve_refuses_kind_not_in_set() { let now = 1_751_800_000u64; @@ -1749,4 +1945,35 @@ mod tests { assert!(json.contains("\"type\":\"decrypt_result\"")); assert!(json.contains("\"plaintext\":\"secret\"")); } + + /// Cross-implementation NIP-44 v2 round trip against nostr-tools (magick's + /// browser side). Gated on the GOBLIN_CT1 env var so the normal suite skips it; + /// the interop harness sets it to a nostr-tools ciphertext (A->B), the wallet + /// (B) decrypts it, then encrypts a reply (A->B) to /tmp/ct2.txt for nostr-tools + /// to open. Proves the two NIP-44 v2 implementations actually interoperate. + #[test] + fn nip44_v2_interop_with_nostr_tools() { + let ct1 = match std::env::var("GOBLIN_CT1") { + Ok(v) => v, + Err(_) => return, + }; + let sk_a = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let sk_b = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") + .unwrap(); + let pk_a = Keys::new(sk_a.clone()).public_key(); + let pk_b = Keys::new(sk_b.clone()).public_key(); + let pt = nip44::decrypt(&sk_b, &pk_a, &ct1).expect("rust must decrypt nostr-tools ct"); + assert_eq!(pt, "hello from nostr-tools (magick browser side)"); + let ct2 = nip44::encrypt( + &sk_a, + &pk_b, + "hello from rust (goblin wallet side)", + nip44::Version::V2, + ) + .unwrap(); + std::fs::write("/tmp/ct2.txt", ct2).unwrap(); + } }