1
0
forked from GRIN/grim

goblin: split nostr/session.rs into session/ submodules

Pure move (no behavior change): convert the ~1,985-line session.rs file
into a session/ directory module along its existing banner-marked seams:

  session/mod.rs      locked constants, the Session object and its
                      enforcement (decide/serve bookkeeping, rate/size/TTL),
                      SessionSummary/PendingMoney, and the full test suite
  session/classify.rs tier classification and the trust/grant taxonomy
                      (kind-to-tier table, content escalation, kind-set
                      sanitation, category display mapping)
  session/sign.rs     request/channel wire types, the client-pinned signer,
                      NIP-44 envelope shapes and the serving orchestration

Public API is unchanged: mod.rs re-exports classify and sign items
(pub use), so every crate::nostr::session path still resolves. The shared
abs_diff clock helper lives in the parent so both descendants reach it; no
other visibility changed (descendants already see parent-private items).
This commit is contained in:
2ro
2026-07-09 21:39:56 -04:00
parent 9e4289041e
commit 905b3ab94d
3 changed files with 779 additions and 737 deletions
+258
View File
@@ -0,0 +1,258 @@
// 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.
//! Tier classification and the trust/grant taxonomy: the kind-to-tier
//! table, the content-escalation hook, kind-set sanitation and the
//! kind-to-category display mapping. Security-critical, I/O-free.
use super::*;
// ---------------------------------------------------------------------------
// 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::<serde_json::Value>(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.
/// Deliberately broad (amount/total/price catch generic order shapes): a false
/// positive costs one extra prompt, the bias the spec asks for.
const PAYMENT_MARKER_KEYS: &[&str] = &[
"payment",
"payment_request",
"bolt11",
"invoice",
"amount",
"amount_sat",
"amount_sats",
"msat",
"total",
"price",
"payment_hash",
"preimage",
];
/// 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<u16> {
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: 30405, 30406, 31990 (30402 is money tier, owner ruling).
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<TrustCategory> {
match kind {
1 | 6 | 7 | 1111 => Some(TrustCategory::Social),
13 | 14 | 16 | 1059 => Some(TrustCategory::DirectMessages),
// 30402 (listing) is money tier by owner ruling, never a granted category.
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<TrustCategory>,
/// Unrecognized low-tier kinds (each a caution line).
pub unknown_kinds: Vec<u16>,
/// 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,
}
}
@@ -34,6 +34,12 @@ use std::collections::{HashMap, VecDeque};
use super::loginuri::LOGIN_EVENT_KIND;
mod classify;
mod sign;
pub use classify::*;
pub use sign::*;
// ---------------------------------------------------------------------------
// Locked constants (the spec's section 12 recommendations, taken as decided).
// ---------------------------------------------------------------------------
@@ -92,532 +98,11 @@ const MONEY_KINDS: &[u16] = &[17, 30402];
/// 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::<serde_json::Value>(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.
/// Deliberately broad (amount/total/price catch generic order shapes): a false
/// positive costs one extra prompt, the bias the spec asks for.
const PAYMENT_MARKER_KEYS: &[&str] = &[
"payment",
"payment_request",
"bolt11",
"invoice",
"amount",
"amount_sat",
"amount_sats",
"msat",
"total",
"price",
"payment_hash",
"preimage",
];
/// 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<u16> {
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: 30405, 30406, 31990 (30402 is money tier, owner ruling).
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<TrustCategory> {
match kind {
1 | 6 | 7 | 1111 => Some(TrustCategory::Social),
13 | 14 | 16 | 1059 => Some(TrustCategory::DirectMessages),
// 30402 (listing) is money tier by owner ruling, never a granted category.
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<TrustCategory>,
/// Unrecognized low-tier kinds (each a caution line).
pub unknown_kinds: Vec<u16>,
/// 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<Vec<String>>,
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,
}
/// An encrypt request (site to wallet): NIP-44-encrypt `plaintext` to
/// `peer_pubkey` with the SESSION IDENTITY key. magick needs this to build the
/// kind-13 seal of an order DM (silent signing alone cannot construct a seal).
/// Low tier and rate-limited like a silent sign, BUT because the plaintext is
/// visible here, the content-escalation rule runs on it: a pay-committing order
/// message escalates to the money-tier password prompt at the encrypt step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncryptRequest {
/// Always `"encrypt"`.
#[serde(rename = "type")]
pub msg_type: String,
pub id: String,
pub ts: u64,
/// The recipient the identity key encrypts to (hex).
pub peer_pubkey: String,
/// The plaintext to seal (inspected for a payment commitment).
pub plaintext: String,
}
/// A decrypt request (site to wallet): NIP-44-decrypt `ciphertext` from
/// `peer_pubkey` with the SESSION IDENTITY key. THE RISKIEST OP: a compromised
/// site could read that identity's DMs during a live session, so it is
/// rate-limited like a silent sign and called out prominently for the security
/// pass. Its ciphertext is opaque, so no content escalation is possible here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecryptRequest {
/// Always `"decrypt"`.
#[serde(rename = "type")]
pub msg_type: String,
pub id: String,
pub ts: u64,
/// The counterparty the identity key decrypts from (hex).
pub peer_pubkey: String,
/// The opaque ciphertext to open.
pub ciphertext: String,
}
/// A channel operation the wallet may be asked to perform. `Sign` and (a
/// pay-committing) `Encrypt` can escalate to the money-tier prompt, so a pending
/// money item carries one of these.
#[derive(Debug, Clone)]
pub enum ChannelOp {
Sign(SignRequest),
Encrypt(EncryptRequest),
}
impl ChannelOp {
/// The correlation id, for replay dedup and the money-answer routing.
pub fn id(&self) -> &str {
match self {
ChannelOp::Sign(r) => &r.id,
ChannelOp::Encrypt(e) => &e.id,
}
}
}
/// The session-open envelope (wallet to site), sent once at channel
/// establishment. The site reads the WALLET CHANNEL KEY from the outer event's
/// `pubkey` (the envelope sender) and binds the channel to it; this payload is
/// the client-side authority that confirms the signing identity. It is sent in
/// ADDITION to the server-side kind-22242 login callback (which authenticates
/// the server session): the two bind different layers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionOpen {
/// Always `"session-open"`.
#[serde(rename = "type")]
pub msg_type: String,
/// A correlation id (the wallet channel pubkey hex; unique per session).
pub id: String,
/// The confirmed signing identity public key (hex).
pub identity_pubkey: String,
}
/// The session-end envelope (either direction): the site's logout signal, or the
/// wallet announcing a unilateral end. Only the type is trusted; `reason` is
/// display data the receiving side may show ("logout" from the site, "revoked"
/// or "expired" from the wallet).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEnd {
/// Always `"session-end"`.
#[serde(rename = "type")]
pub msg_type: String,
/// Why the session ended: "logout" | "revoked" | "expired".
#[serde(default)]
pub reason: String,
}
/// A sign response (wallet to site). On success `ok` is true and `event` carries
/// 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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
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 cross-worker error set
/// (user_declined, kind_not_in_session, identity_mismatch, stale_request,
/// too_large, session_paused, session_ended). `Refused` (a login-capable or
/// delegation-bearing event the session will never sign) maps onto the site's
/// `kind_not_in_session` handling; `Malformed` is internal and rarely emitted
/// (an unparseable envelope carries no id to answer, so it is simply dropped).
#[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",
// An outright refusal reads to the site exactly as "not covered by
// this session": re-grant, do not retry. Kept aligned with the
// cross-worker code set rather than a wallet-only "refused" string.
SignError::Refused => "kind_not_in_session",
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<Event, SignError> {
// 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 {
a.abs_diff(b)
}
// ---------------------------------------------------------------------------
// 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<String, String> {
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<String, String> {
nip44::decrypt(wallet_channel_sk, sender, payload).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// The session object and its enforcement.
// ---------------------------------------------------------------------------
@@ -1072,222 +557,6 @@ impl PendingMoney {
}
}
// ---------------------------------------------------------------------------
// 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, Default)]
pub struct Served {
/// A `sign_result` JSON to publish back to the site on the channel, or `None`
/// when the request is a money-tier prompt still pending the user (or a
/// replay of one).
pub response: Option<String>,
/// True when the soft rate cap tripped: surface a single non-blocking notice.
pub notify_high_volume: bool,
/// True when the decrypt soft cap tripped: surface the honest "reading your
/// messages" notice (distinct from the signing-volume wording).
pub notify_decrypt_volume: bool,
/// True when this request needs the money-tier password prompt (the loop
/// enqueues it for the GUI and publishes nothing yet).
pub money_pending: bool,
}
/// 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(json),
// A replayed money request whose prompt is still up: no second prompt, no
// response; the original prompt's answer covers this id.
Decision::AlreadyPending => Served::default(),
Decision::Refuse(err) => {
let json =
serde_json::to_string(&SignResult::refused(&req.id, err)).unwrap_or_default();
session.remember(&req.id, &json, false, now);
served_response(json)
}
Decision::MoneyPrompt => Served {
money_pending: true,
..Served::default()
},
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,
..Served::default()
}
}
}
}
/// Complete a money-tier operation after the user answered the password prompt.
/// `approved` true performs the op (sign, or a pay-committing encrypt) and
/// returns its result JSON; false returns the `user_declined` refusal in the
/// matching result shape. Either way the result is remembered so a replay of the
/// same id returns it verbatim. Money operations are individually gated and so
/// never count toward the silent rate window.
pub fn complete_money(
session: &mut Session,
op: &ChannelOp,
keys: &Keys,
approved: bool,
now: u64,
) -> String {
let json = match op {
ChannelOp::Sign(req) => {
let result = if approved {
match sign_session_event(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)
};
serde_json::to_string(&result).unwrap_or_default()
}
ChannelOp::Encrypt(e) => {
if approved {
perform_encrypt(e, keys)
} else {
crypto_error("encrypt_result", &e.id, SignError::UserDeclined)
}
}
};
session.remember(op.id(), &json, false, now);
json
}
// ---------------------------------------------------------------------------
// Encrypt / decrypt channel ops (identity-key NIP-44, for the order-DM path).
// ---------------------------------------------------------------------------
/// A crypto-op refusal JSON in the matching `*_result` shape.
fn crypto_error(result_type: &str, id: &str, err: SignError) -> String {
serde_json::json!({ "type": result_type, "id": id, "ok": false, "error": err.code() })
.to_string()
}
/// Perform an identity-key NIP-44 encrypt, returning the `encrypt_result` JSON.
fn perform_encrypt(e: &EncryptRequest, keys: &Keys) -> String {
let Ok(peer) = PublicKey::from_hex(&e.peer_pubkey) else {
return crypto_error("encrypt_result", &e.id, SignError::Malformed);
};
match nip44::encrypt(keys.secret_key(), &peer, &e.plaintext, nip44::Version::V2) {
Ok(ct) => {
serde_json::json!({ "type": "encrypt_result", "id": e.id, "ok": true, "ciphertext": ct })
.to_string()
}
Err(_) => crypto_error("encrypt_result", &e.id, SignError::Malformed),
}
}
/// Perform an identity-key NIP-44 decrypt, returning the `decrypt_result` JSON.
fn perform_decrypt(d: &DecryptRequest, keys: &Keys) -> String {
let Ok(peer) = PublicKey::from_hex(&d.peer_pubkey) else {
return crypto_error("decrypt_result", &d.id, SignError::Malformed);
};
match nip44::decrypt(keys.secret_key(), &peer, &d.ciphertext) {
Ok(pt) => {
serde_json::json!({ "type": "decrypt_result", "id": d.id, "ok": true, "plaintext": pt })
.to_string()
}
Err(_) => crypto_error("decrypt_result", &d.id, SignError::Malformed),
}
}
/// Serve an encrypt or decrypt op against a live session. Both are low tier and
/// rate-limited like a silent sign; an encrypt whose plaintext commits to a
/// payment escalates to the money prompt (`money_pending`, op carried for
/// [`complete_money`]). Decrypt never escalates (its ciphertext is opaque).
/// `keys` are the session identity's unlocked keys.
pub fn serve_encrypt(session: &mut Session, e: &EncryptRequest, keys: &Keys, now: u64) -> Served {
let escalate = content_commits_payment(&e.plaintext);
match session.decide_crypto(&e.id, e.ts, now, escalate) {
Decision::Duplicate(json) => served_response(json),
Decision::AlreadyPending => Served::default(),
Decision::Refuse(err) => {
let json = crypto_error("encrypt_result", &e.id, err);
session.remember(&e.id, &json, false, now);
served_response(json)
}
Decision::MoneyPrompt => Served {
money_pending: true,
..Served::default()
},
Decision::Silent { notify_high_volume } => {
let json = perform_encrypt(e, keys);
session.remember(&e.id, &json, true, now);
Served {
response: Some(json),
notify_high_volume,
..Served::default()
}
}
}
}
/// Serve a decrypt op (see [`serve_encrypt`]; decrypt never escalates). Counts
/// toward its own soft window so heavy DM-reading surfaces the honest notice.
pub fn serve_decrypt(session: &mut Session, d: &DecryptRequest, keys: &Keys, now: u64) -> Served {
match session.decide_crypto(&d.id, d.ts, now, false) {
Decision::Duplicate(json) => served_response(json),
Decision::AlreadyPending => Served::default(),
Decision::Refuse(err) => {
let json = crypto_error("decrypt_result", &d.id, err);
session.remember(&d.id, &json, false, now);
served_response(json)
}
// Decrypt never escalates, so MoneyPrompt cannot occur; treat defensively.
Decision::MoneyPrompt => {
served_response(crypto_error("decrypt_result", &d.id, SignError::Refused))
}
Decision::Silent { .. } => {
let json = perform_decrypt(d, keys);
session.remember(&d.id, &json, true, now);
let notify_decrypt = session.note_decrypt(now);
Served {
response: Some(json),
notify_decrypt_volume: notify_decrypt,
..Served::default()
}
}
}
}
/// A `Served` carrying just a response JSON.
fn served_response(json: String) -> Served {
Served {
response: Some(json),
..Served::default()
}
}
/// The typed refusal JSON for any channel op, keyed by its request `type` (the
/// runtime uses this when it cannot even look up keys, e.g. the identity was
/// dropped mid-session, so the site fails fast instead of timing out).
pub fn refusal_json(op_type: &str, id: &str, err: SignError) -> String {
match op_type {
"encrypt" => crypto_error("encrypt_result", id, err),
"decrypt" => crypto_error("decrypt_result", id, err),
// "sign" and anything else answers in the sign_result shape.
_ => serde_json::to_string(&SignResult::refused(id, err)).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
+515
View File
@@ -0,0 +1,515 @@
// 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.
//! Request/channel wire types, the client-pinned `created_at` signer
//! with its skew guard, the NIP-44 envelope shapes and the runtime
//! serving orchestration (sign / encrypt / decrypt / money completion).
use super::*;
// ---------------------------------------------------------------------------
// 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<Vec<String>>,
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,
}
/// An encrypt request (site to wallet): NIP-44-encrypt `plaintext` to
/// `peer_pubkey` with the SESSION IDENTITY key. magick needs this to build the
/// kind-13 seal of an order DM (silent signing alone cannot construct a seal).
/// Low tier and rate-limited like a silent sign, BUT because the plaintext is
/// visible here, the content-escalation rule runs on it: a pay-committing order
/// message escalates to the money-tier password prompt at the encrypt step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncryptRequest {
/// Always `"encrypt"`.
#[serde(rename = "type")]
pub msg_type: String,
pub id: String,
pub ts: u64,
/// The recipient the identity key encrypts to (hex).
pub peer_pubkey: String,
/// The plaintext to seal (inspected for a payment commitment).
pub plaintext: String,
}
/// A decrypt request (site to wallet): NIP-44-decrypt `ciphertext` from
/// `peer_pubkey` with the SESSION IDENTITY key. THE RISKIEST OP: a compromised
/// site could read that identity's DMs during a live session, so it is
/// rate-limited like a silent sign and called out prominently for the security
/// pass. Its ciphertext is opaque, so no content escalation is possible here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecryptRequest {
/// Always `"decrypt"`.
#[serde(rename = "type")]
pub msg_type: String,
pub id: String,
pub ts: u64,
/// The counterparty the identity key decrypts from (hex).
pub peer_pubkey: String,
/// The opaque ciphertext to open.
pub ciphertext: String,
}
/// A channel operation the wallet may be asked to perform. `Sign` and (a
/// pay-committing) `Encrypt` can escalate to the money-tier prompt, so a pending
/// money item carries one of these.
#[derive(Debug, Clone)]
pub enum ChannelOp {
Sign(SignRequest),
Encrypt(EncryptRequest),
}
impl ChannelOp {
/// The correlation id, for replay dedup and the money-answer routing.
pub fn id(&self) -> &str {
match self {
ChannelOp::Sign(r) => &r.id,
ChannelOp::Encrypt(e) => &e.id,
}
}
}
/// The session-open envelope (wallet to site), sent once at channel
/// establishment. The site reads the WALLET CHANNEL KEY from the outer event's
/// `pubkey` (the envelope sender) and binds the channel to it; this payload is
/// the client-side authority that confirms the signing identity. It is sent in
/// ADDITION to the server-side kind-22242 login callback (which authenticates
/// the server session): the two bind different layers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionOpen {
/// Always `"session-open"`.
#[serde(rename = "type")]
pub msg_type: String,
/// A correlation id (the wallet channel pubkey hex; unique per session).
pub id: String,
/// The confirmed signing identity public key (hex).
pub identity_pubkey: String,
}
/// The session-end envelope (either direction): the site's logout signal, or the
/// wallet announcing a unilateral end. Only the type is trusted; `reason` is
/// display data the receiving side may show ("logout" from the site, "revoked"
/// or "expired" from the wallet).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionEnd {
/// Always `"session-end"`.
#[serde(rename = "type")]
pub msg_type: String,
/// Why the session ended: "logout" | "revoked" | "expired".
#[serde(default)]
pub reason: String,
}
/// A sign response (wallet to site). On success `ok` is true and `event` carries
/// 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_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
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 cross-worker error set
/// (user_declined, kind_not_in_session, identity_mismatch, stale_request,
/// too_large, session_paused, session_ended). `Refused` (a login-capable or
/// delegation-bearing event the session will never sign) maps onto the site's
/// `kind_not_in_session` handling; `Malformed` is internal and rarely emitted
/// (an unparseable envelope carries no id to answer, so it is simply dropped).
#[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",
// An outright refusal reads to the site exactly as "not covered by
// this session": re-grant, do not retry. Kept aligned with the
// cross-worker code set rather than a wallet-only "refused" string.
SignError::Refused => "kind_not_in_session",
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<Event, SignError> {
// 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)
}
// ---------------------------------------------------------------------------
// 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<String, String> {
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<String, String> {
nip44::decrypt(wallet_channel_sk, sender, payload).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// 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, Default)]
pub struct Served {
/// A `sign_result` JSON to publish back to the site on the channel, or `None`
/// when the request is a money-tier prompt still pending the user (or a
/// replay of one).
pub response: Option<String>,
/// True when the soft rate cap tripped: surface a single non-blocking notice.
pub notify_high_volume: bool,
/// True when the decrypt soft cap tripped: surface the honest "reading your
/// messages" notice (distinct from the signing-volume wording).
pub notify_decrypt_volume: bool,
/// True when this request needs the money-tier password prompt (the loop
/// enqueues it for the GUI and publishes nothing yet).
pub money_pending: bool,
}
/// 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(json),
// A replayed money request whose prompt is still up: no second prompt, no
// response; the original prompt's answer covers this id.
Decision::AlreadyPending => Served::default(),
Decision::Refuse(err) => {
let json =
serde_json::to_string(&SignResult::refused(&req.id, err)).unwrap_or_default();
session.remember(&req.id, &json, false, now);
served_response(json)
}
Decision::MoneyPrompt => Served {
money_pending: true,
..Served::default()
},
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,
..Served::default()
}
}
}
}
/// Complete a money-tier operation after the user answered the password prompt.
/// `approved` true performs the op (sign, or a pay-committing encrypt) and
/// returns its result JSON; false returns the `user_declined` refusal in the
/// matching result shape. Either way the result is remembered so a replay of the
/// same id returns it verbatim. Money operations are individually gated and so
/// never count toward the silent rate window.
pub fn complete_money(
session: &mut Session,
op: &ChannelOp,
keys: &Keys,
approved: bool,
now: u64,
) -> String {
let json = match op {
ChannelOp::Sign(req) => {
let result = if approved {
match sign_session_event(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)
};
serde_json::to_string(&result).unwrap_or_default()
}
ChannelOp::Encrypt(e) => {
if approved {
perform_encrypt(e, keys)
} else {
crypto_error("encrypt_result", &e.id, SignError::UserDeclined)
}
}
};
session.remember(op.id(), &json, false, now);
json
}
// ---------------------------------------------------------------------------
// Encrypt / decrypt channel ops (identity-key NIP-44, for the order-DM path).
// ---------------------------------------------------------------------------
/// A crypto-op refusal JSON in the matching `*_result` shape.
fn crypto_error(result_type: &str, id: &str, err: SignError) -> String {
serde_json::json!({ "type": result_type, "id": id, "ok": false, "error": err.code() })
.to_string()
}
/// Perform an identity-key NIP-44 encrypt, returning the `encrypt_result` JSON.
fn perform_encrypt(e: &EncryptRequest, keys: &Keys) -> String {
let Ok(peer) = PublicKey::from_hex(&e.peer_pubkey) else {
return crypto_error("encrypt_result", &e.id, SignError::Malformed);
};
match nip44::encrypt(keys.secret_key(), &peer, &e.plaintext, nip44::Version::V2) {
Ok(ct) => {
serde_json::json!({ "type": "encrypt_result", "id": e.id, "ok": true, "ciphertext": ct })
.to_string()
}
Err(_) => crypto_error("encrypt_result", &e.id, SignError::Malformed),
}
}
/// Perform an identity-key NIP-44 decrypt, returning the `decrypt_result` JSON.
fn perform_decrypt(d: &DecryptRequest, keys: &Keys) -> String {
let Ok(peer) = PublicKey::from_hex(&d.peer_pubkey) else {
return crypto_error("decrypt_result", &d.id, SignError::Malformed);
};
match nip44::decrypt(keys.secret_key(), &peer, &d.ciphertext) {
Ok(pt) => {
serde_json::json!({ "type": "decrypt_result", "id": d.id, "ok": true, "plaintext": pt })
.to_string()
}
Err(_) => crypto_error("decrypt_result", &d.id, SignError::Malformed),
}
}
/// Serve an encrypt or decrypt op against a live session. Both are low tier and
/// rate-limited like a silent sign; an encrypt whose plaintext commits to a
/// payment escalates to the money prompt (`money_pending`, op carried for
/// [`complete_money`]). Decrypt never escalates (its ciphertext is opaque).
/// `keys` are the session identity's unlocked keys.
pub fn serve_encrypt(session: &mut Session, e: &EncryptRequest, keys: &Keys, now: u64) -> Served {
let escalate = content_commits_payment(&e.plaintext);
match session.decide_crypto(&e.id, e.ts, now, escalate) {
Decision::Duplicate(json) => served_response(json),
Decision::AlreadyPending => Served::default(),
Decision::Refuse(err) => {
let json = crypto_error("encrypt_result", &e.id, err);
session.remember(&e.id, &json, false, now);
served_response(json)
}
Decision::MoneyPrompt => Served {
money_pending: true,
..Served::default()
},
Decision::Silent { notify_high_volume } => {
let json = perform_encrypt(e, keys);
session.remember(&e.id, &json, true, now);
Served {
response: Some(json),
notify_high_volume,
..Served::default()
}
}
}
}
/// Serve a decrypt op (see [`serve_encrypt`]; decrypt never escalates). Counts
/// toward its own soft window so heavy DM-reading surfaces the honest notice.
pub fn serve_decrypt(session: &mut Session, d: &DecryptRequest, keys: &Keys, now: u64) -> Served {
match session.decide_crypto(&d.id, d.ts, now, false) {
Decision::Duplicate(json) => served_response(json),
Decision::AlreadyPending => Served::default(),
Decision::Refuse(err) => {
let json = crypto_error("decrypt_result", &d.id, err);
session.remember(&d.id, &json, false, now);
served_response(json)
}
// Decrypt never escalates, so MoneyPrompt cannot occur; treat defensively.
Decision::MoneyPrompt => {
served_response(crypto_error("decrypt_result", &d.id, SignError::Refused))
}
Decision::Silent { .. } => {
let json = perform_decrypt(d, keys);
session.remember(&d.id, &json, true, now);
let notify_decrypt = session.note_decrypt(now);
Served {
response: Some(json),
notify_decrypt_volume: notify_decrypt,
..Served::default()
}
}
}
}
/// A `Served` carrying just a response JSON.
fn served_response(json: String) -> Served {
Served {
response: Some(json),
..Served::default()
}
}
/// The typed refusal JSON for any channel op, keyed by its request `type` (the
/// runtime uses this when it cannot even look up keys, e.g. the identity was
/// dropped mid-session, so the site fails fast instead of timing out).
pub fn refusal_json(op_type: &str, id: &str, err: SignError) -> String {
match op_type {
"encrypt" => crypto_error("encrypt_result", id, err),
"decrypt" => crypto_error("decrypt_result", id, err),
// "sign" and anything else answers in the sign_result shape.
_ => serde_json::to_string(&SignResult::refused(id, err)).unwrap_or_default(),
}
}