Skip to content

Messaging Transport Abstraction Layer — Gift Wrap Apocalypse Contingency Plan #626

Description

@mostronatorcoder

Summary

Mostro currently has NIP-59 (Gift Wrap) deeply embedded throughout the codebase as the sole messaging transport. While NIP-59 provides excellent metadata privacy, it introduces a critical vulnerability: relays cannot distinguish legitimate gift wraps from spam, making Mostro susceptible to a "Gift Wrap Apocalypse" — a coordinated spam attack that could overwhelm relays and/or the Mostro daemon itself.

This issue proposes a Messaging Transport Abstraction Layer (MTAL) that decouples the transport mechanism from business logic, allowing Mostro to:

  1. Switch messaging transports via configuration (no code changes)
  2. Support alternative transports like NIP-04 + NIP-44 encryption as fallback
  3. Run in dual-mode during migration periods
  4. Add transport-level anti-spam defenses

Threat Model

Attack Vectors

Vector Description Impact
Relay flooding Millions of garbage gift wraps sent to relays Relay storage exhaustion, relay operators drop GiftWrap support
User targeting Gift wraps sent to a specific pubkey (e.g., Mostro's) CPU exhaustion on decrypt attempts, message queue saturation
Mostro-specific Valid-looking gift wraps with garbage content targeting Mostro's pubkey Mostro wastes resources on extract_rumor() + deserialization for every event

Current Mitigations (insufficient)

  • POW requirement: Mostro checks event.check_pow(pow) before processing — raises cost for attackers but doesn't prevent determined spam
  • Timestamp filter: Events older than 10 seconds are discarded — prevents replay but not fresh spam
  • Relay limits: max_size: 6000/6500 — limits individual event size but not volume

Why This Is Critical

Unlike NIP-04/NIP-17 events where relays can see sender/receiver and apply rate limiting, NIP-59 gift wraps are opaque. Relays have no way to:

  • Rate-limit by sender (random ephemeral key per gift wrap)
  • Apply spam filters
  • Distinguish Mostro traffic from noise

If major relay operators start rejecting Kind::GiftWrap due to abuse, Mostro becomes inoperable.

Current State Analysis

NIP-59 Coupling Points

NIP-59 is embedded at three layers:

Layer 1: Event Reception (src/app.rs — event loop)

src/app.rs:312  →  if let Kind::GiftWrap = event.kind { ... }
src/app.rs:318  →  nip59::extract_rumor(&my_keys, &event)
src/app.rs:330  →  event.rumor.created_at (timestamp validation)
src/app.rs:335  →  event.rumor.content (message parsing)
src/app.rs:353  →  event.sender == event.rumor.pubkey (identity check)
src/main.rs:70  →  Filter::new().kind(Kind::GiftWrap) (subscription)

Layer 2: Message Sending (src/util.rssend_dm())

src/util.rs:511-549  →  send_dm() function
  - Builds rumor via EventBuilder::text_note()
  - Creates gift wrap via EventBuilder::gift_wrap()
  - Only send function in the entire codebase

All outbound messages flow through send_dm():

  • src/scheduler.rs:64 — message queue flushing (ALL queued messages)
  • src/app/admin_add_solver.rs:53 — admin responses
  • src/app/admin_cancel.rs:148,162,165 — cancel notifications
  • src/app/admin_take_dispute.rs:175,195,205 — dispute notifications
  • src/app/last_trade_index.rs:93 — trade index responses

Layer 3: Handler Signatures (UnwrappedGift as parameter type)

Every action handler receives event: &UnwrappedGift, directly accessing event.sender (identity pubkey) and event.rumor.pubkey (trade pubkey). 28 function signatures across 17 files:

File Functions Access patterns
src/app.rs manage_errors, check_trade_index, handle_message_action event.sender, event.rumor.pubkey, event.rumor.content, event.rumor.created_at
src/app/order.rs order_action event.sender, event.rumor.pubkey
src/app/take_sell.rs take_sell_action event.sender, event.rumor.pubkey
src/app/take_buy.rs take_buy_action event.sender, event.rumor.pubkey
src/app/cancel.rs 8 cancel functions event.rumor.pubkey (extensively)
src/app/release.rs release_action event.rumor.pubkey
src/app/fiat_sent.rs fiat_sent_action event.rumor.pubkey
src/app/add_invoice.rs add_invoice_action event.rumor.pubkey
src/app/dispute.rs dispute_action event.rumor.pubkey
src/app/rate_user.rs update_user_reputation_action event.rumor.pubkey
src/app/admin_cancel.rs admin_cancel_action event.sender, event.rumor.pubkey
src/app/admin_settle.rs admin_settle_action event.sender, event.rumor.pubkey
src/app/admin_add_solver.rs admin_add_solver_action event.sender, event.rumor.pubkey
src/app/admin_take_dispute.rs admin_take_dispute_action event.sender
src/app/trade_pubkey.rs trade_pubkey_action event.sender, event.rumor.pubkey
src/app/restore_session.rs restore_session_action event.sender, event.rumor.pubkey
src/app/last_trade_index.rs last_trade_index event.sender, event.rumor.pubkey
src/app/orders.rs orders_action event.sender, event.rumor.pubkey
src/util.rs settle_seller_hold_invoice event.rumor.pubkey
src/rpc/service.rs RPC handlers (4 functions) Constructs fake UnwrappedGift

What's Already Clean

  • mostro-core::Message: Fully transport-agnostic. JSON struct with no NIP-59 knowledge.
  • Business logic (flow.rs, db.rs, lightning/): Zero NIP-59 coupling.
  • nip33.rs: Addressable events (order book, ratings) — independent of messaging transport.
  • scheduler.rs: Only touches NIP-59 via send_dm() in message queue flushing.

Implementation Plan

Phase 1: Transport Abstraction (no behavior change)

Goal: Replace all UnwrappedGift usage with a transport-agnostic IncomingMessage type and abstract send/receive behind a trait. Zero protocol changes, identical behavior.

Step 1.1: Define the abstraction (src/transport/mod.rs)

Create a new transport module:

// src/transport/mod.rs

pub mod gift_wrap;
// Future: pub mod direct_encrypted;

use mostro_core::error::MostroError;
use nostr_sdk::prelude::*;

/// Transport-agnostic representation of an incoming message.
/// Replaces `nostr::nips::nip59::UnwrappedGift` throughout the codebase.
#[derive(Debug, Clone)]
pub struct IncomingMessage {
    /// Identity public key of the sender (master key).
    /// In NIP-59: this is `UnwrappedGift.sender`
    /// In NIP-04+NIP-44: this would be the event author
    pub sender: PublicKey,

    /// Trade public key used for this specific operation.
    /// In NIP-59: this is `UnwrappedGift.rumor.pubkey`
    /// In NIP-04+NIP-44: extracted from the signed message content
    pub trade_pubkey: PublicKey,

    /// Raw content string (JSON-serialized Message + optional Signature)
    pub content: String,

    /// When the message was created
    pub created_at: Timestamp,
}

/// Trait for pluggable messaging transports.
#[async_trait::async_trait]
pub trait MessageTransport: Send + Sync + 'static {
    /// Returns the Nostr event kind(s) this transport listens for.
    fn subscription_kinds(&self) -> Vec<Kind>;

    /// Attempt to extract a message from a raw Nostr event.
    /// Returns `None` if the event is not for this transport.
    async fn extract_message(
        &self,
        keys: &Keys,
        event: &Event,
    ) -> Result<Option<IncomingMessage>, MostroError>;

    /// Send an encrypted message to a recipient.
    async fn send_message(
        &self,
        sender_keys: &Keys,
        receiver: &PublicKey,
        payload: &str,
        expiration: Option<Timestamp>,
    ) -> Result<Event, MostroError>;
}

