1
0
forked from GRIN/grim

goblin: audit fixes (money replay guard, honest encrypt prompt) + hardening

P2-1: a money-tier request id is reserved the moment its prompt is raised
(money_pending_ids); a replayed envelope (drain overlapping the live
subscription, or deliberate) returns AlreadyPending / the cached result and
can never raise a second prompt or re-sign. Applies to sign and escalated
encrypt. Regression test covers both replay-while-pending and replay-after-
completion.

P2-3: the money modal for a pay-committing encrypt now shows the inspected
plaintext (escaped + truncated exactly like the sign path), so the user sees
what they are agreeing to pay, not a blind label.

Hardening:
- payment marker set widened (amount, total, price, msat), TODO(audit) kept.
- identity dropped mid-session now answers identity_mismatch on the channel
  (refusal_json per op type) instead of silently timing out the site.
- session-end payload carries reason: revoked (wallet end) / expired (TTL or
  idle sweep, newly published by the tick) / logout (site's own).
- decrypt gets its own soft-cap window (30/min) and an honest 'reading your
  messages' notice, distinct from the signing-volume toast (new locale key
  in all six languages).
- money answers route by the session's site channel key, never the display
  domain.
- committed the auditor's env-gated NIP-44 v2 interop test against
  nostr-tools (GOBLIN_CT1 harness).
This commit is contained in:
2ro
2026-07-06 18:18:28 -04:00
parent 2f52df509e
commit fdbcbf0bdd
9 changed files with 378 additions and 78 deletions
+1
View File
@@ -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?"
+1
View File
@@ -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}?"
+1
View File
@@ -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} ?"
+1
View File
@@ -737,6 +737,7 @@ goblin:
sent: "Доверие %{domain} на эту сессию"
failed: "Не удалось связаться с %{domain}. Сессия не начата."
notice_volume: "Доверенный сайт подписывает очень много."
notice_decrypt: "Доверенный сайт читает ваши сообщения."
money:
title: "Подтвердить"
headline: "Подтвердить в %{domain}?"
+1
View File
@@ -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ı?"
+1
View File
@@ -737,6 +737,7 @@ goblin:
sent: "已在本次会话中信任 %{domain}"
failed: "无法连接 %{domain}。会话未启动。"
notice_volume: "某个受信任网站签署量过大。"
notice_decrypt: "某个受信任网站正在读取您的消息。"
money:
title: "确认"
headline: "在 %{domain} 中确认?"
+21 -11
View File
@@ -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;
+94 -37
View File
@@ -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<NostrService>, 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<NostrService>, client: &Client, event: &Event)
let mut publish: Option<Event> = None;
let mut money: Option<PendingMoney> = 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<NostrService>, 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::<SignRequest>(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::<session::EncryptRequest>(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::<SignRequest>(val) {
Ok(req) => (
session::serve(s, &req, &keys, now),
Some(session::ChannelOp::Sign(req)),
),
Err(_) => return,
},
Some("encrypt") => {
match serde_json::from_value::<session::EncryptRequest>(val) {
Ok(e) => (
session::serve_encrypt(s, &e, &keys, now),
Some(session::ChannelOp::Encrypt(e)),
),
Err(_) => return,
}
}
Some("decrypt") => {
match serde_json::from_value::<session::DecryptRequest>(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::<session::DecryptRequest>(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<NostrService>, 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<NostrService>, 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<NostrService>, 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<NostrService>, client: &Client) {
@@ -2171,9 +2225,12 @@ async fn serve_money_answers(svc: &Arc<NostrService>, client: &Client) {
let mut publish: Option<Event> = 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()
+257 -30
View File
@@ -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<String>,
/// Timestamps (unix seconds) of served silent signs, for the rate windows.
silent_times: VecDeque<u64>,
/// Timestamps of served decrypts, for the decrypt-specific soft notice
/// ("this site is reading your messages"), separate from the sign counter.
decrypt_times: VecDeque<u64>,
/// 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<String>,
}
/// 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<Event, String> {
/// 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<Event, String> {
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<String>,
/// 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();
}
}