From 939d1b18d13f255ae8e262df79ab6c87f4df8afd Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:58:57 -0400 Subject: [PATCH] goblin: Authorize Sessions pure core (trust URI parser, tier classifier, session enforcement) Adds the testable protocol core for the two-tier session signer: - trusturi.rs: goblin:trust URI parser, fail-closed like login/authorize, with sk (site channel key), r (relay hint), k (kind set) fields. - session.rs: wallet-owned tier classifier (kind table + content escalation for flagged conversation kinds), kind-set sanitation, the kind-to-category display mapping, NIP-44 channel envelope shapes, client-pinned created_at signer with skew guard, and per-session enforcement (identity binding, replay dedup, size caps, rate limiting, TTL/idle lifetime). 30 new unit tests; full lib suite green. --- src/nostr/mod.rs | 2 + src/nostr/session.rs | 1220 +++++++++++++++++++++++++++++++++++++++++ src/nostr/trusturi.rs | 523 ++++++++++++++++++ 3 files changed, 1745 insertions(+) create mode 100644 src/nostr/session.rs create mode 100644 src/nostr/trusturi.rs diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 8340bffd..e30d2ab1 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -51,3 +51,5 @@ pub mod nip05; pub mod authuri; pub mod loginuri; pub mod payuri; +pub mod session; +pub mod trusturi; diff --git a/src/nostr/session.rs b/src/nostr/session.rs new file mode 100644 index 00000000..1c36d366 --- /dev/null +++ b/src/nostr/session.rs @@ -0,0 +1,1220 @@ +// 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; + +// --------------------------------------------------------------------------- +// 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; + +/// 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; it is the archetypal money action. +const MONEY_KINDS: &[u16] = &[17]; + +/// 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]; + +// --------------------------------------------------------------------------- +// Tier classification (the security-critical surface). +// --------------------------------------------------------------------------- + +/// The risk tier of a request. LOW is silent under a grant; MONEY always raises +/// a per-action password prompt and is never covered by the silent grant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tier { + /// Signed silently when the kind is in the session's `silent_kind_set`. + Low, + /// Never silent: a value-moving or value-committing sign. + Money, +} + +/// True for a kind the wallet always treats as money tier, by kind alone. +pub fn is_money_kind(kind: u16) -> bool { + MONEY_KINDS.contains(&kind) +} + +/// The wallet's tier decision for a request, from the event kind AND its +/// content. Never trusts the site. Fail-safe: on any ambiguity about whether a +/// request commits value, this resolves to [`Tier::Money`] (prompt), never +/// [`Tier::Low`] (silent). +/// +/// - A money-tier kind ([`is_money_kind`]) is always [`Tier::Money`]. +/// - A flagged conversation kind (13, 14, 16, 1059) is [`Tier::Low`] as +/// messaging, but escalates to [`Tier::Money`] when its inspectable content +/// commits the user to a payment ([`content_commits_payment`]). +/// - Everything else follows the kind alone and is [`Tier::Low`]. +pub fn classify(kind: u16, content: &str) -> Tier { + if is_money_kind(kind) { + return Tier::Money; + } + if FLAGGED_CONVERSATION_KINDS.contains(&kind) && content_commits_payment(content) { + return Tier::Money; + } + Tier::Low +} + +/// The first-build content-escalation hook for the flagged conversation kinds. +/// +/// TODO(audit): the security pass owns the real detector (spec section 9b). This +/// hook parses INSPECTABLE plaintext content and escalates on a payment marker; +/// it cannot see inside opaque NIP-44 ciphertext (a sealed kind 13 or wrapped +/// kind 1059), so a genuine pay-commitment there surfaces instead as a separate +/// money-tier kind-17 sign, which always prompts. Escalation only (never a +/// downgrade), so a false positive costs at most one extra prompt, the bias the +/// spec asks for. +pub fn content_commits_payment(content: &str) -> bool { + let trimmed = content.trim(); + if trimmed.is_empty() { + return false; + } + match serde_json::from_str::(trimmed) { + Ok(v) => value_has_payment_marker(&v, 0), + // Not JSON we can read (plain text, or opaque ciphertext): no escalation + // here; a real commitment surfaces as a money-tier kind-17 sign. + Err(_) => false, + } +} + +/// Keys whose presence in an order/message JSON marks a payment commitment. +const PAYMENT_MARKER_KEYS: &[&str] = &[ + "payment", + "payment_request", + "bolt11", + "invoice", + "amount_sat", + "amount_sats", + "payment_hash", + "preimage", +]; + +/// Recursively (bounded depth) scan a JSON value for a payment marker. +fn value_has_payment_marker(v: &serde_json::Value, depth: usize) -> bool { + if depth > 6 { + return false; + } + match v { + serde_json::Value::Object(map) => { + for (k, val) in map { + let lk = k.to_ascii_lowercase(); + if PAYMENT_MARKER_KEYS.contains(&lk.as_str()) && !val.is_null() { + return true; + } + if value_has_payment_marker(val, depth + 1) { + return true; + } + } + false + } + serde_json::Value::Array(items) => { + items.iter().any(|x| value_has_payment_marker(x, depth + 1)) + } + _ => false, + } +} + +// --------------------------------------------------------------------------- +// Kind-set sanitation (strip the wallet ceiling) and category display. +// --------------------------------------------------------------------------- + +/// The `silent_kind_set` the wallet stores from a requested set: deduplicated, +/// first-seen order preserved, with the ceiling removed. The ceiling is kind +/// 22242 (login, never in any session set) and every money-tier kind (never +/// silent). Everything left may be signed silently once the tier classifier also +/// agrees it is low tier per request. +pub fn sanitize_kind_set(requested: &[u16]) -> Vec { + let mut out = Vec::new(); + for &k in requested { + if k == LOGIN_EVENT_KIND || is_money_kind(k) { + continue; + } + if !out.contains(&k) { + out.push(k); + } + } + out +} + +/// A human category the grant prompt renders instead of raw kind numbers. Each +/// carries a stable i18n key. Money-tier kinds never map here (they are covered +/// by the fixed "money will always ask" line); unrecognized low kinds fall +/// through to a per-kind caution line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustCategory { + /// Posts and reactions: 1, 6, 7, 1111. + Social, + /// Direct messages: 13, 14, 16, 1059. + DirectMessages, + /// Listings: 30402, 30405, 30406, 31990. + Market, + /// Profile and lists: 0, 10000, 30000, 30003, 30078. + Identity, + /// Deletes: 5. + Delete, + /// Uploads and HTTP auth: 24242, 27235. + Http, +} + +impl TrustCategory { + /// The i18n key for this category's label. + pub fn key(self) -> &'static str { + match self { + TrustCategory::Social => "goblin.trust.cat_social", + TrustCategory::DirectMessages => "goblin.trust.cat_dm", + TrustCategory::Market => "goblin.trust.cat_market", + TrustCategory::Identity => "goblin.trust.cat_identity", + TrustCategory::Delete => "goblin.trust.cat_delete", + TrustCategory::Http => "goblin.trust.cat_http", + } + } + + /// A stable render order, so the prompt reads the same every time. + const ORDER: [TrustCategory; 6] = [ + TrustCategory::Social, + TrustCategory::DirectMessages, + TrustCategory::Market, + TrustCategory::Identity, + TrustCategory::Delete, + TrustCategory::Http, + ]; +} + +/// The category a LOW-tier kind belongs to, or `None` for an unrecognized kind +/// (which the prompt renders on its own caution line). Total over all `u16`. +pub fn category_for_kind(kind: u16) -> Option { + match kind { + 1 | 6 | 7 | 1111 => Some(TrustCategory::Social), + 13 | 14 | 16 | 1059 => Some(TrustCategory::DirectMessages), + 30402 | 30405 | 30406 | 31990 => Some(TrustCategory::Market), + 0 | 10000 | 30000 | 30003 | 30078 => Some(TrustCategory::Identity), + 5 => Some(TrustCategory::Delete), + 24242 | 27235 => Some(TrustCategory::Http), + _ => None, + } +} + +/// What the trust prompt renders from the site's RAW requested kind set: the +/// deduplicated low-tier categories being granted, the unrecognized low kinds +/// shown one caution line each, and whether login (22242) was requested and +/// stripped. Money-tier kinds requested are silently folded into the fixed +/// "money always asks" line and appear nowhere as a grant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrantDisplay { + /// Granted low-tier categories, in [`TrustCategory::ORDER`]. + pub categories: Vec, + /// Unrecognized low-tier kinds (each a caution line). + pub unknown_kinds: Vec, + /// True when the site requested kind 22242 and the wallet stripped it. + pub stripped_login: bool, +} + +/// Build the grant prompt's render plan from the raw requested kinds. Pure and +/// unit-testable, so it is verifiable without a running GUI. +pub fn render_grant(requested: &[u16]) -> GrantDisplay { + let mut present = [false; 6]; + let mut unknown_kinds = Vec::new(); + let mut stripped_login = false; + for &k in requested { + if k == LOGIN_EVENT_KIND { + stripped_login = true; + continue; + } + if is_money_kind(k) { + // Covered by the fixed money line; never a granted category. + continue; + } + match category_for_kind(k) { + Some(cat) => { + let idx = TrustCategory::ORDER.iter().position(|c| *c == cat).unwrap(); + present[idx] = true; + } + None => { + if !unknown_kinds.contains(&k) { + unknown_kinds.push(k); + } + } + } + } + let categories = TrustCategory::ORDER + .iter() + .enumerate() + .filter(|(i, _)| present[*i]) + .map(|(_, c)| *c) + .collect(); + GrantDisplay { + categories, + unknown_kinds, + stripped_login, + } +} + +// --------------------------------------------------------------------------- +// Wire envelope shapes (the plaintext inside the NIP-44 channel envelope). +// --------------------------------------------------------------------------- + +/// The full event as the client (NDK) composed it, WITHOUT `id` and `sig`. The +/// wallet signs exactly this: it computes the NIP-01 `id` over these fields and +/// produces `sig`, but never re-stamps `created_at` and never adopts a +/// client-supplied `id`/`sig` (finding A). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RequestEvent { + /// Must equal the session identity or the request is rejected. + pub pubkey: String, + /// Client-owned, bounded by the skew guard. The wallet signs this exact time. + pub created_at: u64, + pub kind: u16, + pub tags: Vec>, + pub content: String, +} + +/// A sign request (site to wallet), the plaintext inside a NIP-44 envelope. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignRequest { + /// Always `"sign"`. + #[serde(rename = "type")] + pub msg_type: String, + /// A UUID, unique per request; the replay-dedup key. + pub id: String, + /// Envelope timestamp, checked against the skew window independently. + pub ts: u64, + pub event: RequestEvent, +} + +/// The session-open envelope (wallet to site), sent once at channel +/// establishment: it hands the site the wallet's channel public key (also the +/// signing pubkey of the envelope event, so the site can derive the conversation +/// key) and confirms the signing identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionOpen { + /// Always `"session-open"`. + #[serde(rename = "type")] + pub msg_type: String, + /// The wallet's ephemeral channel public key (x-only hex). + pub wallet_pubkey: String, + /// The confirmed signing identity public key (hex). + pub identity: String, +} + +/// The session-end envelope (either direction): the site's logout signal, or the +/// wallet announcing a unilateral end. Type only; nothing else is trusted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionEnd { + /// Always `"session-end"`. + #[serde(rename = "type")] + pub msg_type: String, +} + +/// A sign response (wallet to site). On success `ok` is true and `event` carries +/// the fully signed event; on refusal `ok` is false and `error` carries a typed +/// code. Exactly one of `event`/`error` is set. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignResult { + /// Always `"sign_result"`. + #[serde(rename = "type")] + pub msg_type: String, + /// The request UUID this answers. + pub id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl SignResult { + /// A success response carrying the signed event JSON. + pub fn ok(id: &str, event: &Event) -> Self { + SignResult { + msg_type: "sign_result".to_string(), + id: id.to_string(), + ok: true, + event: serde_json::to_value(event).ok(), + error: None, + } + } + + /// A refusal response carrying a typed error code. + pub fn refused(id: &str, error: SignError) -> Self { + SignResult { + msg_type: "sign_result".to_string(), + id: id.to_string(), + ok: false, + event: None, + error: Some(error.code().to_string()), + } + } +} + +// --------------------------------------------------------------------------- +// Typed errors (the wire `error` codes). +// --------------------------------------------------------------------------- + +/// Every refusal returns one of these typed codes on the channel so the site can +/// show an honest state. The wire strings match the spec's section 7 table; +/// `Refused` and `Malformed` are additions for the outright-refusal and +/// unparseable cases the table folds into "sign in again". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignError { + /// A low-tier kind the session was not granted. + KindNotInSession, + /// `event.pubkey` did not equal the session identity. + IdentityMismatch, + /// `created_at` or envelope `ts` outside the skew window. + StaleRequest, + /// Over a size cap. + TooLarge, + /// The user declined a money-tier prompt. + UserDeclined, + /// The hard rate cap tripped and the session is paused. + SessionPaused, + /// The session ended (logout, wallet-side end, TTL, or idle). + SessionEnded, + /// Outright refusal: a login-capable (22242) or delegation-bearing event. + /// Never signed by the session path at all, not even via the money prompt. + Refused, + /// The envelope or event JSON was not well-formed. + Malformed, +} + +impl SignError { + /// The wire error string. + pub fn code(self) -> &'static str { + match self { + SignError::KindNotInSession => "kind_not_in_session", + SignError::IdentityMismatch => "identity_mismatch", + SignError::StaleRequest => "stale_request", + SignError::TooLarge => "too_large", + SignError::UserDeclined => "user_declined", + SignError::SessionPaused => "session_paused", + SignError::SessionEnded => "session_ended", + SignError::Refused => "refused", + SignError::Malformed => "malformed", + } + } +} + +// --------------------------------------------------------------------------- +// The client-pinned `created_at` signer. +// --------------------------------------------------------------------------- + +/// Sign exactly the event the client composed: the wallet fills `pubkey` (from +/// `keys`, which must already equal `req.pubkey`) and computes `id`/`sig`, but +/// pins `created_at` to the client's value so the signed event matches NDK's +/// `id` and relays accept it. Defense in depth re-checks the invariants the +/// enforcement layer also checks: the pubkey must equal the identity, the skew +/// must hold, kind 22242 and any `delegation` tag are refused outright. Only the +/// canonical NIP-01 serialization this computes is ever signed; no client hash. +pub fn sign_session_event(keys: &Keys, ev: &RequestEvent, now: u64) -> Result { + // Identity binding: a session for identity A can never sign as identity B. + let want = keys.public_key(); + let got = PublicKey::from_hex(&ev.pubkey).map_err(|_| SignError::Malformed)?; + if got != want { + return Err(SignError::IdentityMismatch); + } + // Skew guard on the client-pinned time. + if abs_diff(ev.created_at, now) > CREATED_AT_SKEW_SECS { + return Err(SignError::StaleRequest); + } + // The wallet never yields a login-capable signature, in any build, ever. + if ev.kind == LOGIN_EVENT_KIND { + return Err(SignError::Refused); + } + let mut tags = Vec::with_capacity(ev.tags.len()); + for row in &ev.tags { + // A delegation token is unreachable (we sign a composed event, not a + // hash), but refuse it at sign time regardless, exactly as v1. + if row.first().map(|k| k == "delegation").unwrap_or(false) { + return Err(SignError::Refused); + } + tags.push(Tag::parse(row.clone()).map_err(|_| SignError::Malformed)?); + } + EventBuilder::new(Kind::from(ev.kind), ev.content.clone()) + .tags(tags) + .custom_created_at(Timestamp::from(ev.created_at)) + .sign_with_keys(keys) + .map_err(|_| SignError::Malformed) +} + +/// `|a - b|` on unsigned clocks without overflow. +fn abs_diff(a: u64, b: u64) -> u64 { + if a > b { a - b } else { b - a } +} + +// --------------------------------------------------------------------------- +// NIP-44 channel envelope crypto (standard NIP-44 v2, the shape the site uses). +// --------------------------------------------------------------------------- + +/// Encrypt a plaintext payload to `recipient` under the wallet channel key. +/// Standard NIP-44 v2, the same shape magick's browser side uses. +pub fn seal_envelope( + wallet_channel_sk: &SecretKey, + recipient: &PublicKey, + plaintext: &str, +) -> Result { + nip44::encrypt(wallet_channel_sk, recipient, plaintext, nip44::Version::V2) + .map_err(|e| e.to_string()) +} + +/// Decrypt a channel envelope sent by `sender` (the site's channel key, taken +/// from the outer event's `pubkey`) under the wallet channel key. +pub fn open_envelope( + wallet_channel_sk: &SecretKey, + sender: &PublicKey, + payload: &str, +) -> Result { + nip44::decrypt(wallet_channel_sk, sender, payload).map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// 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, + /// 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, + /// 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, + /// Replay-dedup ring: request id -> cached response JSON. A duplicate id + /// returns the cached result, never a second signature. + seen: HashMap, + /// FIFO of seen ids for bounded eviction. + seen_order: VecDeque, + /// Timestamps (unix seconds) of served silent signs, for the rate windows. + silent_times: VecDeque, +} + +/// 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, + /// 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, + 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, + seen: HashMap::new(), + seen_order: VecDeque::new(), + silent_times: VecDeque::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()); + } + // 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 => 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, + } + } + } + } + + /// 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; + 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); + } + } + } + + /// 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; + } + + /// 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 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn low_content() -> &'static str { + "gm" + } + + #[test] + fn money_kind_always_money() { + assert_eq!(classify(17, ""), Tier::Money); + assert!(is_money_kind(17)); + assert!(!is_money_kind(1)); + } + + #[test] + fn plain_low_kinds_are_low() { + for k in [ + 0u16, 1, 5, 7, 1111, 10000, 30000, 30003, 30078, 30402, 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)); + assert_eq!(category_for_kind(30402), 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, 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); + // 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()]]); + } +} diff --git a/src/nostr/trusturi.rs b/src/nostr/trusturi.rs new file mode 100644 index 00000000..9393ce3f --- /dev/null +++ b/src/nostr/trusturi.rs @@ -0,0 +1,523 @@ +// 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. + +//! "Trust with Goblin" (Authorize Sessions, v2) request parser. +//! +//! A site (magick.market) asks the wallet to establish a signing SESSION for a +//! domain by handing it a challenge plus session parameters over either the +//! `goblin:` deep-link scheme or the equivalent `nostr:` QR payload: +//! +//! ```text +//! goblin:trust?c=<64-hex nonce>&d=&cb=&sk=&r=&k= +//! nostr:trust?... (same, for QR payloads) +//! ``` +//! +//! The grant is a superset of login: on approval the wallet signs the one-time +//! kind-22242 login event AND opens an encrypted relay channel bound to the +//! site's ephemeral `sk` channel key, over which the site can then have the +//! wallet silently sign an approved LOW-tier kind set for the life of the +//! session. Money-tier signs are never silent: they arrive on the same channel +//! and raise a per-action password prompt. See the Authorize Sessions spec. +//! +//! Parsing is PURE and fail-closed over UNTRUSTED input, mirroring +//! [`crate::nostr::loginuri`] and [`crate::nostr::authuri`] exactly (same scheme +//! handling, same percent-decoding, first occurrence of a duplicate param wins, +//! unknown params ignored). ANY single validation failure rejects the whole +//! URI: no modal, no partial handling, no fallthrough to the pay path. The +//! `sk`/`r`/`k` session fields are validated here, before any modal can open, so +//! the wallet never even considers a malformed session request. + +use super::authuri::domain_bound; +use super::payuri::{percent_decode, strip_pay_scheme}; +use super::session::sanitize_kind_set; + +/// Total payload byte cap, same bar as the pay/login/authorize parsers. +const MAX_URI_LEN: usize = 4096; +/// Domain byte cap (a DNS name is at most 253 bytes). +const MAX_DOMAIN_LEN: usize = 253; +/// Callback URL byte cap. +const MAX_CALLBACK_LEN: usize = 2048; +/// Relay-hint URL byte cap (a single `wss://` URL). +const MAX_RELAY_LEN: usize = 512; +/// Maximum number of distinct kinds a site may request for the silent set. +const MAX_KINDS: usize = 64; + +/// A validated trust (session) request: the one-time login nonce, the requesting +/// domain the user approves, the HTTPS login callback, the site's ephemeral +/// channel public key, the relay hint the channel runs on, and the raw requested +/// kind set (deduplicated, in range, BEFORE the wallet strips the ceiling). +/// Only constructed by [`parse`], so holding one means every field validated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrustUri { + /// The one-time login-request nonce, exactly 64 hex chars. + pub challenge: String, + /// The requesting domain, shown to the user for approval. + pub domain: String, + /// The login callback the kind-22242 event is delivered to (identity step). + pub callback: String, + /// The site's ephemeral CHANNEL public key (x-only, 64 hex). NOT an identity + /// key: it only encrypts and addresses the request/response envelopes. + pub site_session_pubkey: String, + /// The relay hint the encrypted channel runs on (`wss://`, or dev + /// `ws://localhost`). The wallet honours it and may add its own fallbacks. + pub relay: String, + /// The kinds the site requested for the SILENT low-tier set, deduplicated and + /// in range, exactly as sent. The wallet strips the ceiling (22242 and every + /// money-tier kind) before storing this as `silent_kind_set`; the modal + /// renders the remainder as categories and shows caution lines for anything + /// stripped. Guaranteed non-empty, and guaranteed non-empty after stripping. + pub requested_kinds: Vec, +} + +/// True when `scanned` carries a Goblin scheme with the `trust` keyword, i.e. it +/// is a trust request (valid or not) and must NEVER be fed to the pay path. The +/// dispatcher checks this after [`super::loginuri::is_login_shaped`] and +/// [`super::authuri::is_authorize_shaped`] and BEFORE the pay path; a +/// shaped-but-invalid trust URI is then dropped entirely (no modal, no send). +pub fn is_trust_shaped(scanned: &str) -> bool { + let text = scanned.trim(); + if text.len() > MAX_URI_LEN { + return false; + } + match strip_pay_scheme(text) { + Some(rest) => { + let head = rest.split('?').next().unwrap_or(""); + head.eq_ignore_ascii_case("trust") + } + None => false, + } +} + +/// Parse a trust URI. `Some` only when EVERY field validates: `c` is exactly 64 +/// hex, `d` is a shaped domain, `cb` is an https (or dev localhost) callback +/// bound to `d`, `sk` is 64-hex x-only, `r` is a single `wss://` (or dev +/// `ws://localhost`) URL, and `k` is a non-empty, in-range, deduplicated kind +/// list that still holds at least one kind after the wallet strips the ceiling. +/// Anything else is `None` and the whole request is ignored. Pure, total, no I/O. +pub fn parse(scanned: &str) -> Option { + let text = scanned.trim(); + if text.len() > MAX_URI_LEN || text.as_bytes().contains(&0) { + return None; + } + let rest = strip_pay_scheme(text)?; + let (head, query) = rest.split_once('?')?; + if !head.eq_ignore_ascii_case("trust") { + return None; + } + let mut challenge = None; + let mut domain = None; + let mut callback = None; + let mut site_key = None; + let mut relay = None; + let mut kinds = None; + for pair in query.split('&') { + let Some((key, val)) = pair.split_once('=') else { + continue; // valueless / malformed segment, ignore + }; + match key { + // First occurrence wins, matching the pay/login/authorize convention, + // so a second value can never override a validated one. + "c" if challenge.is_none() => challenge = Some(val), + "d" if domain.is_none() => domain = Some(val), + "cb" if callback.is_none() => callback = Some(val), + "sk" if site_key.is_none() => site_key = Some(val), + "r" if relay.is_none() => relay = Some(val), + "k" if kinds.is_none() => kinds = Some(val), + // Unknown params are ignored for forward-compat. + _ => {} + } + } + let challenge = validate_challenge(challenge?)?; + let domain = validate_domain(domain?)?; + let callback = validate_callback(callback?)?; + // Same domain binding as login and authorize: the login callback host must + // belong to the displayed domain, so a site cannot show one domain while + // harvesting a login event for a host it does not control. + if !domain_bound(&callback, &domain) { + return None; + } + let site_session_pubkey = validate_x_only_hex(site_key?)?; + let relay = validate_relay(relay?)?; + let requested_kinds = validate_kinds(kinds?)?; + Some(TrustUri { + challenge, + domain, + callback, + site_session_pubkey, + relay, + requested_kinds, + }) +} + +/// The challenge nonce must be exactly 64 hex chars (same rule as login). +fn validate_challenge(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + if decoded.len() == 64 && decoded.chars().all(|c| c.is_ascii_hexdigit()) { + Some(decoded) + } else { + None + } +} + +/// The domain must be non-empty, printable ASCII without spaces, within DNS +/// length bounds. Display data plus one binding check, never a route (same rule +/// as login and authorize). +fn validate_domain(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.is_empty() + || decoded.len() > MAX_DOMAIN_LEN + || !decoded.chars().all(|c| c.is_ascii_graphic()) + { + return None; + } + Some(decoded.to_string()) +} + +/// The callback must be `https://...`, or `http://localhost[:port]...` as the +/// one development exception (same rule as login and authorize). +fn validate_callback(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.len() > MAX_CALLBACK_LEN || decoded.bytes().any(|b| b < 0x20 || b == 0x7f) { + return None; + } + if let Some(rest) = strip_prefix_ignore_case(decoded, "https://") { + if !rest.is_empty() { + return Some(decoded.to_string()); + } + return None; + } + if let Some(rest) = strip_prefix_ignore_case(decoded, "http://") { + if is_localhost_authority(rest) { + return Some(decoded.to_string()); + } + } + None +} + +/// A channel public key: exactly 64 hex chars (an x-only secp256k1 key). This is +/// NOT the identity key; it only encrypts and addresses the channel envelopes. +fn validate_x_only_hex(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.len() == 64 && decoded.chars().all(|c| c.is_ascii_hexdigit()) { + Some(decoded.to_ascii_lowercase()) + } else { + None + } +} + +/// The relay hint must be a single `wss://` URL, or `ws://localhost[:port]` as +/// the one development exception. Everything else rejects, so channel traffic +/// can never be pointed at a plaintext or non-relay endpoint. +fn validate_relay(raw: &str) -> Option { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.is_empty() + || decoded.len() > MAX_RELAY_LEN + || decoded.bytes().any(|b| b < 0x20 || b == 0x7f) + // A single URL only: no spaces, no comma-separated list. + || decoded.chars().any(|c| c == ' ' || c == ',') + { + return None; + } + if let Some(rest) = strip_prefix_ignore_case(decoded, "wss://") { + if !rest.is_empty() { + return Some(decoded.to_string()); + } + return None; + } + if let Some(rest) = strip_prefix_ignore_case(decoded, "ws://") { + if is_localhost_authority(rest) { + return Some(decoded.to_string()); + } + } + None +} + +/// The requested low-tier kind set: a comma-separated list of unsigned integers, +/// each a valid `u16`, deduplicated preserving first-seen order. Empty rejects (a +/// session must request at least one kind), the count is capped at [`MAX_KINDS`], +/// and — critically — a set that is empty AFTER the wallet strips the ceiling +/// (22242 and every money-tier kind) also rejects, since a session with nothing +/// left to sign silently is not a session. +fn validate_kinds(raw: &str) -> Option> { + let decoded = String::from_utf8_lossy(&percent_decode(raw)).into_owned(); + let decoded = decoded.trim(); + if decoded.is_empty() { + return None; + } + let mut out: Vec = Vec::new(); + for part in decoded.split(',') { + let part = part.trim(); + // `parse::` rejects negatives, floats, out-of-range, and non-digits. + let kind: u16 = part.parse().ok()?; + if !out.contains(&kind) { + out.push(kind); + } + if out.len() > MAX_KINDS { + return None; + } + } + if out.is_empty() { + return None; + } + // A request the wallet would strip down to nothing (all login/money kinds) is + // no session at all. + if sanitize_kind_set(&out).is_empty() { + return None; + } + Some(out) +} + +/// Strip a case-insensitive ASCII prefix. +fn strip_prefix_ignore_case<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let n = prefix.len(); + match s.get(..n) { + Some(head) if head.eq_ignore_ascii_case(prefix) => Some(&s[n..]), + _ => None, + } +} + +/// True when the URL remainder after `http://`/`ws://` names exactly +/// `localhost`, optionally with a `:port` (a valid non-zero u16), followed by +/// nothing or a `/ ? #` delimiter. `localhost.evil.com` and friends do NOT pass. +fn is_localhost_authority(rest: &str) -> bool { + let authority_end = rest + .find(|c| c == '/' || c == '?' || c == '#') + .unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let (host, port) = match authority.split_once(':') { + Some((h, p)) => (h, Some(p)), + None => (authority, None), + }; + if !host.eq_ignore_ascii_case("localhost") { + return false; + } + match port { + None => true, + Some(p) => { + !p.is_empty() + && p.chars().all(|c| c.is_ascii_digit()) + && p.parse::().map(|n| n > 0).unwrap_or(false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A well-formed 64-hex nonce / channel key. + const C: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const SK: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely"; + + fn valid_uri(scheme: &str) -> String { + format!( + "{scheme}trust?c={C}&d=magick.market&cb=https://magick.market/api/login&sk={SK}&r=wss://relay.magick.market&k=1,7,1059,30402" + ) + } + + #[test] + fn valid_goblin_and_nostr_trust_accepted() { + for scheme in ["goblin:", "nostr:"] { + let out = parse(&valid_uri(scheme)).expect("valid trust URI must parse"); + assert_eq!(out.challenge, C); + assert_eq!(out.domain, "magick.market"); + assert_eq!(out.callback, "https://magick.market/api/login"); + assert_eq!(out.site_session_pubkey, SK); + assert_eq!(out.relay, "wss://relay.magick.market"); + assert_eq!(out.requested_kinds, vec![1, 7, 1059, 30402]); + } + } + + #[test] + fn scheme_and_keyword_case_insensitive() { + let uri = format!( + "GOBLIN:TRUST?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=1" + ); + let out = parse(&uri).expect("uppercase scheme/keyword must parse"); + assert_eq!(out.domain, "magick.market"); + assert!(is_trust_shaped(&uri)); + } + + #[test] + fn is_trust_shaped_detects_keyword() { + assert!(is_trust_shaped(&valid_uri("goblin:"))); + assert!(is_trust_shaped(&valid_uri("nostr:"))); + // Shaped even when invalid, so the dispatcher grabs it before pay. + assert!(is_trust_shaped("goblin:trust?c=&d=&cb=&sk=&r=&k=")); + assert!(!is_trust_shaped("goblin:login?c=x")); + assert!(!is_trust_shaped("goblin:authorize?e=x")); + assert!(!is_trust_shaped("bitcoin:trust?c=x")); + assert!(!is_trust_shaped("trust?c=x")); + assert!(!is_trust_shaped("")); + } + + #[test] + fn challenge_must_be_exactly_64_hex() { + for bad in [&C[..63], &format!("{C}0")[..], ""] { + let uri = format!( + "goblin:trust?c={bad}&d=magick.market&cb=https://m.m/cb&sk={SK}&r=wss://r.m/&k=1" + ); + assert_eq!(parse(&uri), None, "expected c={bad:?} rejected"); + } + } + + #[test] + fn site_key_must_be_64_hex() { + for bad in [&SK[..63], &format!("{SK}0")[..], "", "not-hex-not-hex"] { + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={bad}&r=wss://r.magick.market&k=1" + ); + assert_eq!(parse(&uri), None, "expected sk={bad:?} rejected"); + } + } + + #[test] + fn relay_must_be_wss_or_localhost_ws() { + // Accepted: wss, and the dev ws://localhost exception. + for ok in ["wss://relay.magick.market", "ws://localhost:7777"] { + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r={ok}&k=1" + ); + assert!(parse(&uri).is_some(), "expected r={ok:?} accepted"); + } + // Rejected: plain ws to a non-localhost host, http, garbage, a list. + for bad in [ + "ws://relay.evil.com", + "http://relay.magick.market", + "relay.magick.market", + "wss://a.com,wss://b.com", + "", + ] { + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r={bad}&k=1" + ); + assert_eq!(parse(&uri), None, "expected r={bad:?} rejected"); + } + } + + #[test] + fn kinds_parsed_deduped_and_capped() { + // Dedup preserving first-seen order. + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=7,1,7,1" + ); + assert_eq!(parse(&uri).unwrap().requested_kinds, vec![7, 1]); + // Empty k rejects. + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=" + ); + assert_eq!(parse(&uri), None); + // Non-integer / out of range rejects the whole URI. + for bad in ["1,two,3", "1,-2", "1,70000", "1,1.0"] { + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k={bad}" + ); + assert_eq!(parse(&uri), None, "expected k={bad:?} rejected"); + } + } + + #[test] + fn kinds_that_strip_to_empty_reject() { + // 22242 (login) alone strips to nothing: no session. + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=22242" + ); + assert_eq!(parse(&uri), None); + // Money-only (kind 17) strips to nothing: no session. + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=17" + ); + assert_eq!(parse(&uri), None); + // But a set that keeps something after stripping is fine, and the raw set + // is preserved for the modal to render caution lines. + let uri = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=22242,1,17" + ); + assert_eq!(parse(&uri).unwrap().requested_kinds, vec![22242, 1, 17]); + } + + #[test] + fn missing_params_rejected() { + let base = format!( + "c={C}&d=magick.market&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=1" + ); + for drop in ["c=", "d=", "cb=", "sk=", "r=", "k="] { + // Rebuild the query omitting one param entirely. + let key = &drop[..drop.len() - 1]; + let kept: Vec<&str> = base + .split('&') + .filter(|p| !p.starts_with(&format!("{key}="))) + .collect(); + let uri = format!("goblin:trust?{}", kept.join("&")); + assert_eq!(parse(&uri), None, "dropping {key} must reject"); + } + assert_eq!(parse("goblin:trust"), None); + assert_eq!(parse("goblin:trust?"), None); + } + + #[test] + fn callback_domain_binding_enforced() { + // Cross-domain callback rejected. + let bad = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://evil.com/cb&sk={SK}&r=wss://r.magick.market&k=1" + ); + assert_eq!(parse(&bad), None); + // Subdomain accepted. + let ok = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://auth.magick.market/cb&sk={SK}&r=wss://r.magick.market&k=1" + ); + assert!(parse(&ok).is_some()); + } + + #[test] + fn duplicate_params_first_wins() { + let uri = format!( + "goblin:trust?c={C}&c={}&d=magick.market&d=evil.com&cb=https://magick.market/cb&sk={SK}&r=wss://r.magick.market&k=1&k=7", + "f".repeat(64) + ); + let out = parse(&uri).expect("must parse"); + assert_eq!(out.challenge, C); + assert_eq!(out.domain, "magick.market"); + assert_eq!(out.requested_kinds, vec![1]); + } + + #[test] + fn whitespace_trimmed_and_oversize_rejected() { + let uri = format!(" {} ", valid_uri("nostr:")); + assert!(parse(&uri).is_some()); + let huge = format!( + "goblin:trust?c={C}&d=magick.market&cb=https://m.m/{}&sk={SK}&r=wss://r.m/&k=1", + "a".repeat(5000) + ); + assert_eq!(parse(&huge), None); + assert!(!is_trust_shaped(&huge)); + } + + #[test] + fn trust_is_not_pay_login_or_authorize() { + let trust = valid_uri("goblin:"); + assert!(is_trust_shaped(&trust)); + assert!(!crate::nostr::loginuri::is_login_shaped(&trust)); + assert!(!crate::nostr::authuri::is_authorize_shaped(&trust)); + // A pay URI is never trust-shaped. + let pay = format!("goblin:{NPUB}?amount=1.5"); + assert!(!is_trust_shaped(&pay)); + assert_eq!(parse(&pay), None); + } +}