Step 1.2: Implement GiftWrapTransport (src/transport/gift_wrap.rs)

Encapsulate current NIP-59 logic:

// src/transport/gift_wrap.rs

use super::{IncomingMessage, MessageTransport};
// ... imports ...

pub struct GiftWrapTransport;

#[async_trait::async_trait]
impl MessageTransport for GiftWrapTransport {
    fn subscription_kinds(&self) -> Vec<Kind> {
        vec![Kind::GiftWrap]
    }

    async fn extract_message(
        &self,
        keys: &Keys,
        event: &Event,
    ) -> Result<Option<IncomingMessage>, MostroError> {
        if event.kind != Kind::GiftWrap {
            return Ok(None);
        }

        if event.verify().is_err() {
            return Err(/* ... */);
        }

        let unwrapped = nip59::extract_rumor(keys, event).await
            .map_err(/* ... */)?;

        Ok(Some(IncomingMessage {
            sender: unwrapped.sender,
            trade_pubkey: unwrapped.rumor.pubkey,
            content: unwrapped.rumor.content,
            created_at: unwrapped.rumor.created_at,
        }))
    }

    async fn send_message(
        &self,
        sender_keys: &Keys,
        receiver: &PublicKey,
        payload: &str,
        expiration: Option<Timestamp>,
    ) -> Result<Event, MostroError> {
        // Current send_dm() logic moved here
        let content = /* ... build (Message, Option<Signature>) tuple ... */;
        let rumor = EventBuilder::text_note(content)
            .build(sender_keys.public_key());
        let mut tags = Vec::new();
        if let Some(ts) = expiration {
            tags.push(Tag::expiration(ts));
        }
        let event = EventBuilder::gift_wrap(
            sender_keys, receiver, rumor, Tags::from_list(tags)
        ).await?;
        Ok(event)
    }
}

