905b3ab94d
Pure move (no behavior change): convert the ~1,985-line session.rs file
into a session/ directory module along its existing banner-marked seams:
session/mod.rs locked constants, the Session object and its
enforcement (decide/serve bookkeeping, rate/size/TTL),
SessionSummary/PendingMoney, and the full test suite
session/classify.rs tier classification and the trust/grant taxonomy
(kind-to-tier table, content escalation, kind-set
sanitation, category display mapping)
session/sign.rs request/channel wire types, the client-pinned signer,
NIP-44 envelope shapes and the serving orchestration
Public API is unchanged: mod.rs re-exports classify and sign items
(pub use), so every crate::nostr::session path still resolves. The shared
abs_diff clock helper lives in the parent so both descendants reach it; no
other visibility changed (descendants already see parent-private items).
1255 lines
44 KiB
Rust
1255 lines
44 KiB
Rust
// Copyright 2026 The Goblin Developers
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
//! Authorize Sessions (v2): the two-tier session signer's PURE core.
|
|
//!
|
|
//! Everything a running GUI does not need lives here and is unit-tested without
|
|
//! one: the tier classifier (kind-to-tier table plus the content-escalation hook
|
|
//! for the flagged conversation kinds), the kind-to-category display mapping, the
|
|
//! kind-set sanitation that strips the wallet ceiling, the NIP-44 channel
|
|
//! envelope shapes, the client-pinned `created_at` signer with its skew guard,
|
|
//! and the per-session enforcement (identity binding, replay dedup, size caps,
|
|
//! rate limiting, lifetime). The relay subscription and the two modals that use
|
|
//! this core live in the GUI; this module never touches I/O beyond the crypto
|
|
//! helpers `nip44` already gives us.
|
|
//!
|
|
//! THE WALLET DECIDES THE TIER, from the event kind and its content, never from
|
|
//! anything the site asserts. A money-tier request is never signed silently.
|
|
|
|
use nostr_sdk::nips::nip44;
|
|
use nostr_sdk::{Event, EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag, Timestamp};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{HashMap, VecDeque};
|
|
|
|
use super::loginuri::LOGIN_EVENT_KIND;
|
|
|
|
mod classify;
|
|
mod sign;
|
|
|
|
pub use classify::*;
|
|
pub use sign::*;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Locked constants (the spec's section 12 recommendations, taken as decided).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The Goblin-native channel event kind: a stored, addressed, NIP-44-encrypted
|
|
/// envelope carrying a sign request or response, with a NIP-40 `expiration` tag.
|
|
/// Stored (not ephemeral) so a request sent while the wallet is backgrounded
|
|
/// waits on the relay until the wallet resumes and drains it.
|
|
pub const CHANNEL_EVENT_KIND: u16 = 24140;
|
|
|
|
/// Client `created_at` (and envelope `ts`) must be within this many seconds of
|
|
/// the wallet's own clock, matching what the relays and the magick server
|
|
/// enforce. Bounds a compromised site from pre/post-dating events.
|
|
pub const CREATED_AT_SKEW_SECS: u64 = 300;
|
|
|
|
/// Hard TTL backstop: a session cannot outlive this, even if neither the site
|
|
/// nor the user ends it (spec section 6, recommendation 12.2).
|
|
pub const SESSION_TTL_SECS: u64 = 12 * 3600;
|
|
|
|
/// Idle timeout: a session with no served request for this long ends (12.2).
|
|
pub const SESSION_IDLE_SECS: u64 = 30 * 60;
|
|
|
|
/// The NIP-40 `expiration` a channel request carries: short, so a queued request
|
|
/// the wallet never drains lapses rather than lingering.
|
|
pub const REQUEST_EXPIRATION_SECS: u64 = 120;
|
|
|
|
/// Envelope plaintext cap: generous enough for a 1059 gift wrap, small enough to
|
|
/// bound abuse (spec section 5.8).
|
|
pub const MAX_ENVELOPE_BYTES: usize = 128 * 1024;
|
|
/// `event.content` cap.
|
|
pub const MAX_CONTENT_BYTES: usize = 64 * 1024;
|
|
/// Tag-count and per-tag-byte caps.
|
|
pub const MAX_TAGS: usize = 512;
|
|
pub const MAX_TAG_BYTES: usize = 8 * 1024;
|
|
|
|
/// Silent-path rate limits (12.4). Soft: surface a single notice, keep signing.
|
|
/// 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.
|
|
const SEEN_IDS_CAP: usize = 4096;
|
|
|
|
/// The money-tier kinds: never silent, always a per-action password prompt,
|
|
/// always stripped from a requested set. Kind 17 finalizes a purchase and grants
|
|
/// value; kind 30402 (a product listing) is a seller's commitment to a price
|
|
/// (owner ruling: listing and buying both require the password round-trip).
|
|
const MONEY_KINDS: &[u16] = &[17, 30402];
|
|
|
|
/// The flagged conversation kinds: low as messaging, but their content may
|
|
/// commit the user to a payment, so the classifier escalates such a request to
|
|
/// the money tier (spec sections 5.5, 5.6, finding B).
|
|
const FLAGGED_CONVERSATION_KINDS: &[u16] = &[13, 14, 16, 1059];
|
|
|
|
/// `|a - b|` on unsigned clocks without overflow.
|
|
fn abs_diff(a: u64, b: u64) -> u64 {
|
|
a.abs_diff(b)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The session object and its enforcement.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A live signing session for one domain and one identity. Held in memory only
|
|
/// (restart equals end). The channel keypair, the site's channel key, and the
|
|
/// approved silent kind set are set once at grant time and never widened.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Session {
|
|
/// The trusted domain, exactly as approved. The channel's origin binding.
|
|
pub domain: String,
|
|
/// The chosen signing identity. Every silent sign uses this key and no other.
|
|
pub identity_pubkey: PublicKey,
|
|
/// A display label for the Trusted Sites list.
|
|
pub label: String,
|
|
/// The approved LOW-tier kinds. Ceiling already stripped (never 22242, never
|
|
/// a money kind).
|
|
pub silent_kind_set: Vec<u16>,
|
|
/// The wallet's ephemeral channel secret key for this session.
|
|
pub wallet_channel_sk: SecretKey,
|
|
/// The wallet's ephemeral channel public key (published in `session-open`).
|
|
pub wallet_channel_pk: PublicKey,
|
|
/// The site's ephemeral channel public key. The only key allowed to request.
|
|
pub site_session_pubkey: PublicKey,
|
|
/// The relay hint plus wallet fallbacks the channel runs on.
|
|
pub relays: Vec<String>,
|
|
/// Grant time (unix seconds).
|
|
pub created_at: u64,
|
|
/// Hard TTL backstop.
|
|
pub expires_at: u64,
|
|
/// Updated on every served request; drives the idle timeout.
|
|
pub last_used_at: u64,
|
|
/// True once the hard rate cap tripped: the silent path stops serving until
|
|
/// the user resumes or ends the session.
|
|
pub paused: bool,
|
|
/// Set when the session has ended (logout, wallet end, TTL, idle).
|
|
pub ended: bool,
|
|
/// True once the wallet's `session-open` envelope was CONFIRMED accepted by a
|
|
/// relay the site is listening on (the hint relay). Only then is the channel
|
|
/// live for the site; until then the loop keeps re-publishing.
|
|
pub announced: bool,
|
|
/// How many times the loop has attempted to publish this session's
|
|
/// `session-open`. Bounds the confirm-or-retry loop so a dead hint relay can
|
|
/// never spin the service for the whole session TTL.
|
|
pub announce_attempts: u32,
|
|
/// Replay-dedup ring: request id -> cached response JSON. A duplicate id
|
|
/// returns the cached result, never a second signature.
|
|
seen: HashMap<String, String>,
|
|
/// FIFO of seen ids for bounded eviction.
|
|
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
|
|
/// any signing. The runtime acts on it: sign silently, raise the money prompt,
|
|
/// send a refusal, or return the cached duplicate.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Decision {
|
|
/// Low tier, in the set, all checks pass: sign silently. `notify_high_volume`
|
|
/// is set when the soft rate cap tripped (surface a notice, still sign).
|
|
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.
|
|
Duplicate(String),
|
|
}
|
|
|
|
impl Session {
|
|
/// Create a session at grant time. `requested` is the site's RAW kind set;
|
|
/// the ceiling is stripped here so `silent_kind_set` can never hold 22242 or
|
|
/// a money kind.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
domain: String,
|
|
identity_pubkey: PublicKey,
|
|
label: String,
|
|
requested: &[u16],
|
|
wallet_channel: &Keys,
|
|
site_session_pubkey: PublicKey,
|
|
relays: Vec<String>,
|
|
now: u64,
|
|
) -> Self {
|
|
Session {
|
|
domain,
|
|
identity_pubkey,
|
|
label,
|
|
silent_kind_set: sanitize_kind_set(requested),
|
|
wallet_channel_sk: wallet_channel.secret_key().clone(),
|
|
wallet_channel_pk: wallet_channel.public_key(),
|
|
site_session_pubkey,
|
|
relays,
|
|
created_at: now,
|
|
expires_at: now + SESSION_TTL_SECS,
|
|
last_used_at: now,
|
|
paused: false,
|
|
ended: false,
|
|
announced: false,
|
|
announce_attempts: 0,
|
|
seen: HashMap::new(),
|
|
seen_order: VecDeque::new(),
|
|
silent_times: VecDeque::new(),
|
|
decrypt_times: VecDeque::new(),
|
|
money_pending_ids: std::collections::HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// True when the TTL or idle timeout has lapsed as of `now`.
|
|
pub fn is_expired(&self, now: u64) -> bool {
|
|
now >= self.expires_at || now.saturating_sub(self.last_used_at) >= SESSION_IDLE_SECS
|
|
}
|
|
|
|
/// Seconds until the hard TTL, for the session-detail screen (0 once past).
|
|
pub fn ttl_remaining(&self, now: u64) -> u64 {
|
|
self.expires_at.saturating_sub(now)
|
|
}
|
|
|
|
/// Classify a decoded request against this live session, mutating only the
|
|
/// rate window and lifetime flags. Every rule fails closed. Does NOT record
|
|
/// the request as seen or bump the served counter: the runtime calls
|
|
/// [`Session::remember`] once it has actually produced a response, so a
|
|
/// refused or still-pending request never consumes replay/rate budget.
|
|
pub fn decide(&mut self, req: &SignRequest, now: u64) -> Decision {
|
|
if self.ended {
|
|
return Decision::Refuse(SignError::SessionEnded);
|
|
}
|
|
if self.is_expired(now) {
|
|
self.ended = true;
|
|
return Decision::Refuse(SignError::SessionEnded);
|
|
}
|
|
if self.paused {
|
|
return Decision::Refuse(SignError::SessionPaused);
|
|
}
|
|
// Replay: a duplicate id returns the cached prior result, never a re-sign.
|
|
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);
|
|
}
|
|
// Size caps.
|
|
if let Err(e) = self.check_size(req) {
|
|
return Decision::Refuse(e);
|
|
}
|
|
// Identity binding.
|
|
match PublicKey::from_hex(&req.event.pubkey) {
|
|
Ok(pk) if pk == self.identity_pubkey => {}
|
|
Ok(_) => return Decision::Refuse(SignError::IdentityMismatch),
|
|
Err(_) => return Decision::Refuse(SignError::Malformed),
|
|
}
|
|
// Inner-event skew.
|
|
if abs_diff(req.event.created_at, now) > CREATED_AT_SKEW_SECS {
|
|
return Decision::Refuse(SignError::StaleRequest);
|
|
}
|
|
// Outright refusals: never signed by the session path, not even money.
|
|
if req.event.kind == LOGIN_EVENT_KIND {
|
|
return Decision::Refuse(SignError::Refused);
|
|
}
|
|
if req
|
|
.event
|
|
.tags
|
|
.iter()
|
|
.any(|t| t.first().map(|k| k == "delegation").unwrap_or(false))
|
|
{
|
|
return Decision::Refuse(SignError::Refused);
|
|
}
|
|
// Tier classification runs before the silent path, from kind AND content.
|
|
match classify(req.event.kind, &req.event.content) {
|
|
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);
|
|
}
|
|
// Rate limiting on the silent path only.
|
|
self.trim_rate_window(now);
|
|
if self.silent_times.len() >= RATE_HARD_PER_5MIN {
|
|
self.paused = true;
|
|
return Decision::Refuse(SignError::SessionPaused);
|
|
}
|
|
let notify = self.count_last_minute(now) >= RATE_SOFT_PER_MIN;
|
|
Decision::Silent {
|
|
notify_high_volume: notify,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The session-level decision for an encrypt/decrypt op (no kind/tier by
|
|
/// event, since there is no event): the same lifetime, pause, replay, skew,
|
|
/// and rate gates as [`Session::decide`], then `MoneyPrompt` when `escalate`
|
|
/// (a pay-committing encrypt plaintext) else `Silent`.
|
|
pub fn decide_crypto(&mut self, id: &str, ts: u64, now: u64, escalate: bool) -> Decision {
|
|
if self.ended {
|
|
return Decision::Refuse(SignError::SessionEnded);
|
|
}
|
|
if self.is_expired(now) {
|
|
self.ended = true;
|
|
return Decision::Refuse(SignError::SessionEnded);
|
|
}
|
|
if self.paused {
|
|
return Decision::Refuse(SignError::SessionPaused);
|
|
}
|
|
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);
|
|
}
|
|
self.trim_rate_window(now);
|
|
if self.silent_times.len() >= RATE_HARD_PER_5MIN {
|
|
self.paused = true;
|
|
return Decision::Refuse(SignError::SessionPaused);
|
|
}
|
|
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 {
|
|
notify_high_volume: notify,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record a produced response for `id` (both tiers) so a replay returns it
|
|
/// verbatim, bump the idle clock, and — for a served silent sign — count it
|
|
/// toward the rate windows. Call this exactly once per request the wallet
|
|
/// answers (success OR typed refusal that should not be retried into a second
|
|
/// 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);
|
|
}
|
|
if self.seen.contains_key(id) {
|
|
return;
|
|
}
|
|
self.seen.insert(id.to_string(), response_json.to_string());
|
|
self.seen_order.push_back(id.to_string());
|
|
while self.seen_order.len() > SEEN_IDS_CAP {
|
|
if let Some(old) = self.seen_order.pop_front() {
|
|
self.seen.remove(&old);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
self.ended = true;
|
|
}
|
|
|
|
/// The wallet's ephemeral channel keypair (reconstructed from the stored
|
|
/// secret) for signing and decrypting channel envelopes.
|
|
fn channel_keys(&self) -> Keys {
|
|
Keys::new(self.wallet_channel_sk.clone())
|
|
}
|
|
|
|
/// Decrypt a channel envelope from the site (its outer event `pubkey` must be
|
|
/// `site_session_pubkey`, checked by the caller) into its plaintext payload.
|
|
pub fn decrypt(&self, sender: &PublicKey, payload: &str) -> Result<String, String> {
|
|
open_envelope(&self.wallet_channel_sk, sender, payload)
|
|
}
|
|
|
|
/// Wrap a plaintext payload as a signed, NIP-44-encrypted, addressed channel
|
|
/// event (kind [`CHANNEL_EVENT_KIND`]) carrying a NIP-40 `expiration` tag,
|
|
/// ready to publish back to the site.
|
|
pub fn wrap_channel_event(&self, plaintext: &str, now: u64) -> Result<Event, String> {
|
|
let content = seal_envelope(
|
|
&self.wallet_channel_sk,
|
|
&self.site_session_pubkey,
|
|
plaintext,
|
|
)?;
|
|
let exp = Timestamp::from(now + REQUEST_EXPIRATION_SECS);
|
|
EventBuilder::new(Kind::from(CHANNEL_EVENT_KIND), content)
|
|
.tags(vec![
|
|
Tag::public_key(self.site_session_pubkey),
|
|
Tag::expiration(exp),
|
|
])
|
|
.sign_with_keys(&self.channel_keys())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// The `session-open` channel event: hands the site the wallet channel key
|
|
/// (the event's own `pubkey`) and confirms the signing identity.
|
|
pub fn session_open_event(&self, now: u64) -> Result<Event, String> {
|
|
let open = SessionOpen {
|
|
msg_type: "session-open".to_string(),
|
|
id: self.wallet_channel_pk.to_hex(),
|
|
identity_pubkey: self.identity_pubkey.to_hex(),
|
|
};
|
|
let json = serde_json::to_string(&open).map_err(|e| e.to_string())?;
|
|
self.wrap_channel_event(&json, now)
|
|
}
|
|
|
|
/// The `session-end` channel event: tells the site the wallet ended the
|
|
/// 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)
|
|
}
|
|
|
|
/// A read-only snapshot for the Trusted Sites list.
|
|
pub fn summary(&self, now: u64) -> SessionSummary {
|
|
SessionSummary {
|
|
domain: self.domain.clone(),
|
|
label: self.label.clone(),
|
|
categories: render_grant(&self.silent_kind_set).categories,
|
|
ttl_remaining_secs: self.ttl_remaining(now),
|
|
paused: self.paused,
|
|
}
|
|
}
|
|
|
|
/// Resume a paused session (the user tapped "resume"). Clears the pause and
|
|
/// the rate window so counting starts fresh.
|
|
pub fn resume(&mut self, now: u64) {
|
|
self.paused = false;
|
|
self.silent_times.clear();
|
|
self.last_used_at = now;
|
|
}
|
|
|
|
/// Size caps: envelope plaintext, content, tag count, per-tag bytes.
|
|
fn check_size(&self, req: &SignRequest) -> Result<(), SignError> {
|
|
if req.event.content.len() > MAX_CONTENT_BYTES {
|
|
return Err(SignError::TooLarge);
|
|
}
|
|
if req.event.tags.len() > MAX_TAGS {
|
|
return Err(SignError::TooLarge);
|
|
}
|
|
for tag in &req.event.tags {
|
|
let bytes: usize = tag.iter().map(|s| s.len()).sum();
|
|
if bytes > MAX_TAG_BYTES {
|
|
return Err(SignError::TooLarge);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Drop rate-window entries older than 5 minutes.
|
|
fn trim_rate_window(&mut self, now: u64) {
|
|
let cutoff = now.saturating_sub(5 * 60);
|
|
while let Some(&front) = self.silent_times.front() {
|
|
if front < cutoff {
|
|
self.silent_times.pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Count served silent signs in the last minute.
|
|
fn count_last_minute(&self, now: u64) -> usize {
|
|
let cutoff = now.saturating_sub(60);
|
|
self.silent_times.iter().filter(|&&t| t >= cutoff).count()
|
|
}
|
|
}
|
|
|
|
/// Total plaintext-envelope byte check, applied at the transport layer before
|
|
/// JSON parse (the whole decrypted plaintext, not just `content`).
|
|
pub fn envelope_within_cap(plaintext: &str) -> bool {
|
|
plaintext.len() <= MAX_ENVELOPE_BYTES
|
|
}
|
|
|
|
/// A read-only snapshot of a session for the Trusted Sites list.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SessionSummary {
|
|
pub domain: String,
|
|
pub label: String,
|
|
/// The low-tier categories this session can sign silently.
|
|
pub categories: Vec<TrustCategory>,
|
|
/// Seconds until the hard TTL (the session-detail "time remaining").
|
|
pub ttl_remaining_secs: u64,
|
|
/// True when the hard rate cap paused the silent path.
|
|
pub paused: bool,
|
|
}
|
|
|
|
/// A money-tier request awaiting the user's per-action password prompt. The
|
|
/// runtime hands one of these to the GUI, which raises the v1-style authorize
|
|
/// modal; the GUI's answer routes back through [`complete_money`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct PendingMoney {
|
|
/// 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).
|
|
pub op: ChannelOp,
|
|
}
|
|
|
|
impl PendingMoney {
|
|
/// The correlation id, used to route the user's answer.
|
|
pub fn id(&self) -> &str {
|
|
self.op.id()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn low_content() -> &'static str {
|
|
"gm"
|
|
}
|
|
|
|
#[test]
|
|
fn money_kind_always_money() {
|
|
assert_eq!(classify(17, ""), Tier::Money);
|
|
// Owner ruling: a product listing (30402) is a commitment, money tier.
|
|
assert_eq!(classify(30402, ""), Tier::Money);
|
|
assert!(is_money_kind(17));
|
|
assert!(is_money_kind(30402));
|
|
assert!(!is_money_kind(1));
|
|
assert!(!is_money_kind(30405));
|
|
}
|
|
|
|
#[test]
|
|
fn plain_low_kinds_are_low() {
|
|
for k in [
|
|
0u16, 1, 5, 7, 1111, 10000, 30000, 30003, 30078, 30405, 30406, 24242, 27235,
|
|
] {
|
|
assert_eq!(classify(k, low_content()), Tier::Low, "kind {k}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn flagged_conversation_low_by_default_money_on_payment_content() {
|
|
for k in [13u16, 14, 16, 1059] {
|
|
// Plain message: low.
|
|
assert_eq!(classify(k, "hello there"), Tier::Low, "kind {k} plain");
|
|
// Order JSON with a payment marker: escalates to money.
|
|
let paying = r#"{"type":"order","payment":{"bolt11":"lnbc1..."}}"#;
|
|
assert_eq!(classify(k, paying), Tier::Money, "kind {k} paying");
|
|
// A nested marker is caught too.
|
|
let nested = r#"{"order":{"items":[{"amount_sat":1000}]}}"#;
|
|
assert_eq!(classify(k, nested), Tier::Money, "kind {k} nested");
|
|
}
|
|
// A non-flagged kind is NOT escalated even with payment-looking content.
|
|
assert_eq!(
|
|
classify(1, r#"{"payment":{"bolt11":"x"}}"#),
|
|
Tier::Low,
|
|
"kind 1 is not a flagged conversation kind"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn content_marker_ignores_opaque_and_plain() {
|
|
assert!(!content_commits_payment("just some text"));
|
|
assert!(!content_commits_payment("")); // empty
|
|
assert!(!content_commits_payment("Agk7d9...base64ish-ciphertext")); // not JSON
|
|
assert!(content_commits_payment(r#"{"invoice":"lnbc1"}"#));
|
|
assert!(!content_commits_payment(r#"{"invoice":null}"#)); // null marker ignored
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_strips_login_and_money_and_dedups() {
|
|
let got = sanitize_kind_set(&[22242, 1, 17, 7, 1, 17, 22242, 1059]);
|
|
assert_eq!(got, vec![1, 7, 1059]);
|
|
// All-ceiling strips to empty.
|
|
assert!(sanitize_kind_set(&[22242, 17]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn category_mapping_matches_spec_table() {
|
|
assert_eq!(category_for_kind(1), Some(TrustCategory::Social));
|
|
assert_eq!(category_for_kind(7), Some(TrustCategory::Social));
|
|
assert_eq!(category_for_kind(1111), Some(TrustCategory::Social));
|
|
assert_eq!(category_for_kind(13), Some(TrustCategory::DirectMessages));
|
|
assert_eq!(category_for_kind(1059), Some(TrustCategory::DirectMessages));
|
|
// 30402 (listing) is money tier by owner ruling, not a low category.
|
|
assert_eq!(category_for_kind(30402), None);
|
|
assert_eq!(category_for_kind(30405), Some(TrustCategory::Market));
|
|
assert_eq!(category_for_kind(31990), Some(TrustCategory::Market));
|
|
assert_eq!(category_for_kind(0), Some(TrustCategory::Identity));
|
|
assert_eq!(category_for_kind(30078), Some(TrustCategory::Identity));
|
|
assert_eq!(category_for_kind(5), Some(TrustCategory::Delete));
|
|
assert_eq!(category_for_kind(24242), Some(TrustCategory::Http));
|
|
assert_eq!(category_for_kind(27235), Some(TrustCategory::Http));
|
|
// Kind 17 (money) is not a display category.
|
|
assert_eq!(category_for_kind(17), None);
|
|
assert_eq!(category_for_kind(60000), None);
|
|
}
|
|
|
|
#[test]
|
|
fn render_grant_dedups_orders_and_flags_ceiling() {
|
|
let d = render_grant(&[1, 7, 1059, 30405, 30402, 22242, 17, 55555]);
|
|
assert_eq!(
|
|
d.categories,
|
|
vec![
|
|
TrustCategory::Social,
|
|
TrustCategory::DirectMessages,
|
|
TrustCategory::Market,
|
|
]
|
|
);
|
|
assert_eq!(d.unknown_kinds, vec![55555]);
|
|
assert!(d.stripped_login);
|
|
// 30402 is money now: stripped, never an unknown/caution line.
|
|
assert!(!d.unknown_kinds.contains(&30402));
|
|
// Money kind 17 appears nowhere: not a category, not an unknown line.
|
|
assert!(!d.unknown_kinds.contains(&17));
|
|
}
|
|
|
|
#[test]
|
|
fn sign_pins_created_at_and_matches_ndk_id() {
|
|
let keys = Keys::generate();
|
|
let now = 1_751_800_000u64;
|
|
let pinned = now - 42; // client-composed time, inside skew
|
|
let ev = RequestEvent {
|
|
pubkey: keys.public_key().to_hex(),
|
|
created_at: pinned,
|
|
kind: 7,
|
|
tags: vec![
|
|
vec!["e".to_string(), "abc".to_string()],
|
|
vec!["p".to_string(), "def".to_string()],
|
|
],
|
|
content: "+".to_string(),
|
|
};
|
|
let signed = sign_session_event(&keys, &ev, now).expect("sign");
|
|
assert_eq!(
|
|
signed.created_at.as_secs(),
|
|
pinned,
|
|
"created_at must be pinned"
|
|
);
|
|
assert_eq!(signed.pubkey, keys.public_key());
|
|
assert_eq!(signed.kind.as_u16(), 7);
|
|
assert_eq!(signed.content, "+");
|
|
assert!(signed.verify().is_ok());
|
|
// Recomputing NDK-style over the same fields yields the same id: build a
|
|
// second event with the same pinned time and compare ids.
|
|
let again = sign_session_event(&keys, &ev, now).expect("sign again");
|
|
assert_eq!(
|
|
signed.id, again.id,
|
|
"id is a pure function of pinned fields"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sign_rejects_wrong_identity_login_and_delegation() {
|
|
let keys = Keys::generate();
|
|
let other = Keys::generate();
|
|
let now = 1_751_800_000u64;
|
|
let base = RequestEvent {
|
|
pubkey: keys.public_key().to_hex(),
|
|
created_at: now,
|
|
kind: 1,
|
|
tags: vec![],
|
|
content: "x".to_string(),
|
|
};
|
|
// Wrong pubkey.
|
|
let mut wrong = base.clone();
|
|
wrong.pubkey = other.public_key().to_hex();
|
|
assert_eq!(
|
|
sign_session_event(&keys, &wrong, now),
|
|
Err(SignError::IdentityMismatch)
|
|
);
|
|
// Login kind refused outright.
|
|
let mut login = base.clone();
|
|
login.kind = LOGIN_EVENT_KIND;
|
|
assert_eq!(
|
|
sign_session_event(&keys, &login, now),
|
|
Err(SignError::Refused)
|
|
);
|
|
// Delegation tag refused outright.
|
|
let mut deleg = base.clone();
|
|
deleg.tags = vec![vec!["delegation".to_string(), "x".to_string()]];
|
|
assert_eq!(
|
|
sign_session_event(&keys, &deleg, now),
|
|
Err(SignError::Refused)
|
|
);
|
|
// Out-of-skew created_at.
|
|
let mut stale = base.clone();
|
|
stale.created_at = now + CREATED_AT_SKEW_SECS + 5;
|
|
assert_eq!(
|
|
sign_session_event(&keys, &stale, now),
|
|
Err(SignError::StaleRequest)
|
|
);
|
|
}
|
|
|
|
fn mk_session(kinds: &[u16], now: u64) -> (Session, Keys) {
|
|
let identity = Keys::generate();
|
|
let wallet_channel = Keys::generate();
|
|
let site = Keys::generate();
|
|
let s = Session::new(
|
|
"magick.market".to_string(),
|
|
identity.public_key(),
|
|
"magick.market".to_string(),
|
|
kinds,
|
|
&wallet_channel,
|
|
site.public_key(),
|
|
vec!["wss://relay.magick.market".to_string()],
|
|
now,
|
|
);
|
|
(s, identity)
|
|
}
|
|
|
|
fn mk_req(identity: &Keys, kind: u16, content: &str, now: u64, id: &str) -> SignRequest {
|
|
SignRequest {
|
|
msg_type: "sign".to_string(),
|
|
id: id.to_string(),
|
|
ts: now,
|
|
event: RequestEvent {
|
|
pubkey: identity.public_key().to_hex(),
|
|
created_at: now,
|
|
kind,
|
|
tags: vec![],
|
|
content: content.to_string(),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decide_silent_money_and_not_in_set() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[1, 7, 14], now);
|
|
// Low + in set -> silent.
|
|
let r = mk_req(&id, 7, "+", now, "a");
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Silent {
|
|
notify_high_volume: false
|
|
}
|
|
);
|
|
// Money kind -> prompt (even though the site never listed 17).
|
|
let r = mk_req(&id, 17, "{}", now, "b");
|
|
assert_eq!(s.decide(&r, now), Decision::MoneyPrompt);
|
|
// Flagged kind with paying content -> prompt, despite kind 14 being in set.
|
|
let r = mk_req(&id, 14, r#"{"payment":{"bolt11":"x"}}"#, now, "c");
|
|
assert_eq!(s.decide(&r, now), Decision::MoneyPrompt);
|
|
// Low kind NOT in set -> refuse (not prompt).
|
|
let r = mk_req(&id, 5, "", now, "d");
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Refuse(SignError::KindNotInSession)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decide_identity_skew_login_delegation() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[1], now);
|
|
// Wrong identity.
|
|
let mut r = mk_req(&id, 1, "x", now, "a");
|
|
r.event.pubkey = Keys::generate().public_key().to_hex();
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Refuse(SignError::IdentityMismatch)
|
|
);
|
|
// Envelope ts stale.
|
|
let r = mk_req(&id, 1, "x", now, "b");
|
|
let mut stale = r.clone();
|
|
stale.ts = now + 10_000;
|
|
assert_eq!(
|
|
s.decide(&stale, now),
|
|
Decision::Refuse(SignError::StaleRequest)
|
|
);
|
|
// Login kind.
|
|
let r = mk_req(&id, LOGIN_EVENT_KIND, "", now, "c");
|
|
assert_eq!(s.decide(&r, now), Decision::Refuse(SignError::Refused));
|
|
// Delegation tag.
|
|
let mut r = mk_req(&id, 1, "x", now, "e");
|
|
r.event.tags = vec![vec!["delegation".to_string(), "z".to_string()]];
|
|
assert_eq!(s.decide(&r, now), Decision::Refuse(SignError::Refused));
|
|
}
|
|
|
|
#[test]
|
|
fn replay_returns_cached_result() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[7], now);
|
|
let r = mk_req(&id, 7, "+", now, "dup");
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Silent {
|
|
notify_high_volume: false
|
|
}
|
|
);
|
|
// The runtime signs and remembers the response.
|
|
s.remember(
|
|
"dup",
|
|
r#"{"type":"sign_result","id":"dup","ok":true}"#,
|
|
true,
|
|
now,
|
|
);
|
|
// A second decide with the same id returns the cached JSON.
|
|
match s.decide(&r, now) {
|
|
Decision::Duplicate(json) => assert!(json.contains("\"id\":\"dup\"")),
|
|
other => panic!("expected Duplicate, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rate_soft_then_hard_pause() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[7], now);
|
|
// Serve up to the soft cap: no notify until the window hits the soft cap.
|
|
for i in 0..RATE_SOFT_PER_MIN {
|
|
let r = mk_req(&id, 7, "+", now, &format!("s{i}"));
|
|
let d = s.decide(&r, now);
|
|
assert_eq!(
|
|
d,
|
|
Decision::Silent {
|
|
notify_high_volume: false
|
|
}
|
|
);
|
|
s.remember(&format!("s{i}"), "{}", true, now);
|
|
}
|
|
// The next one trips the soft cap notice.
|
|
let r = mk_req(&id, 7, "+", now, "soft");
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Silent {
|
|
notify_high_volume: true
|
|
}
|
|
);
|
|
s.remember("soft", "{}", true, now);
|
|
// Fill to the hard cap.
|
|
for i in 0..(RATE_HARD_PER_5MIN - RATE_SOFT_PER_MIN - 1) {
|
|
s.remember(&format!("h{i}"), "{}", true, now);
|
|
}
|
|
// Now at the hard cap: the next decide pauses the session.
|
|
let r = mk_req(&id, 7, "+", now, "hard");
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Refuse(SignError::SessionPaused)
|
|
);
|
|
assert!(s.paused);
|
|
// Resume clears it.
|
|
s.resume(now);
|
|
let r = mk_req(&id, 7, "+", now, "after");
|
|
assert_eq!(
|
|
s.decide(&r, now),
|
|
Decision::Silent {
|
|
notify_high_volume: false
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn size_caps_refuse() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[1], now);
|
|
let mut r = mk_req(&id, 1, "x", now, "big");
|
|
r.event.content = "a".repeat(MAX_CONTENT_BYTES + 1);
|
|
assert_eq!(s.decide(&r, now), Decision::Refuse(SignError::TooLarge));
|
|
assert!(!envelope_within_cap(&"x".repeat(MAX_ENVELOPE_BYTES + 1)));
|
|
assert!(envelope_within_cap("small"));
|
|
}
|
|
|
|
#[test]
|
|
fn lifetime_expiry_and_idle_and_end() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[7], now);
|
|
// Past the hard TTL.
|
|
let later = now + SESSION_TTL_SECS + 1;
|
|
let r = mk_req(&id, 7, "+", later, "x");
|
|
let mut expired = r.clone();
|
|
expired.ts = later;
|
|
expired.event.created_at = later;
|
|
assert_eq!(
|
|
s.decide(&expired, later),
|
|
Decision::Refuse(SignError::SessionEnded)
|
|
);
|
|
// A fresh session that goes idle also ends.
|
|
let (mut s2, id2) = mk_session(&[7], now);
|
|
let idle_at = now + SESSION_IDLE_SECS + 1;
|
|
let mut idle = mk_req(&id2, 7, "+", idle_at, "y");
|
|
idle.ts = idle_at;
|
|
idle.event.created_at = idle_at;
|
|
assert_eq!(
|
|
s2.decide(&idle, idle_at),
|
|
Decision::Refuse(SignError::SessionEnded)
|
|
);
|
|
// Wallet-side end refuses immediately.
|
|
let (mut s3, id3) = mk_session(&[7], now);
|
|
s3.end();
|
|
let r = mk_req(&id3, 7, "+", now, "z");
|
|
assert_eq!(
|
|
s3.decide(&r, now),
|
|
Decision::Refuse(SignError::SessionEnded)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn envelope_roundtrip_and_result_shapes() {
|
|
let wallet = Keys::generate();
|
|
let site = Keys::generate();
|
|
let plaintext = r#"{"type":"session-open","wallet_pubkey":"aa","identity":"bb"}"#;
|
|
let sealed = seal_envelope(wallet.secret_key(), &site.public_key(), plaintext).unwrap();
|
|
// The site opens it with (site sk, wallet pk).
|
|
let opened = open_envelope(site.secret_key(), &wallet.public_key(), &sealed).unwrap();
|
|
assert_eq!(opened, plaintext);
|
|
// SignResult ok/refused serialize with the exact wire fields.
|
|
let keys = Keys::generate();
|
|
let ev = sign_session_event(
|
|
&keys,
|
|
&RequestEvent {
|
|
pubkey: keys.public_key().to_hex(),
|
|
created_at: 1_751_800_000,
|
|
kind: 1,
|
|
tags: vec![],
|
|
content: "hi".to_string(),
|
|
},
|
|
1_751_800_000,
|
|
)
|
|
.unwrap();
|
|
let ok = serde_json::to_string(&SignResult::ok("u1", &ev)).unwrap();
|
|
assert!(ok.contains("\"type\":\"sign_result\""));
|
|
assert!(ok.contains("\"ok\":true"));
|
|
assert!(ok.contains("\"id\":\"u1\""));
|
|
let refused =
|
|
serde_json::to_string(&SignResult::refused("u2", SignError::KindNotInSession)).unwrap();
|
|
assert!(refused.contains("\"ok\":false"));
|
|
assert!(refused.contains("\"error\":\"kind_not_in_session\""));
|
|
assert!(!refused.contains("\"event\"")); // omitted on refusal
|
|
}
|
|
|
|
#[test]
|
|
fn request_envelope_deserializes_from_wire() {
|
|
let wire = r#"{
|
|
"type":"sign","id":"uuid-1","ts":1751800000,
|
|
"event":{"pubkey":"aa","created_at":1751800000,"kind":7,"tags":[["e","x"]],"content":"+"}
|
|
}"#;
|
|
let req: SignRequest = serde_json::from_str(wire).unwrap();
|
|
assert_eq!(req.msg_type, "sign");
|
|
assert_eq!(req.id, "uuid-1");
|
|
assert_eq!(req.event.kind, 7);
|
|
assert_eq!(req.event.tags, vec![vec!["e".to_string(), "x".to_string()]]);
|
|
}
|
|
|
|
#[test]
|
|
fn serve_silent_signs_and_publishes_result() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[7], now);
|
|
let req = mk_req(&id, 7, "+", now, "sv1");
|
|
let out = serve(&mut s, &req, &id, now);
|
|
assert!(!out.money_pending);
|
|
let json = out.response.expect("silent sign yields a response");
|
|
assert!(json.contains("\"ok\":true"));
|
|
assert!(json.contains("\"id\":\"sv1\""));
|
|
// The signed event rides back in the result and verifies.
|
|
let parsed: SignResult = serde_json::from_str(&json).unwrap();
|
|
let ev: Event = serde_json::from_value(parsed.event.unwrap()).unwrap();
|
|
assert!(ev.verify().is_ok());
|
|
assert_eq!(ev.pubkey, id.public_key());
|
|
}
|
|
|
|
#[test]
|
|
fn serve_money_is_pending_then_completes_or_declines() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[1], now);
|
|
let req = mk_req(&id, 17, "{}", now, "mv1");
|
|
let out = serve(&mut s, &req, &id, now);
|
|
assert!(out.money_pending);
|
|
assert!(out.response.is_none(), "money pends: nothing published yet");
|
|
// User approves -> signed result.
|
|
let json = complete_money(&mut s, &ChannelOp::Sign(req.clone()), &id, true, now);
|
|
assert!(json.contains("\"ok\":true"));
|
|
// A replay of the same id now returns the cached signed result.
|
|
match s.decide(&req, now) {
|
|
Decision::Duplicate(cached) => assert!(cached.contains("\"ok\":true")),
|
|
other => panic!("expected Duplicate, got {other:?}"),
|
|
}
|
|
// A fresh money request the user declines -> user_declined.
|
|
let req2 = mk_req(&id, 17, "{}", now, "mv2");
|
|
let declined = complete_money(&mut s, &ChannelOp::Sign(req2), &id, false, now);
|
|
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;
|
|
let (mut s, id) = mk_session(&[7], now);
|
|
let req = mk_req(&id, 1, "hi", now, "rf1");
|
|
let out = serve(&mut s, &req, &id, now);
|
|
assert!(!out.money_pending);
|
|
assert!(
|
|
out.response
|
|
.unwrap()
|
|
.contains("\"error\":\"kind_not_in_session\"")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encrypt_roundtrips_and_escalates_on_payment() {
|
|
let now = 1_751_800_000u64;
|
|
let (mut s, id) = mk_session(&[13], now);
|
|
let peer = Keys::generate();
|
|
let e = EncryptRequest {
|
|
msg_type: "encrypt".to_string(),
|
|
id: "e1".to_string(),
|
|
ts: now,
|
|
peer_pubkey: peer.public_key().to_hex(),
|
|
plaintext: "hello".to_string(),
|
|
};
|
|
let out = serve_encrypt(&mut s, &e, &id, now);
|
|
assert!(!out.money_pending);
|
|
let json = out.response.unwrap();
|
|
assert!(json.contains("\"type\":\"encrypt_result\""));
|
|
assert!(json.contains("\"ok\":true"));
|
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
|
let ct = v["ciphertext"].as_str().unwrap();
|
|
let back = nip44::decrypt(peer.secret_key(), &id.public_key(), ct).unwrap();
|
|
assert_eq!(back, "hello");
|
|
// A pay-committing plaintext escalates to the money prompt at encrypt time.
|
|
let e2 = EncryptRequest {
|
|
msg_type: "encrypt".to_string(),
|
|
id: "e2".to_string(),
|
|
ts: now,
|
|
peer_pubkey: peer.public_key().to_hex(),
|
|
plaintext: r#"{"payment":{"bolt11":"lnbc1"}}"#.to_string(),
|
|
};
|
|
let out = serve_encrypt(&mut s, &e2, &id, now);
|
|
assert!(out.money_pending);
|
|
assert!(out.response.is_none());
|
|
let json = complete_money(&mut s, &ChannelOp::Encrypt(e2), &id, true, now);
|
|
assert!(json.contains("\"type\":\"encrypt_result\""));
|
|
assert!(json.contains("\"ok\":true"));
|
|
}
|
|
|
|
#[test]
|
|
fn decrypt_reads_a_peer_message_and_is_low_tier() {
|
|
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(),
|
|
"secret",
|
|
nip44::Version::V2,
|
|
)
|
|
.unwrap();
|
|
let d = DecryptRequest {
|
|
msg_type: "decrypt".to_string(),
|
|
id: "d1".to_string(),
|
|
ts: now,
|
|
peer_pubkey: peer.public_key().to_hex(),
|
|
ciphertext: ct,
|
|
};
|
|
let out = serve_decrypt(&mut s, &d, &id, now);
|
|
assert!(!out.money_pending);
|
|
let json = out.response.unwrap();
|
|
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();
|
|
}
|
|
}
|