goblin: Authorize Sessions transport + runtime (encrypted relay channel, session store)
- session.rs: channel-event builders (session-open / session-end / wrapped sign_result), decrypt, SessionSummary + PendingMoney, and the serve/ complete_money runtime orchestration (with unit tests). - client.rs: in-memory session store on NostrService (add/end/resume, summaries, money-prompt queue + answers, high-volume notice); channel subscription over the sessions' wallet channel keys, handle_channel event serving, session-open announce, drain-on-resume via the frame heartbeat, and money-answer draining wired into the service loop. - lib.rs: PENDING_TRUST crate-root stash (set/take). - dispatch: goblin:trust routed after authorize, before pay, in both the deep-link and camera-scan dispatchers.
This commit is contained in:
@@ -277,6 +277,17 @@ impl SendFlow {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A "Trust with Goblin" (Authorize Sessions) request is grabbed BEFORE any
|
||||
// pay parsing too: it establishes a signing session, never a payment, and a
|
||||
// trust-shaped payload that fails validation is dropped entirely. A valid
|
||||
// one is stashed for the Goblin surface, whose per-frame router closes this
|
||||
// flow and opens the trust-grant modal.
|
||||
if crate::nostr::trusturi::is_trust_shaped(text) {
|
||||
if let Some(t) = crate::nostr::trusturi::parse(text) {
|
||||
crate::set_pending_trust(t);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Proof-on-request context (frozen contract 4.1) rides alongside the
|
||||
// recipient/amount/memo routing and is independent of whether we land on
|
||||
// Review or Search, so pull it from the same pure parse. `order` is a
|
||||
|
||||
@@ -520,6 +520,12 @@ impl WalletsContent {
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(d) if crate::nostr::trusturi::is_trust_shaped(&d) => {
|
||||
if let Some(t) = crate::nostr::trusturi::parse(&d) {
|
||||
crate::set_pending_trust(t);
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
|
||||
crate::set_pending_pay_uri(d);
|
||||
None
|
||||
|
||||
+22
@@ -574,6 +574,28 @@ lazy_static! {
|
||||
/// are dropped at the dispatch site and never reach the UI.
|
||||
static ref PENDING_AUTHORIZE: Arc<RwLock<Option<nostr::authuri::AuthorizeUri>>> =
|
||||
Arc::new(RwLock::new(None));
|
||||
/// A pending, already-VALIDATED "Trust with Goblin" (Authorize Sessions)
|
||||
/// request (`goblin:trust?...` deep link or QR), waiting for the Goblin
|
||||
/// surface to open its trust-grant modal. Only a fully validated
|
||||
/// [`nostr::trusturi::TrustUri`] (nonce, domain binding, channel key, relay
|
||||
/// hint, and a kind set that survives ceiling stripping) is ever stashed
|
||||
/// here; rejected trust URIs are dropped at the dispatch site.
|
||||
static ref PENDING_TRUST: Arc<RwLock<Option<nostr::trusturi::TrustUri>>> =
|
||||
Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Stash a VALIDATED trust request for the Goblin surface to open its trust-grant
|
||||
/// modal (see [`take_pending_trust`]). The most recent request wins; the surface
|
||||
/// ignores new requests while one approval (login, authorize, or trust) is
|
||||
/// already pending.
|
||||
pub fn set_pending_trust(trust: nostr::trusturi::TrustUri) {
|
||||
*PENDING_TRUST.write() = Some(trust);
|
||||
}
|
||||
|
||||
/// Take (and clear) a pending trust request, if any. The Goblin wallet view
|
||||
/// polls this each frame and opens the "Trust <domain>?" grant modal.
|
||||
pub fn take_pending_trust() -> Option<nostr::trusturi::TrustUri> {
|
||||
PENDING_TRUST.write().take()
|
||||
}
|
||||
|
||||
/// Stash a payment deep link for the Goblin surface to open (see
|
||||
|
||||
+352
-3
@@ -52,6 +52,11 @@ pub struct NostrProfile {
|
||||
/// duplicate REQs on the relays.
|
||||
const GIFTWRAP_SUB: &str = "goblin-giftwrap";
|
||||
|
||||
/// Stable subscription id for the Authorize Sessions encrypted channel (kind
|
||||
/// 24140), re-subscribed (replace-not-duplicate) whenever the session set
|
||||
/// changes so newly granted sessions start receiving immediately.
|
||||
const CHANNEL_SUB: &str = "goblin-session-channel";
|
||||
|
||||
/// The Goblin news publisher (kind 30023 long-form). The Home news panel shows
|
||||
/// this key's latest post, fetched from our own relay set.
|
||||
const NEWS_NPUB: &str = "npub15gsytqvs5c78u83yv2agl4twjkk6qgem7gtwe2agu7s90tkelxys0xxely";
|
||||
@@ -156,6 +161,22 @@ pub struct NostrService {
|
||||
/// Serializes a manual payment-cancel against a concurrent S2 finalize+post
|
||||
/// so the two can't both succeed (cancel the outputs AND post on-chain).
|
||||
cancel_finalize_lock: Mutex<()>,
|
||||
/// Active Authorize Sessions (v2). In memory only: quitting the wallet ends
|
||||
/// every session. The service loop subscribes each session's encrypted
|
||||
/// channel, serves silent low-tier signs, and routes money-tier requests to
|
||||
/// the GUI money prompt.
|
||||
sessions: RwLock<Vec<crate::nostr::session::Session>>,
|
||||
/// Set whenever the session set changes so the loop re-subscribes the channel
|
||||
/// filter and publishes `session-open` for any new session.
|
||||
sessions_dirty: AtomicBool,
|
||||
/// Money-tier requests awaiting the GUI's per-action password prompt (FIFO).
|
||||
money_pending: Mutex<Vec<crate::nostr::session::PendingMoney>>,
|
||||
/// The GUI's answers to money prompts (the answered request plus approve/decline),
|
||||
/// drained by the loop which then signs (or declines) and publishes on the channel.
|
||||
money_answers: Mutex<Vec<(crate::nostr::session::PendingMoney, bool)>>,
|
||||
/// A single non-blocking "signing a lot" notice for the GUI toast, set when a
|
||||
/// session trips the soft rate cap.
|
||||
session_notice: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
/// Phase of the most recent outgoing send, polled by the send UI.
|
||||
@@ -206,6 +227,11 @@ impl NostrService {
|
||||
last_send_error: RwLock::new(None),
|
||||
cancel_notice: RwLock::new(None),
|
||||
cancel_finalize_lock: Mutex::new(()),
|
||||
sessions: RwLock::new(Vec::new()),
|
||||
sessions_dirty: AtomicBool::new(false),
|
||||
money_pending: Mutex::new(Vec::new()),
|
||||
money_answers: Mutex::new(Vec::new()),
|
||||
session_notice: RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,6 +257,107 @@ impl NostrService {
|
||||
self.recv.read().iter().any(|h| &h.keys.public_key() == pk)
|
||||
}
|
||||
|
||||
// --- Authorize Sessions (v2) -------------------------------------------
|
||||
|
||||
/// Register a freshly granted session and wake the loop to subscribe its
|
||||
/// channel and publish `session-open`.
|
||||
pub fn add_session(&self, session: crate::nostr::session::Session) {
|
||||
self.sessions.write().push(session);
|
||||
self.sessions_dirty.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// True when at least one session is live (for the Trusted Sites badge/list).
|
||||
pub fn has_sessions(&self) -> bool {
|
||||
!self.sessions.read().is_empty()
|
||||
}
|
||||
|
||||
/// Read-only snapshots for the Trusted Sites list, newest last.
|
||||
pub fn session_summaries(&self) -> Vec<crate::nostr::session::SessionSummary> {
|
||||
let now = unix_time() as u64;
|
||||
self.sessions
|
||||
.read()
|
||||
.iter()
|
||||
.map(|s| s.summary(now))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// End (revoke) the session for `domain`: mark it ended, send the courtesy
|
||||
/// `session-end`, and drop it. Immediate and unilateral.
|
||||
pub fn end_session(&self, domain: &str) {
|
||||
let now = unix_time() as u64;
|
||||
let mut end_event = None;
|
||||
{
|
||||
let mut sessions = self.sessions.write();
|
||||
if let Some(s) = sessions.iter_mut().find(|s| s.domain == domain) {
|
||||
s.end();
|
||||
end_event = s.session_end_event(now).ok();
|
||||
}
|
||||
sessions.retain(|s| s.domain != domain);
|
||||
}
|
||||
self.sessions_dirty.store(true, Ordering::SeqCst);
|
||||
// Best-effort courtesy notice to the site; teardown already happened.
|
||||
if let Some(ev) = end_event {
|
||||
self.publish_event_best_effort(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a paused session (the user tapped "resume" in Trusted Sites).
|
||||
pub fn resume_session(&self, domain: &str) {
|
||||
let now = unix_time() as u64;
|
||||
if let Some(s) = self
|
||||
.sessions
|
||||
.write()
|
||||
.iter_mut()
|
||||
.find(|s| s.domain == domain)
|
||||
{
|
||||
s.resume(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The front money-tier request awaiting the user, if any (GUI polls this to
|
||||
/// raise its per-action password prompt).
|
||||
pub fn peek_money_prompt(&self) -> Option<crate::nostr::session::PendingMoney> {
|
||||
self.money_pending.lock().first().cloned()
|
||||
}
|
||||
|
||||
/// Record the user's answer to a money prompt: remove it from the display
|
||||
/// queue and hand the full request to the loop, which signs (or declines) and
|
||||
/// publishes the result on the channel.
|
||||
pub fn answer_money_prompt(&self, req_id: &str, approved: bool) {
|
||||
let answered = {
|
||||
let mut pending = self.money_pending.lock();
|
||||
let idx = pending.iter().position(|p| p.req.id == req_id);
|
||||
idx.map(|i| pending.remove(i))
|
||||
};
|
||||
if let Some(p) = answered {
|
||||
self.money_answers.lock().push((p, approved));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take (and clear) the "signing a lot" notice, if any.
|
||||
pub fn take_session_notice(&self) -> Option<String> {
|
||||
self.session_notice.write().take()
|
||||
}
|
||||
|
||||
/// Publish an event on the service runtime without blocking the caller
|
||||
/// (used for the best-effort `session-end` courtesy). No-op if the loop is
|
||||
/// not running.
|
||||
fn publish_event_best_effort(&self, event: nostr_sdk::Event) {
|
||||
let (Some(client), Some(handle)) =
|
||||
(self.client.read().clone(), self.rt_handle.read().clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let urls: Vec<String> = self.relays();
|
||||
handle.spawn(async move {
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.send_event_to(&urls, &event),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Update a held identity's PRIVATE tag in the in-memory set (and the active
|
||||
/// copy when it is the same identity), so the switcher re-renders immediately
|
||||
/// after a rename without a service rebuild. The caller persists the file.
|
||||
@@ -1326,6 +1453,9 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
// names right away (so you see refreshed info from app open), unless one ran
|
||||
// within the last interval.
|
||||
let mut last_name_sweep = svc.store.last_name_sweep_at().unwrap_or(0);
|
||||
// Tracks the app foreground state so a background→foreground transition drains
|
||||
// any session-channel requests queued on the relay while the wallet slept.
|
||||
let mut was_foreground = crate::app_foreground();
|
||||
loop {
|
||||
if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() {
|
||||
break;
|
||||
@@ -1334,9 +1464,11 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
notification = notifications.recv() => {
|
||||
match notification {
|
||||
Ok(RelayPoolNotification::Event { event, .. }) => {
|
||||
// News long-form posts and gift wraps ride the same feed;
|
||||
// route by kind (handle_wrap ignores non-1059 anyway).
|
||||
if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote {
|
||||
// News long-form posts, session-channel envelopes, and gift
|
||||
// wraps ride the same feed; route by kind.
|
||||
if event.kind.as_u16() == crate::nostr::session::CHANNEL_EVENT_KIND {
|
||||
handle_channel(&svc, &client, &event).await;
|
||||
} else if let Some(pk) = news_pk && event.kind == Kind::LongFormTextNote {
|
||||
handle_news(&svc, pk, *event).await;
|
||||
} else {
|
||||
handle_wrap(&svc, &wallet, *event).await;
|
||||
@@ -1408,6 +1540,21 @@ async fn run_service(svc: Arc<NostrService>, wallet: Wallet) {
|
||||
svc.resolve_contact_identity(&c.npub);
|
||||
}
|
||||
}
|
||||
// Authorize Sessions (v2): when the session set changed, re-subscribe
|
||||
// the encrypted channel and publish `session-open` for new sessions;
|
||||
// then sign/decline any money-tier prompts the user answered.
|
||||
if svc.sessions_dirty.swap(false, Ordering::SeqCst) {
|
||||
resubscribe_channel(&client, &relays, &svc).await;
|
||||
announce_new_sessions(&svc, &client, &relays).await;
|
||||
}
|
||||
serve_money_answers(&svc, &client).await;
|
||||
// Drain requests queued while backgrounded on a resume (the Build-95
|
||||
// frame-heartbeat pattern), gated on the app being foregrounded.
|
||||
let fg = crate::app_foreground();
|
||||
if fg && !was_foreground && svc.has_sessions() {
|
||||
drain_channel(&svc, &client, &relays).await;
|
||||
}
|
||||
was_foreground = fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1906,6 +2053,208 @@ fn first_tag_value(event: &Event, name: &str) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve one Authorize Sessions channel event (kind 24140). Matches it to a
|
||||
/// live session by the site's channel key, decrypts under the session key,
|
||||
/// enforces every rule (via the pure `session` core), and either publishes a
|
||||
/// signed `sign_result` back to the site, enqueues a money-tier prompt for the
|
||||
/// GUI, or tears the session down on a `session-end` signal. Fails closed and
|
||||
/// silent on anything it cannot match, decrypt, or parse.
|
||||
async fn handle_channel(svc: &Arc<NostrService>, client: &Client, event: &Event) {
|
||||
use crate::nostr::session::{self, PendingMoney, SignRequest, SignResult};
|
||||
if event.kind.as_u16() != session::CHANNEL_EVENT_KIND || event.verify().is_err() {
|
||||
return;
|
||||
}
|
||||
let now = unix_time() as u64;
|
||||
let mut publish: Option<Event> = None;
|
||||
let mut money: Option<PendingMoney> = None;
|
||||
let mut notice = false;
|
||||
let mut ended = false;
|
||||
{
|
||||
let mut sessions = svc.sessions.write();
|
||||
// Origin binding: the only key allowed to request is the site channel key
|
||||
// bound at grant time. Nothing else can even open an envelope.
|
||||
let Some(s) = sessions
|
||||
.iter_mut()
|
||||
.find(|s| s.site_session_pubkey == event.pubkey && !s.ended)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Ok(plaintext) = s.decrypt(&event.pubkey, &event.content) else {
|
||||
return;
|
||||
};
|
||||
if !session::envelope_within_cap(&plaintext) {
|
||||
return;
|
||||
}
|
||||
let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) else {
|
||||
return;
|
||||
};
|
||||
match val.get("type").and_then(|t| t.as_str()) {
|
||||
Some("session-end") => {
|
||||
s.end();
|
||||
ended = true;
|
||||
}
|
||||
Some("sign") => {
|
||||
let Ok(req) = serde_json::from_value::<SignRequest>(val) else {
|
||||
return;
|
||||
};
|
||||
// The signing identity's unlocked keys from the in-memory snapshot.
|
||||
let keys = svc
|
||||
.recv_snapshot()
|
||||
.into_iter()
|
||||
.find(|h| h.keys.public_key() == s.identity_pubkey)
|
||||
.map(|h| h.keys);
|
||||
match keys {
|
||||
Some(keys) => {
|
||||
let served = session::serve(s, &req, &keys, now);
|
||||
notice = served.notify_high_volume;
|
||||
if served.money_pending {
|
||||
money = Some(PendingMoney {
|
||||
domain: s.domain.clone(),
|
||||
identity_pubkey: s.identity_pubkey,
|
||||
req,
|
||||
});
|
||||
} else if let Some(json) = served.response {
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Identity no longer held: honest refusal, not a hang.
|
||||
let json = serde_json::to_string(&SignResult::refused(
|
||||
&req.id,
|
||||
session::SignError::IdentityMismatch,
|
||||
))
|
||||
.unwrap_or_default();
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if ended {
|
||||
svc.sessions.write().retain(|s| !s.ended);
|
||||
svc.sessions_dirty.store(true, Ordering::SeqCst);
|
||||
}
|
||||
if notice {
|
||||
*svc.session_notice.write() = Some("signing".to_string());
|
||||
}
|
||||
if let Some(p) = money {
|
||||
svc.money_pending.lock().push(p);
|
||||
}
|
||||
if let Some(ev) = publish {
|
||||
let urls = svc.relays();
|
||||
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the GUI's answers to money-tier prompts: sign (or decline) each and
|
||||
/// publish the `sign_result` on its session channel. Called from the loop tick.
|
||||
async fn serve_money_answers(svc: &Arc<NostrService>, client: &Client) {
|
||||
use crate::nostr::session;
|
||||
let answers: Vec<(session::PendingMoney, bool)> =
|
||||
std::mem::take(&mut *svc.money_answers.lock());
|
||||
if answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now = unix_time() as u64;
|
||||
let snapshot = svc.recv_snapshot();
|
||||
for (pending, approved) in answers {
|
||||
let mut publish: Option<Event> = None;
|
||||
{
|
||||
let mut sessions = svc.sessions.write();
|
||||
if let Some(s) = sessions
|
||||
.iter_mut()
|
||||
.find(|s| s.domain == pending.domain && !s.ended)
|
||||
{
|
||||
let keys = snapshot
|
||||
.iter()
|
||||
.find(|h| h.keys.public_key() == s.identity_pubkey)
|
||||
.map(|h| h.keys.clone());
|
||||
if let Some(keys) = keys {
|
||||
let json = session::complete_money(s, &pending.req, &keys, approved, now);
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ev) = publish {
|
||||
let urls = svc.relays();
|
||||
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(&urls, &ev)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel subscription/fetch filter over the live sessions' wallet channel
|
||||
/// keys, or `None` when there are no sessions. Bounded `since` to the request
|
||||
/// expiration: anything older has lapsed its NIP-40 expiration anyway.
|
||||
fn channel_filter(svc: &Arc<NostrService>) -> Option<Filter> {
|
||||
let pks: Vec<PublicKey> = svc
|
||||
.sessions
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|s| !s.ended)
|
||||
.map(|s| s.wallet_channel_pk)
|
||||
.collect();
|
||||
if pks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let now = unix_time() as u64;
|
||||
let since = now.saturating_sub(crate::nostr::session::REQUEST_EXPIRATION_SECS);
|
||||
Some(
|
||||
Filter::new()
|
||||
.kind(Kind::from(crate::nostr::session::CHANNEL_EVENT_KIND))
|
||||
.pubkeys(pks)
|
||||
.since(Timestamp::from_secs(since)),
|
||||
)
|
||||
}
|
||||
|
||||
/// (Re)subscribe the encrypted session channel over the current session set.
|
||||
async fn resubscribe_channel(client: &Client, relays: &[String], svc: &Arc<NostrService>) {
|
||||
if let Some(filter) = channel_filter(svc) {
|
||||
if let Err(e) = client
|
||||
.subscribe_with_id_to(relays, SubscriptionId::new(CHANNEL_SUB), filter, None)
|
||||
.await
|
||||
{
|
||||
warn!("nostr: session-channel subscribe failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish the one-time `session-open` for every session not yet announced, and
|
||||
/// mark them announced. Called when the session set changes.
|
||||
async fn announce_new_sessions(svc: &Arc<NostrService>, client: &Client, relays: &[String]) {
|
||||
let now = unix_time() as u64;
|
||||
let mut events = Vec::new();
|
||||
{
|
||||
let mut sessions = svc.sessions.write();
|
||||
for s in sessions.iter_mut().filter(|s| !s.announced && !s.ended) {
|
||||
if let Ok(ev) = s.session_open_event(now) {
|
||||
events.push(ev);
|
||||
}
|
||||
s.announced = true;
|
||||
}
|
||||
}
|
||||
for ev in events {
|
||||
let _ = tokio::time::timeout(SEND_TIMEOUT, client.send_event_to(relays, &ev)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any channel requests queued on the relay while the wallet was asleep,
|
||||
/// serving each. Called on a background→foreground transition (the Build-95
|
||||
/// frame-heartbeat resume pattern) and once at loop start.
|
||||
async fn drain_channel(svc: &Arc<NostrService>, client: &Client, relays: &[String]) {
|
||||
let Some(filter) = channel_filter(svc) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(events) = client
|
||||
.fetch_events_from(relays, filter, FETCH_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
for ev in events.into_iter() {
|
||||
handle_channel(svc, client, &ev).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest one kind-30023 news post from the Goblin news key and cache it (the
|
||||
/// store dedupes newest-per-`d`). Guards kind + author so a stray event on the
|
||||
/// news subscription can't spoof the panel.
|
||||
|
||||
@@ -575,6 +575,9 @@ pub struct Session {
|
||||
pub paused: bool,
|
||||
/// Set when the session has ended (logout, wallet end, TTL, idle).
|
||||
pub ended: bool,
|
||||
/// True once the wallet has published its `session-open` envelope and
|
||||
/// subscribed the channel for this session (a one-time runtime step).
|
||||
pub announced: bool,
|
||||
/// Replay-dedup ring: request id -> cached response JSON. A duplicate id
|
||||
/// returns the cached result, never a second signature.
|
||||
seen: HashMap<String, String>,
|
||||
@@ -629,6 +632,7 @@ impl Session {
|
||||
last_used_at: now,
|
||||
paused: false,
|
||||
ended: false,
|
||||
announced: false,
|
||||
seen: HashMap::new(),
|
||||
seen_order: VecDeque::new(),
|
||||
silent_times: VecDeque::new(),
|
||||
@@ -745,6 +749,71 @@ impl Session {
|
||||
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(),
|
||||
wallet_pubkey: self.wallet_channel_pk.to_hex(),
|
||||
identity: 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 (a courtesy on wallet-side revocation; the teardown is unilateral
|
||||
/// regardless of delivery).
|
||||
pub fn session_end_event(&self, now: u64) -> Result<Event, String> {
|
||||
let end = SessionEnd {
|
||||
msg_type: "session-end".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) {
|
||||
@@ -795,6 +864,121 @@ 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.
|
||||
pub domain: String,
|
||||
/// The signing identity for this session (looked up to sign on approval).
|
||||
pub identity_pubkey: PublicKey,
|
||||
/// The full request, replayed verbatim to sign on approval.
|
||||
pub req: SignRequest,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime serving orchestration (thin, so the async relay loop stays dumb).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The upshot of serving one decoded request against a session. The async loop
|
||||
/// acts on it and never touches classification, signing, or bookkeeping itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Served {
|
||||
/// A `sign_result` JSON to publish back to the site on the channel, or `None`
|
||||
/// when the request is a money-tier prompt still pending the user.
|
||||
pub response: Option<String>,
|
||||
/// True when the soft rate cap tripped: surface a single non-blocking notice.
|
||||
pub notify_high_volume: bool,
|
||||
/// True when this request needs the money-tier password prompt (the loop
|
||||
/// enqueues it for the GUI and publishes nothing yet).
|
||||
pub money_pending: bool,
|
||||
}
|
||||
|
||||
/// Serve a decoded sign request against a live session. Silent low-tier requests
|
||||
/// are signed here and turned into a `sign_result` JSON; refusals and cached
|
||||
/// duplicates likewise return a JSON to publish; a money-tier request returns
|
||||
/// `money_pending` with no response (the GUI raises the prompt, then the loop
|
||||
/// calls [`complete_money`]). `sign_keys` are the session identity's unlocked
|
||||
/// keys, looked up by the loop from the wallet's in-memory snapshot.
|
||||
pub fn serve(session: &mut Session, req: &SignRequest, sign_keys: &Keys, now: u64) -> Served {
|
||||
match session.decide(req, now) {
|
||||
Decision::Duplicate(json) => Served {
|
||||
response: Some(json),
|
||||
notify_high_volume: false,
|
||||
money_pending: false,
|
||||
},
|
||||
Decision::Refuse(err) => {
|
||||
let json =
|
||||
serde_json::to_string(&SignResult::refused(&req.id, err)).unwrap_or_default();
|
||||
session.remember(&req.id, &json, false, now);
|
||||
Served {
|
||||
response: Some(json),
|
||||
notify_high_volume: false,
|
||||
money_pending: false,
|
||||
}
|
||||
}
|
||||
Decision::MoneyPrompt => Served {
|
||||
response: None,
|
||||
notify_high_volume: false,
|
||||
money_pending: true,
|
||||
},
|
||||
Decision::Silent { notify_high_volume } => {
|
||||
let result = match sign_session_event(sign_keys, &req.event, now) {
|
||||
Ok(ev) => SignResult::ok(&req.id, &ev),
|
||||
Err(err) => SignResult::refused(&req.id, err),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap_or_default();
|
||||
// A produced silent signature counts toward the rate window; a refusal
|
||||
// on the silent path does not.
|
||||
session.remember(&req.id, &json, result.ok, now);
|
||||
Served {
|
||||
response: Some(json),
|
||||
notify_high_volume,
|
||||
money_pending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete a money-tier request after the user answered the password prompt.
|
||||
/// `approved` true signs the event and returns the `sign_result`; false returns
|
||||
/// the `user_declined` refusal. Either way the result is remembered so a replay
|
||||
/// of the same id returns it verbatim. Money signs are individually gated and so
|
||||
/// never count toward the silent rate window.
|
||||
pub fn complete_money(
|
||||
session: &mut Session,
|
||||
req: &SignRequest,
|
||||
sign_keys: &Keys,
|
||||
approved: bool,
|
||||
now: u64,
|
||||
) -> String {
|
||||
let result = if approved {
|
||||
match sign_session_event(sign_keys, &req.event, now) {
|
||||
Ok(ev) => SignResult::ok(&req.id, &ev),
|
||||
Err(err) => SignResult::refused(&req.id, err),
|
||||
}
|
||||
} else {
|
||||
SignResult::refused(&req.id, SignError::UserDeclined)
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap_or_default();
|
||||
session.remember(&req.id, &json, false, now);
|
||||
json
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1217,4 +1401,57 @@ mod tests {
|
||||
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, &req, &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, &req2, &id, false, now);
|
||||
assert!(declined.contains("\"error\":\"user_declined\""));
|
||||
}
|
||||
|
||||
#[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\"")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user