Step 1.3: Refactor send_dm() → use transport (src/util.rs)

Current send_dm() (lines 511-549) becomes a thin wrapper:

// Before:
pub async fn send_dm(receiver_pubkey, sender_keys, payload, expiration) {
    // ... build rumor, gift_wrap, send ...
}

// After:
pub async fn send_dm(receiver_pubkey, sender_keys, payload, expiration) {
    let transport = get_transport(); // global or injected
    let event = transport.send_message(sender_keys, &receiver_pubkey, payload, expiration).await?;
    if let Ok(client) = get_nostr_client() {
        client.send_event(&event).await?;
    }
    Ok(())
}

No callers change — send_dm() signature stays identical.

Step 1.4: Refactor event loop (src/app.rs:run())

Current loop (lines 305-375) hardcodes Kind::GiftWrap and nip59::extract_rumor. Refactor to use transport:

// Before (app.rs:312-318):
if let Kind::GiftWrap = event.kind {
    if event.verify().is_err() { ... }
    let event = match nip59::extract_rumor(&my_keys, &event).await { ... };
    // ... timestamp check, content parsing, signature verification ...
}

// After:
let transport = get_transport();
if let Some(incoming) = transport.extract_message(&my_keys, &event).await? {
    // Timestamp validation
    if incoming.created_at.as_u64() < since_time { continue; }
    // Content parsing (identical logic, just reads from incoming.content)
    let (message, sig) = serde_json::from_str::<(Message, Option<Signature>)>(&incoming.content)?;
    // Signature verification using incoming.sender and incoming.trade_pubkey
    // ... rest of the logic identical but using incoming instead of event ...
}

Step 1.5: Replace UnwrappedGift with IncomingMessage in all handlers

This is the largest mechanical change. For each of the 28 functions:

  1. Change parameter type: event: &UnwrappedGiftevent: &IncomingMessage
  2. Replace field access:
    • event.senderevent.sender (same field name, no change needed)
    • event.rumor.pubkeyevent.trade_pubkey
    • event.rumor.contentevent.content
    • event.rumor.created_atevent.created_at
  3. Update imports: remove use nostr::nips::nip59::UnwrappedGift, add use crate::transport::IncomingMessage

Detailed file-by-file changes:

File Lines to change Key replacements
src/app.rs ~30 lines Refactor run(), update manage_errors, check_trade_index, handle_message_action signatures
src/app/order.rs ~8 lines event.rumor.pubkeyevent.trade_pubkey, event.sender stays
src/app/take_sell.rs ~12 lines Same pattern + test mock updates
src/app/take_buy.rs ~10 lines Same pattern
src/app/cancel.rs ~20 lines 8 functions, all use event.rumor.pubkeyevent.trade_pubkey
src/app/release.rs ~4 lines Single check event.rumor.pubkey
src/app/fiat_sent.rs ~4 lines event.rumor.pubkey in check and payload
src/app/add_invoice.rs ~3 lines Single event.rumor.pubkey check
src/app/dispute.rs ~3 lines event.rumor.pubkey for sender check
src/app/rate_user.rs ~4 lines event.rumor.pubkey for rating
src/app/admin_cancel.rs ~6 lines Both event.sender and event.rumor.pubkey
src/app/admin_settle.rs ~4 lines Both fields
src/app/admin_add_solver.rs ~4 lines event.sender check + event.rumor.pubkey for response
src/app/admin_take_dispute.rs ~8 lines event.sender extensively
src/app/trade_pubkey.rs ~6 lines Both fields for pubkey rotation
src/app/restore_session.rs ~3 lines Both fields
src/app/last_trade_index.rs ~6 lines Both fields + test mock
src/app/orders.rs ~3 lines event.sender for user ID
src/util.rs ~3 lines settle_seller_hold_invoice
src/rpc/service.rs ~20 lines 4 functions that construct mock UnwrappedGift → construct IncomingMessage

Step 1.6: Update subscription in main.rs

