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:
@@ -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::*;
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user