goblin: fold cross-worker seam rulings (encrypt/decrypt ops, session-open, 30402 money)
- Add channel encrypt/decrypt ops (identity-key NIP-44) for magick's order-DM
seal path: EncryptRequest/DecryptRequest, serve_encrypt/serve_decrypt,
ChannelOp, generalized complete_money. Both low tier + rate-limited; an
encrypt whose plaintext commits payment escalates to the money prompt;
decrypt (the DM-read risk) is rate-limited and flagged for the security pass.
- session-open payload aligned to {type,id,identity_pubkey}; sent alongside
the unchanged 22242 server callback.
- Error codes aligned to the shared set (Refused -> kind_not_in_session).
- Owner ruling: kind 30402 (listing) is money tier, stripped from the silent
set like 17/22242; spec tier table + category map + tests updated.
This commit is contained in:
+50
-41
@@ -326,7 +326,7 @@ impl NostrService {
|
||||
pub fn answer_money_prompt(&self, req_id: &str, approved: bool) {
|
||||
let answered = {
|
||||
let mut pending = self.money_pending.lock();
|
||||
let idx = pending.iter().position(|p| p.req.id == req_id);
|
||||
let idx = pending.iter().position(|p| p.id() == req_id);
|
||||
idx.map(|i| pending.remove(i))
|
||||
};
|
||||
if let Some(p) = answered {
|
||||
@@ -2060,7 +2060,7 @@ fn first_tag_value(event: &Event, name: &str) -> Option<String> {
|
||||
/// GUI, or tears the session down on a `session-end` signal. Fails closed and
|
||||
/// silent on anything it cannot match, decrypt, or parse.
|
||||
async fn handle_channel(svc: &Arc<NostrService>, client: &Client, event: &Event) {
|
||||
use crate::nostr::session::{self, PendingMoney, SignRequest, SignResult};
|
||||
use crate::nostr::session::{self, PendingMoney, SignRequest};
|
||||
if event.kind.as_u16() != session::CHANNEL_EVENT_KIND || event.verify().is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -2088,47 +2088,56 @@ async fn handle_channel(svc: &Arc<NostrService>, client: &Client, event: &Event)
|
||||
let Ok(val) = serde_json::from_str::<serde_json::Value>(&plaintext) else {
|
||||
return;
|
||||
};
|
||||
match val.get("type").and_then(|t| t.as_str()) {
|
||||
Some("session-end") => {
|
||||
s.end();
|
||||
ended = true;
|
||||
}
|
||||
Some("sign") => {
|
||||
let Ok(req) = serde_json::from_value::<SignRequest>(val) else {
|
||||
return;
|
||||
};
|
||||
// The signing identity's unlocked keys from the in-memory snapshot.
|
||||
let keys = svc
|
||||
.recv_snapshot()
|
||||
.into_iter()
|
||||
.find(|h| h.keys.public_key() == s.identity_pubkey)
|
||||
.map(|h| h.keys);
|
||||
match keys {
|
||||
Some(keys) => {
|
||||
let served = session::serve(s, &req, &keys, now);
|
||||
notice = served.notify_high_volume;
|
||||
if served.money_pending {
|
||||
money = Some(PendingMoney {
|
||||
domain: s.domain.clone(),
|
||||
identity_pubkey: s.identity_pubkey,
|
||||
req,
|
||||
});
|
||||
} else if let Some(json) = served.response {
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Identity no longer held: honest refusal, not a hang.
|
||||
let json = serde_json::to_string(&SignResult::refused(
|
||||
&req.id,
|
||||
session::SignError::IdentityMismatch,
|
||||
))
|
||||
.unwrap_or_default();
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
let msg_type = val.get("type").and_then(|t| t.as_str()).map(str::to_string);
|
||||
if msg_type.as_deref() == Some("session-end") {
|
||||
s.end();
|
||||
ended = true;
|
||||
} else if matches!(msg_type.as_deref(), Some("sign" | "encrypt" | "decrypt")) {
|
||||
// The signing identity's unlocked keys from the in-memory snapshot.
|
||||
let keys = svc
|
||||
.recv_snapshot()
|
||||
.into_iter()
|
||||
.find(|h| h.keys.public_key() == s.identity_pubkey)
|
||||
.map(|h| h.keys);
|
||||
let (served, op) = match (msg_type.as_deref(), &keys) {
|
||||
(Some("sign"), Some(keys)) => match serde_json::from_value::<SignRequest>(val) {
|
||||
Ok(req) => (
|
||||
session::serve(s, &req, keys, now),
|
||||
Some(session::ChannelOp::Sign(req)),
|
||||
),
|
||||
Err(_) => return,
|
||||
},
|
||||
(Some("encrypt"), Some(keys)) => {
|
||||
match serde_json::from_value::<session::EncryptRequest>(val) {
|
||||
Ok(e) => (
|
||||
session::serve_encrypt(s, &e, keys, now),
|
||||
Some(session::ChannelOp::Encrypt(e)),
|
||||
),
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
(Some("decrypt"), Some(keys)) => {
|
||||
match serde_json::from_value::<session::DecryptRequest>(val) {
|
||||
Ok(d) => (session::serve_decrypt(s, &d, keys, now), None),
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
// Identity no longer held: cannot act; drop silently (the session
|
||||
// will end and the site re-grants).
|
||||
_ => return,
|
||||
};
|
||||
notice = served.notify_high_volume;
|
||||
if served.money_pending {
|
||||
if let Some(op) = op {
|
||||
money = Some(PendingMoney {
|
||||
domain: s.domain.clone(),
|
||||
identity_pubkey: s.identity_pubkey,
|
||||
op,
|
||||
});
|
||||
}
|
||||
} else if let Some(json) = served.response {
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if ended {
|
||||
@@ -2171,7 +2180,7 @@ async fn serve_money_answers(svc: &Arc<NostrService>, client: &Client) {
|
||||
.find(|h| h.keys.public_key() == s.identity_pubkey)
|
||||
.map(|h| h.keys.clone());
|
||||
if let Some(keys) = keys {
|
||||
let json = session::complete_money(s, &pending.req, &keys, approved, now);
|
||||
let json = session::complete_money(s, &pending.op, &keys, approved, now);
|
||||
publish = s.wrap_channel_event(&json, now).ok();
|
||||
}
|
||||
}
|
||||
|
||||
+333
-38
@@ -80,8 +80,9 @@ const SEEN_IDS_CAP: usize = 4096;
|
||||
|
||||
/// The money-tier kinds: never silent, always a per-action password prompt,
|
||||
/// always stripped from a requested set. Kind 17 finalizes a purchase and grants
|
||||
/// value; it is the archetypal money action.
|
||||
const MONEY_KINDS: &[u16] = &[17];
|
||||
/// value; kind 30402 (a product listing) is a seller's commitment to a price
|
||||
/// (owner ruling: listing and buying both require the password round-trip).
|
||||
const MONEY_KINDS: &[u16] = &[17, 30402];
|
||||
|
||||
/// The flagged conversation kinds: low as messaging, but their content may
|
||||
/// commit the user to a payment, so the classifier escalates such a request to
|
||||
@@ -218,7 +219,7 @@ pub enum TrustCategory {
|
||||
Social,
|
||||
/// Direct messages: 13, 14, 16, 1059.
|
||||
DirectMessages,
|
||||
/// Listings: 30402, 30405, 30406, 31990.
|
||||
/// Listings: 30405, 30406, 31990 (30402 is money tier, owner ruling).
|
||||
Market,
|
||||
/// Profile and lists: 0, 10000, 30000, 30003, 30078.
|
||||
Identity,
|
||||
@@ -258,7 +259,8 @@ 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 | 30405 | 30406 | 31990 => Some(TrustCategory::Market),
|
||||
// 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),
|
||||
@@ -353,19 +355,77 @@ pub struct SignRequest {
|
||||
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: it hands the site the wallet's channel public key (also the
|
||||
/// signing pubkey of the envelope event, so the site can derive the conversation
|
||||
/// key) and confirms the signing identity.
|
||||
/// 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,
|
||||
/// The wallet's ephemeral channel public key (x-only hex).
|
||||
pub wallet_pubkey: String,
|
||||
/// A correlation id (the wallet channel pubkey hex; unique per session).
|
||||
pub id: String,
|
||||
/// The confirmed signing identity public key (hex).
|
||||
pub identity: String,
|
||||
pub identity_pubkey: String,
|
||||
}
|
||||
|
||||
/// The session-end envelope (either direction): the site's logout signal, or the
|
||||
@@ -423,9 +483,12 @@ impl SignResult {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Every refusal returns one of these typed codes on the channel so the site can
|
||||
/// show an honest state. The wire strings match the spec's section 7 table;
|
||||
/// `Refused` and `Malformed` are additions for the outright-refusal and
|
||||
/// unparseable cases the table folds into "sign in again".
|
||||
/// 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.
|
||||
@@ -460,7 +523,10 @@ impl SignError {
|
||||
SignError::UserDeclined => "user_declined",
|
||||
SignError::SessionPaused => "session_paused",
|
||||
SignError::SessionEnded => "session_ended",
|
||||
SignError::Refused => "refused",
|
||||
// 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",
|
||||
}
|
||||
}
|
||||
@@ -720,6 +786,42 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The session-level decision for an encrypt/decrypt op (no kind/tier by
|
||||
/// event, since there is no event): the same lifetime, pause, replay, skew,
|
||||
/// and rate gates as [`Session::decide`], then `MoneyPrompt` when `escalate`
|
||||
/// (a pay-committing encrypt plaintext) else `Silent`.
|
||||
pub fn decide_crypto(&mut self, id: &str, ts: u64, now: u64, escalate: bool) -> Decision {
|
||||
if self.ended {
|
||||
return Decision::Refuse(SignError::SessionEnded);
|
||||
}
|
||||
if self.is_expired(now) {
|
||||
self.ended = true;
|
||||
return Decision::Refuse(SignError::SessionEnded);
|
||||
}
|
||||
if self.paused {
|
||||
return Decision::Refuse(SignError::SessionPaused);
|
||||
}
|
||||
if let Some(cached) = self.seen.get(id) {
|
||||
return Decision::Duplicate(cached.clone());
|
||||
}
|
||||
if abs_diff(ts, now) > CREATED_AT_SKEW_SECS {
|
||||
return Decision::Refuse(SignError::StaleRequest);
|
||||
}
|
||||
self.trim_rate_window(now);
|
||||
if self.silent_times.len() >= RATE_HARD_PER_5MIN {
|
||||
self.paused = true;
|
||||
return Decision::Refuse(SignError::SessionPaused);
|
||||
}
|
||||
let notify = self.count_last_minute(now) >= RATE_SOFT_PER_MIN;
|
||||
if escalate {
|
||||
Decision::MoneyPrompt
|
||||
} else {
|
||||
Decision::Silent {
|
||||
notify_high_volume: notify,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a produced response for `id` (both tiers) so a replay returns it
|
||||
/// verbatim, bump the idle clock, and — for a served silent sign — count it
|
||||
/// toward the rate windows. Call this exactly once per request the wallet
|
||||
@@ -785,8 +887,8 @@ impl Session {
|
||||
pub fn session_open_event(&self, now: u64) -> Result<Event, String> {
|
||||
let open = SessionOpen {
|
||||
msg_type: "session-open".to_string(),
|
||||
wallet_pubkey: self.wallet_channel_pk.to_hex(),
|
||||
identity: self.identity_pubkey.to_hex(),
|
||||
id: self.wallet_channel_pk.to_hex(),
|
||||
identity_pubkey: self.identity_pubkey.to_hex(),
|
||||
};
|
||||
let json = serde_json::to_string(&open).map_err(|e| e.to_string())?;
|
||||
self.wrap_channel_event(&json, now)
|
||||
@@ -884,10 +986,17 @@ pub struct SessionSummary {
|
||||
pub struct PendingMoney {
|
||||
/// The trusted domain the request arrived on.
|
||||
pub domain: String,
|
||||
/// The signing identity for this session (looked up to sign on approval).
|
||||
/// The signing identity for this session (looked up to act on approval).
|
||||
pub identity_pubkey: PublicKey,
|
||||
/// The full request, replayed verbatim to sign on approval.
|
||||
pub req: SignRequest,
|
||||
/// The operation to perform on approval (a sign, or a pay-committing encrypt).
|
||||
pub op: ChannelOp,
|
||||
}
|
||||
|
||||
impl PendingMoney {
|
||||
/// The correlation id, used to route the user's answer.
|
||||
pub fn id(&self) -> &str {
|
||||
self.op.id()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -954,31 +1063,146 @@ pub fn serve(session: &mut Session, req: &SignRequest, sign_keys: &Keys, now: u6
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete a money-tier request after the user answered the password prompt.
|
||||
/// `approved` true signs the event and returns the `sign_result`; false returns
|
||||
/// the `user_declined` refusal. Either way the result is remembered so a replay
|
||||
/// of the same id returns it verbatim. Money signs are individually gated and so
|
||||
/// 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,
|
||||
req: &SignRequest,
|
||||
sign_keys: &Keys,
|
||||
op: &ChannelOp,
|
||||
keys: &Keys,
|
||||
approved: bool,
|
||||
now: u64,
|
||||
) -> String {
|
||||
let result = if approved {
|
||||
match sign_session_event(sign_keys, &req.event, now) {
|
||||
Ok(ev) => SignResult::ok(&req.id, &ev),
|
||||
Err(err) => SignResult::refused(&req.id, err),
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SignResult::refused(&req.id, SignError::UserDeclined)
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap_or_default();
|
||||
session.remember(&req.id, &json, false, now);
|
||||
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::Refuse(err) => {
|
||||
let json = crypto_error("encrypt_result", &e.id, err);
|
||||
session.remember(&e.id, &json, false, now);
|
||||
served_response(json)
|
||||
}
|
||||
Decision::MoneyPrompt => Served {
|
||||
response: None,
|
||||
notify_high_volume: false,
|
||||
money_pending: true,
|
||||
},
|
||||
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,
|
||||
money_pending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a decrypt op (see [`serve_encrypt`]; decrypt never escalates).
|
||||
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::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 { notify_high_volume } => {
|
||||
let json = perform_decrypt(d, keys);
|
||||
session.remember(&d.id, &json, true, now);
|
||||
Served {
|
||||
response: Some(json),
|
||||
notify_high_volume,
|
||||
money_pending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Served` carrying just a response JSON.
|
||||
fn served_response(json: String) -> Served {
|
||||
Served {
|
||||
response: Some(json),
|
||||
notify_high_volume: false,
|
||||
money_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -990,14 +1214,18 @@ mod tests {
|
||||
#[test]
|
||||
fn money_kind_always_money() {
|
||||
assert_eq!(classify(17, ""), Tier::Money);
|
||||
// Owner ruling: a product listing (30402) is a commitment, money tier.
|
||||
assert_eq!(classify(30402, ""), Tier::Money);
|
||||
assert!(is_money_kind(17));
|
||||
assert!(is_money_kind(30402));
|
||||
assert!(!is_money_kind(1));
|
||||
assert!(!is_money_kind(30405));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_low_kinds_are_low() {
|
||||
for k in [
|
||||
0u16, 1, 5, 7, 1111, 10000, 30000, 30003, 30078, 30402, 24242, 27235,
|
||||
0u16, 1, 5, 7, 1111, 10000, 30000, 30003, 30078, 30405, 30406, 24242, 27235,
|
||||
] {
|
||||
assert_eq!(classify(k, low_content()), Tier::Low, "kind {k}");
|
||||
}
|
||||
@@ -1047,7 +1275,9 @@ mod tests {
|
||||
assert_eq!(category_for_kind(1111), Some(TrustCategory::Social));
|
||||
assert_eq!(category_for_kind(13), Some(TrustCategory::DirectMessages));
|
||||
assert_eq!(category_for_kind(1059), Some(TrustCategory::DirectMessages));
|
||||
assert_eq!(category_for_kind(30402), Some(TrustCategory::Market));
|
||||
// 30402 (listing) is money tier by owner ruling, not a low category.
|
||||
assert_eq!(category_for_kind(30402), None);
|
||||
assert_eq!(category_for_kind(30405), Some(TrustCategory::Market));
|
||||
assert_eq!(category_for_kind(31990), Some(TrustCategory::Market));
|
||||
assert_eq!(category_for_kind(0), Some(TrustCategory::Identity));
|
||||
assert_eq!(category_for_kind(30078), Some(TrustCategory::Identity));
|
||||
@@ -1061,7 +1291,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn render_grant_dedups_orders_and_flags_ceiling() {
|
||||
let d = render_grant(&[1, 7, 1059, 30402, 22242, 17, 55555]);
|
||||
let d = render_grant(&[1, 7, 1059, 30405, 30402, 22242, 17, 55555]);
|
||||
assert_eq!(
|
||||
d.categories,
|
||||
vec![
|
||||
@@ -1072,6 +1302,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(d.unknown_kinds, vec![55555]);
|
||||
assert!(d.stripped_login);
|
||||
// 30402 is money now: stripped, never an unknown/caution line.
|
||||
assert!(!d.unknown_kinds.contains(&30402));
|
||||
// Money kind 17 appears nowhere: not a category, not an unknown line.
|
||||
assert!(!d.unknown_kinds.contains(&17));
|
||||
}
|
||||
@@ -1428,7 +1660,7 @@ mod tests {
|
||||
assert!(out.money_pending);
|
||||
assert!(out.response.is_none(), "money pends: nothing published yet");
|
||||
// User approves -> signed result.
|
||||
let json = complete_money(&mut s, &req, &id, true, now);
|
||||
let json = complete_money(&mut s, &ChannelOp::Sign(req.clone()), &id, true, now);
|
||||
assert!(json.contains("\"ok\":true"));
|
||||
// A replay of the same id now returns the cached signed result.
|
||||
match s.decide(&req, now) {
|
||||
@@ -1437,7 +1669,7 @@ mod tests {
|
||||
}
|
||||
// A fresh money request the user declines -> user_declined.
|
||||
let req2 = mk_req(&id, 17, "{}", now, "mv2");
|
||||
let declined = complete_money(&mut s, &req2, &id, false, now);
|
||||
let declined = complete_money(&mut s, &ChannelOp::Sign(req2), &id, false, now);
|
||||
assert!(declined.contains("\"error\":\"user_declined\""));
|
||||
}
|
||||
|
||||
@@ -1454,4 +1686,67 @@ mod tests {
|
||||
.contains("\"error\":\"kind_not_in_session\"")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_roundtrips_and_escalates_on_payment() {
|
||||
let now = 1_751_800_000u64;
|
||||
let (mut s, id) = mk_session(&[13], now);
|
||||
let peer = Keys::generate();
|
||||
let e = EncryptRequest {
|
||||
msg_type: "encrypt".to_string(),
|
||||
id: "e1".to_string(),
|
||||
ts: now,
|
||||
peer_pubkey: peer.public_key().to_hex(),
|
||||
plaintext: "hello".to_string(),
|
||||
};
|
||||
let out = serve_encrypt(&mut s, &e, &id, now);
|
||||
assert!(!out.money_pending);
|
||||
let json = out.response.unwrap();
|
||||
assert!(json.contains("\"type\":\"encrypt_result\""));
|
||||
assert!(json.contains("\"ok\":true"));
|
||||
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
let ct = v["ciphertext"].as_str().unwrap();
|
||||
let back = nip44::decrypt(peer.secret_key(), &id.public_key(), ct).unwrap();
|
||||
assert_eq!(back, "hello");
|
||||
// A pay-committing plaintext escalates to the money prompt at encrypt time.
|
||||
let e2 = EncryptRequest {
|
||||
msg_type: "encrypt".to_string(),
|
||||
id: "e2".to_string(),
|
||||
ts: now,
|
||||
peer_pubkey: peer.public_key().to_hex(),
|
||||
plaintext: r#"{"payment":{"bolt11":"lnbc1"}}"#.to_string(),
|
||||
};
|
||||
let out = serve_encrypt(&mut s, &e2, &id, now);
|
||||
assert!(out.money_pending);
|
||||
assert!(out.response.is_none());
|
||||
let json = complete_money(&mut s, &ChannelOp::Encrypt(e2), &id, true, now);
|
||||
assert!(json.contains("\"type\":\"encrypt_result\""));
|
||||
assert!(json.contains("\"ok\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_reads_a_peer_message_and_is_low_tier() {
|
||||
let now = 1_751_800_000u64;
|
||||
let (mut s, id) = mk_session(&[13], now);
|
||||
let peer = Keys::generate();
|
||||
let ct = nip44::encrypt(
|
||||
peer.secret_key(),
|
||||
&id.public_key(),
|
||||
"secret",
|
||||
nip44::Version::V2,
|
||||
)
|
||||
.unwrap();
|
||||
let d = DecryptRequest {
|
||||
msg_type: "decrypt".to_string(),
|
||||
id: "d1".to_string(),
|
||||
ts: now,
|
||||
peer_pubkey: peer.public_key().to_hex(),
|
||||
ciphertext: ct,
|
||||
};
|
||||
let out = serve_decrypt(&mut s, &d, &id, now);
|
||||
assert!(!out.money_pending);
|
||||
let json = out.response.unwrap();
|
||||
assert!(json.contains("\"type\":\"decrypt_result\""));
|
||||
assert!(json.contains("\"plaintext\":\"secret\""));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user