// Before (main.rs:68-71):
let subscription = Filter::new()
    .pubkey(mostro_keys.public_key())
    .kind(Kind::GiftWrap)
    .limit(0);

// After:
let transport = get_transport();
let mut filter = Filter::new()
    .pubkey(mostro_keys.public_key())
    .limit(0);
for kind in transport.subscription_kinds() {
    filter = filter.kind(kind);
}
let subscription = filter;

Step 1.7: Transport initialization

Add to config (settings.toml):

[mostro]
# Transport options: "gift-wrap" (default), "direct-encrypted", "dual"
transport = "gift-wrap"

Initialize in main.rs:

let transport: Arc<dyn MessageTransport> = match config.transport.as_str() {
    "gift-wrap" => Arc::new(GiftWrapTransport),
    "direct-encrypted" => Arc::new(DirectEncryptedTransport),
    "dual" => Arc::new(DualTransport::new(
        GiftWrapTransport,
        DirectEncryptedTransport,
    )),
    _ => Arc::new(GiftWrapTransport), // default fallback
};

// Store globally (similar to NOSTR_CLIENT)
TRANSPORT.set(transport).expect("Transport already initialized");

Estimated scope: ~200-300 lines of new code (transport module), ~150-200 lines of mechanical replacements across handlers. Total: ~400-500 lines changed.

Phase 2: Alternative Transport — NIP-04 + NIP-44

Goal: Implement DirectEncryptedTransport as a ready-to-use fallback.

