Skip to content

Latest commit

 

History

History
264 lines (216 loc) · 12.3 KB

File metadata and controls

264 lines (216 loc) · 12.3 KB

Transport v2 — NIP-44 Direct Messaging (Protocol v2)

Status: Phases 1–2 implemented · Phases 3–4 pending Issue: #626 — Messaging Transport Abstraction Layer Full proposal: issue comment Core implementation: mostro-core#152, released in mostro-core 0.13.0 (transport module)

1. Context and motivation

Mostro historically used NIP-59 Gift Wrap (kind 1059) as its only wire transport. Gift wraps give strong metadata privacy, but they are opaque: the outer event is signed by a random throwaway key, so neither relays nor the daemon can tell legitimate traffic from garbage without paying the full decrypt cost. That makes Mostro vulnerable to a "Gift Wrap Apocalypse" — spam floods that relays cannot rate-limit by sender and that force the daemon to attempt NIP-44 decryption on every event (see the threat model in issue #626).

The accepted direction (issue discussion): trade abuse-resistance for a bounded amount of metadata. Mostro already rotates trade keys per trade — the publicly exposed key for a given trade is short-lived, single-purpose and never reused — so a visible, rate-limitable envelope leaks little, while enabling:

  • relay-side rate limiting by sender pubkey, and
  • daemon-side cheap pre-validation before decrypting (Phase 2).

Protocol v2 is that envelope: a signed kind-14 event whose content is NIP-44 encrypted. Protocol v1 (gift wrap) is frozen and DEPRECATED.

2. Wire format (protocol v2)

2.1 Visible envelope

What relays and observers see:

{
  "kind": 14,
  "pubkey": "<index N pubkey (trade key)>",
  "content": "<NIP-44 ciphertext>",
  "tags": [
    ["p", "<Mostro's pubkey>"],
    ["expiration", "<unix timestamp>"]
  ],
  "created_at": 1234567890,
  "sig": "<trade key signature>"
}
  • Author = trade key. The event signature proves trade-key authorship (unlike v1, where the outer event is signed by a throwaway ephemeral key). This is what makes the transport rate-limitable and pre-filterable.
  • expiration (NIP-40): trade messages are only relevant for the lifetime of a trade plus a dispute window, so they always carry an expiration tag (default 30 days, dm_days setting) instead of sitting on relays forever.
  • Mostro → user direction: Mostro authors the event with its own well-known key, p-tagged to the user's trade key. Clients can subscribe with authors=[mostro] AND #p=[trade keys].
  • NIP-17 deviation (deliberate): NIP-17 defines kind 14 as an unsigned rumor that only travels inside a gift wrap. Mostro publishes it signed, because the author is an ephemeral single-trade key — the association the NIP-17 rule protects against is intentional and bounded. These events are not standard NIP-17 chats.

2.2 Encrypted content

The NIP-44 conversation key is derived from (trade key ↔ counterparty), so only the two parties can decrypt. The plaintext is a JSON 3-element tuple — v1's 2-tuple plus an identity proof:

[
  { "order": { "version": 2, "...": "..." } },
  "<trade_sig | null>",
  ["<identity pubkey>", "<identity_sig>"]   // or null
]
element meaning
1 the logical Message (unchanged from v1, but version: 2)
2 trade key's Message::sign over the serialized first element, or null (Mostro's own messages are unsigned, as in v1)
3 identity proof [identity_pubkey, identity_sig], or null for full-privacy mode (identity = trade key, mirroring v1's unsigned-rumor convention)

2.3 Identity proof

In v1 the long-lived identity key is carried authenticated by the seal (identity = seal.pubkey, hidden inside the wrap). v2 has no seal, so the identity travels inside the ciphertext — never visible at the event level, exactly as private as before — proven by a signature over the domain-tagged payload:

mostro-transport-v2-identity:<trade_pubkey_hex>:<message_json>

Including the trade pubkey binds the proof to the specific trade key authoring the event (the binding v1 gets from the seal signature covering the encrypted rumor). Signing the message JSON alone would let any party that sees a plaintext tuple — the receiving node, or a compromised one — graft the (identity_pubkey, identity_sig) pair onto an event authored by a different trade key and have the identity misattributed. The receiver recomputes the payload from event.pubkey, so a grafted proof fails verification. (Found by review on mostro-core#152; regression-tested there.)

The signature scheme is the existing Message::sign / Message::verify_signature (Schnorr over sha256). The identity key signs once per message — the same custody model as v1, where it signs every seal.

3. Versioning

  • Message.version is 2 (mostro-core PROTOCOL_VER, since 0.13.0).
  • v1 = gift wrap + 2-tuple, frozen. v2 = kind-14 direct + 3-tuple.
  • Which parser applies is keyed off the event kind (1059 vs 14), not the version field. mostro-core's unwrap_incoming() dispatches and returns the same UnwrappedMessage for both, which is why daemon handlers needed no changes.

4. Operator configuration — one transport per node

There is no dual mode: a node speaks exactly one protocol version.

[mostro]
# "gift-wrap" (protocol v1, DEPRECATED) | "nip44" (protocol v2)
transport = "gift-wrap"

[expiration]
# kind-14 direct messages
dm_days = 30
transport event kind who can trade on this node
gift-wrap (default in 0.18.x) 1059 (v1) every current client — wire behavior identical to pre-v2 daemons
nip44 14 (v2) v2-capable clients only — the only mode from v0.19.0

Capability discovery: the node advertises its protocol in the kind 38385 instance-info event with a protocol_version tag ("1" or "2", derived from transport). Old clients ignore the unknown tag; v2-capable clients check it and use the matching wire format — a client implementation should keep both wrap paths (mostro-core ships both) to talk to v1 and v2 nodes during the transition.

Switching a community to v2 is a deliberate operator decision, coordinated with the clients that community uses.

5. Release timeline

  • v0.18.0 — protocol v2 ships. Default transport = "gift-wrap" (nothing changes for existing clients). Protocol v1 is DEPRECATED: announced in release notes, protocol docs and the protocol_version tag. Client developers have the 0.18.x cycle to ship v2.
  • v0.19.0 — protocol v2 becomes the default and only protocol. Everything v1-related is removed from mostrod (gift-wrap path, "gift-wrap" setting value, v1 acceptance). mostro-core keeps its gift-wrap helpers for clients' own migration needs.

6. Implementation phases

Phase 0 — mostro-core (DONE — mostro-core#152, released 0.13.0)

The bulk of the work, all additive, in mostro-core's transport module:

  • wrap_message_nip44 / unwrap_message_nip44 — the v2 wrap/unwrap pair (Ok(None) keeps its "not addressed to me" meaning).
  • unwrap_incoming — kind dispatch returning the same UnwrappedMessage for both transports.
  • wrap_message_with — send-side dispatcher.
  • Transport enum — serde/FromStr for the config values, event_kind(), protocol_version(). Default GiftWrap.
  • PROTOCOL_VER 1 → 2; v1 fixtures kept as parse-regression tests.
  • Identity proof bound to the trade key via the domain-tagged payload (§2.3), with a grafting regression test.

Phase 1 — mostrod wiring (DONE — this change)

Minimal daemon integration; zero handler changes by design:

  • mostro-core 0.12.1 → 0.13.0.
  • [mostro] transport setting (Transport, serde default = gift-wrap) in src/config/types.rs + settings.tpl.toml.
  • [expiration] dm_days knob (default 30) in ExpirationSettings and the get_expiration_timestamp_for_kind fallback (DM_EVENT_KIND = 14 in src/config/constants.rs).
  • src/main.rs — subscription filter uses transport.event_kind().
  • src/app.rs — event loop accepts only the configured kind and unwraps via unwrap_incoming().
  • src/util.rs send_dm() — wraps via wrap_message_with(transport, …); on the nip44 transport, fills a default NIP-40 expiration from dm_days when the caller didn't pass one.
  • src/nip33.rsprotocol_version tag in the kind-38385 info event.

Phase 2 — anti-spam gates (DONE — this change; daemon-only, the payoff)

The reason v2 exists: reject junk before paying decrypt/parse costs. All of the following are v2-only — the gate is skipped on the gift-wrap transport, whose outer key is a throwaway with no pre-validatable signal.

  • Active-trade-pubkey cache (src/spam_gate.rs, SpamGate): the trade keys that may legitimately message Mostro now — buyer/seller/creator of every non-terminal order, plus the solver of every active dispute. Built by db::find_active_trade_pubkeys (terminal set = the restore-session EXCLUDED_ORDER_STATUSES minus 'dispute', so disputed orders stay active). Warmed at startup in main.rs and rebuilt every active_pubkeys_refresh_interval seconds (default 60) by scheduler::job_refresh_active_pubkeys — a periodic full reload, chosen because status mutations are scattered across handlers with no single choke-point. Global-singleton (OnceLock), mirroring PriceManager.
  • Cheap pre-validation in the event loop (src/app.rs), for kind 14, before unwrap_incoming decrypts: check event.pubkey against the cache.
  • Two lanes — the necessary nuance to "only accept known keys": brand-new orders and takes arrive from keys Mostro has never seen.
    • Known-keys lane: sender in the cache → fast-path; only the base pow (already checked at the top of the loop) applies.
    • First-contact lane: sender unseen → must clear pow_first_contact ([mostro], defaults to pow so existing configs are unchanged) before the daemon decrypts. This is where spam concentrates; PoW here plus relay-side rate limiting are the toll.
  • Dedup as defense in depth: a REPLAY_WINDOW_SECS (60 s) guard drops a re-sent identical event id before decryption. The existing 10-second freshness window (post-decrypt, on the inner created_at) still applies as the precise stale-event check.

New config ([mostro]): pow_first_contact (Option<u8>, default = pow) and active_pubkeys_refresh_interval (default 60). Both #[serde(default)], so pre-Phase-2 settings.toml files are wire-identical. Zero handler changes; the gate sits entirely in the event-loop preamble.

Phase 3 — protocol docs + client migration (PENDING)

  • Update the protocol repo (MostroP2P/protocol): overview.md ("The Message": both transports, the v2 tuple, version: 2), key_management.md (v2 examples mirroring the existing unencrypted gift-wrap walkthroughs), migration guide for client developers.
  • mostro-cli / client support via the same mostro-core 0.13.0 APIs: clients keep both wrap paths and pick per node from protocol_version.

Phase 4 — the v0.19.0 cutover (PENDING)

  • Default transport = "nip44"; remove the v1 path from mostrod entirely (per §5). Metrics: messages_received_total, decrypt failures as a spam indicator.

7. Security notes

  • Identity privacy is unchanged from v1: the identity pubkey only ever exists inside NIP-44 ciphertext readable by the two parties. What v2 newly exposes is activity of an ephemeral trade key (who talks to Mostro, when, how much) — accepted, bounded by per-trade key rotation.
  • Identity proof grafting is prevented by the trade-pubkey binding (§2.3). The trade signature (element 2) needs no domain tag because it is verified against event.pubkey — a foreign trade_sig under a different author fails by construction.
  • Event signature is load-bearing in v2 (it proves the visible sender): unwrap_message_nip44 verifies it and hard-errors, unlike v1 where the outer signature is from a throwaway key and the seal carries the trust.
  • The daemon's existing checks (PoW, 10-second freshness window, trade index, identity != sender && signature.is_none() bail-out) apply unchanged to both transports because both yield the same UnwrappedMessage.