Skip to content

Latest commit

 

History

History
241 lines (194 loc) · 10.8 KB

File metadata and controls

241 lines (194 loc) · 10.8 KB

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

Status: Phase 1 implemented (this spec ships with it) · Phases 2–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_versions 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_versions 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_versions tag in the kind-38385 info event.

Phase 2 — anti-spam gates (PENDING — daemon-only, the payoff)

The reason v2 exists: reject junk before paying decrypt/parse costs.

  • Cache of active trade pubkeys (open orders/disputes), refreshed on state changes.
  • Cheap pre-validation in the event loop for kind 14: check event.pubkey against the cache before decrypting.
  • Two lanes — this is the necessary nuance to "only accept known keys": brand-new orders and takes arrive from keys Mostro has never seen, so there is a known-keys lane (pre-validated, cheap) and a first-contact lane (where spam lives; PoW + relay rate-limiting apply there).
  • TTL / stale-event rejection and dedup as defense in depth.

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_versions.

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.