Design: NIP-04 structure + NIP-44 encryption

  • Event kind: Kind::Custom(4) (NIP-04 structure — sender/receiver visible to relays)
  • Encryption: NIP-44 (modern, authenticated encryption — replaces NIP-04's weak CBC scheme)
  • Content: Same (Message, Option<Signature>) JSON tuple, encrypted with NIP-44
  • Metadata tradeoff: Sender/receiver pubkeys visible → relays CAN rate-limit. Since Mostro users rotate trade keys per operation, metadata leakage is bounded to a single trade.
// src/transport/direct_encrypted.rs

pub struct DirectEncryptedTransport;

#[async_trait::async_trait]
impl MessageTransport for DirectEncryptedTransport {
    fn subscription_kinds(&self) -> Vec<Kind> {
        vec![Kind::Custom(4)]
    }

    async fn extract_message(
        &self,
        keys: &Keys,
        event: &Event,
    ) -> Result<Option<IncomingMessage>, MostroError> {
        if event.kind != Kind::Custom(4) {
            return Ok(None);
        }

        // Decrypt content using NIP-44
        let decrypted = nip44::decrypt(
            keys.secret_key()?,
            &event.pubkey,
            &event.content,
        )?;

        // Parse the inner message structure
        // For direct encrypted, sender == event author
        // trade_pubkey is extracted from the signed message content
        let (message, sig): (Message, Option<Signature>) =
            serde_json::from_str(&decrypted)?;

        // Trade pubkey: if signature present, it was signed by trade key
        // otherwise trade_pubkey == sender
        let trade_pubkey = if let Some(sig) = &sig {
            // Extract trade pubkey from message verification
            // (trade key signs the message, identity key wraps the event)
            extract_trade_pubkey_from_sig(&message, sig)?
        } else {
            event.pubkey  // Full privacy mode: identity == trade
        };

        Ok(Some(IncomingMessage {
            sender: event.pubkey,
            trade_pubkey,
            content: decrypted,
            created_at: event.created_at,
        }))
    }

    async fn send_message(
        &self,
        sender_keys: &Keys,
        receiver: &PublicKey,
        payload: &str,
        expiration: Option<Timestamp>,
    ) -> Result<Event, MostroError> {
        // Encrypt with NIP-44
        let encrypted = nip44::encrypt(
            sender_keys.secret_key()?,
            receiver,
            payload,
            nip44::Version::V2,
        )?;

        let mut tags = vec![Tag::public_key(*receiver)];
        if let Some(ts) = expiration {
            tags.push(Tag::expiration(ts));
        }

        let event = EventBuilder::new(Kind::Custom(4), encrypted)
            .tags(Tags::from_list(tags))
            .sign_with_keys(sender_keys)?;

        Ok(event)
    }
}

Step 2.2: Dual-Mode Transport

For migration periods, Mostro can accept both formats:

// src/transport/dual.rs

pub struct DualTransport {
    gift_wrap: GiftWrapTransport,
    direct: DirectEncryptedTransport,
}

#[async_trait::async_trait]
impl MessageTransport for DualTransport {
    fn subscription_kinds(&self) -> Vec<Kind> {
        let mut kinds = self.gift_wrap.subscription_kinds();
        kinds.extend(self.direct.subscription_kinds());
        kinds
    }

    async fn extract_message(&self, keys: &Keys, event: &Event)
        -> Result<Option<IncomingMessage>, MostroError>
    {
        // Try gift wrap first, then direct encrypted
        if let Some(msg) = self.gift_wrap.extract_message(keys, event).await? {
            return Ok(Some(msg));
        }
        self.direct.extract_message(keys, event).await
    }

    async fn send_message(&self, /* ... */) -> Result<Event, MostroError> {
        // Send using the preferred/configured outbound transport
        // Could be configurable: "send as gift_wrap" or "send as direct"
        self.gift_wrap.send_message(/* ... */).await
    }
}

Step 2.3: Protocol Documentation Update

Update protocol/src/overview.md to document:

  • Both transport options
  • When to use each
  • Migration guide for client developers
  • Dual-mode behavior specification

Estimated scope: ~300-400 lines of new code.

Phase 3: Anti-Spam Hardening (defense in depth)

Goal: Regardless of transport, add application-level defenses against spam.

Step 3.1: Enhanced POW management

Make POW dynamically adjustable without restart:

// src/config/settings.rs — add dynamic POW adjustment

pub struct DynamicPow {
    base_pow: u8,
    current_pow: AtomicU8,
    failed_decrypts_per_minute: AtomicU64,
    last_reset: AtomicU64,
}

impl DynamicPow {
    /// If failed decrypt rate exceeds threshold, auto-increase POW
    pub fn check_and_adjust(&self) {
        let rate = self.failed_decrypts_per_minute.load(Ordering::Relaxed);
        if rate > 100 {
            let new_pow = (self.current_pow.load(Ordering::Relaxed) + 2).min(32);
            self.current_pow.store(new_pow, Ordering::Relaxed);
            tracing::warn!("🚨 Spam detected: {} failed decrypts/min, POW raised to {}", rate, new_pow);
        }
    }
}

Step 3.2: Bloom filter for processed events

Prevent re-processing of events seen in the current session:

// Already partially handled by Nostr SDK, but add application-level dedup
use probabilistic_collections::bloom::BloomFilter;

pub struct EventDedup {
    filter: RwLock<BloomFilter<EventId>>,
    count: AtomicU64,
}

Step 3.3: Circuit breaker

If spam rate exceeds threshold, temporarily pause processing and alert:

pub struct CircuitBreaker {
    state: AtomicU8, // 0=closed, 1=open, 2=half-open
    failure_count: AtomicU64,
    threshold: u64,
    cooldown_secs: u64,
}

Step 3.4: Metrics and observability

Add counters for monitoring:

  • messages_received_total (by transport type)
  • messages_decrypted_success
  • messages_decrypted_failure (potential spam indicator)
  • messages_processed_total (by action type)
  • pow_current_difficulty

Estimated scope: ~200-300 lines of new code.

File Impact Summary

File Phase 1 Phase 2 Phase 3
src/transport/mod.rs NEW
src/transport/gift_wrap.rs NEW
src/transport/direct_encrypted.rs NEW
src/transport/dual.rs NEW
src/main.rs Modified
src/app.rs Modified
src/util.rs Modified
src/app/*.rs (17 files) Modified (type changes)
src/rpc/service.rs Modified
src/config/settings.rs Modified (transport config) Modified (dynamic POW)
src/config/types.rs Modified (transport enum)
src/lib.rs Modified (module declaration)

Migration Strategy

Phase 1 (transport="gift-wrap") ← current behavior, zero risk
    ↓
Phase 2 (transport="dual") ← accept both, send as gift-wrap
    ↓
If apocalypse hits: (transport="direct-encrypted") ← instant switch via config

Client developers would need to support the alternative transport, but the Message protocol layer remains identical — only the Nostr event wrapping changes.

Priority

  • Phase 1: High — pure refactoring, no behavior change, enables all future flexibility
  • Phase 2: Medium — implement when Phase 1 is merged, keep as "break glass" option
  • Phase 3: Low-Medium — independent of transport, can be done in parallel

Acceptance Criteria

  • Phase 1: All existing tests pass with zero behavior change. UnwrappedGift type no longer appears outside src/transport/gift_wrap.rs.
  • Phase 2: DirectEncryptedTransport passes same test suite. DualTransport correctly handles mixed traffic.
  • Phase 3: POW auto-adjustment, event dedup, and circuit breaker are configurable and tested.
  • Documentation updated in protocol/ repository reflecting both transport options.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions