Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ reqwest = { version = "0.12.1", default-features = false, features = [
"json",
"rustls-tls",
] }
mostro-core = { version = "0.12.1", features = ["sqlx"] }
mostro-core = { version = "0.13.0", features = ["sqlx"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
async-trait = "0.1.83"
Expand Down
241 changes: 241 additions & 0 deletions docs/TRANSPORT_V2_SPEC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# 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](https://github.com/MostroP2P/mostro/issues/626)
**Full proposal:** [issue comment](https://github.com/MostroP2P/mostro/issues/626#issuecomment-4694164653)
**Core implementation:** [mostro-core#152](https://github.com/MostroP2P/mostro-core/pull/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:

```json
{
"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:

```json
[
{ "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:

```text
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.

```toml
[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.rs` — `protocol_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`.
10 changes: 10 additions & 0 deletions settings.tpl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ user_rates_sent_interval_seconds = 3600
publish_relays_interval = 60
# Requested POW
pow = 0
# Wire transport for protocol messages. A node speaks exactly one:
# "gift-wrap" - protocol v1, NIP-59 gift wraps (kind 1059). DEPRECATED,
# will be removed in v0.19.0.
# "nip44" - protocol v2, signed kind-14 events with NIP-44 encrypted
# content. Rate-limitable by relays; switch once the clients
# your community uses support protocol v2.
# See docs/TRANSPORT_V2_SPEC.md
transport = "gift-wrap"
# Publish mostro info interval
publish_mostro_info_interval = 300
# Bitcoin price API base URL.
Expand Down Expand Up @@ -87,6 +95,8 @@ rating_days = 90
dispute_days = 90
# Fee audit events (kind 8383) - annual transparency
fee_audit_days = 365
# Protocol-v2 direct messages (kind 14) - trade lifetime plus dispute window
dm_days = 30

[rpc]
# Enable RPC server for direct admin communication
Expand Down
25 changes: 16 additions & 9 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ use mostro_core::error::CantDoReason;
use mostro_core::error::MostroError;
use mostro_core::error::ServiceError;
use mostro_core::message::{Action, Message};
use mostro_core::nip59::{unwrap_message, UnwrappedMessage};
use mostro_core::nip59::UnwrappedMessage;
use mostro_core::transport::unwrap_incoming;
use mostro_core::user::User;
use nostr_sdk::prelude::*;

Expand Down Expand Up @@ -300,6 +301,10 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> {
let my_keys = ctx.keys();
let client = ctx.nostr_client();
let pow = ctx.settings().mostro.pow;
// The node speaks exactly one transport (protocol v1 gift wrap or v2
// NIP-44 direct); events of any other kind are dropped before any
// decryption work. See docs/TRANSPORT_V2_SPEC.md.
let accepted_kind = ctx.settings().mostro.transport.event_kind();

loop {
let mut notifications = client.notifications();
Expand All @@ -312,22 +317,24 @@ pub async fn run(ctx: AppContext, ln_client: &mut LndConnector) -> Result<()> {
tracing::info!("Not POW verified event!");
continue;
}
if let Kind::GiftWrap = event.kind {
if event.kind == accepted_kind {
// Validate event signature
if event.verify().is_err() {
tracing::warn!("Error in event verification")
};

// Mostro-core's NIP-59 transport handles the dual-key layout
// (identity key signs seal, trade key authors rumor) plus inner
// tuple (message, signature) decoding and signature verification
// in one shot.
let unwrapped = match unwrap_message(&event, my_keys).await {
// Mostro-core dispatches on the event kind: the gift wrap
// path handles the dual-key layout (identity key signs
// seal, trade key authors rumor), the kind-14 path the
// 3-element tuple with its in-ciphertext identity proof.
// Both decode and verify signatures in one shot and yield
// the same transport-agnostic `UnwrappedMessage`.
let unwrapped = match unwrap_incoming(&event, my_keys).await {
Ok(Some(u)) => u,
// Outer NIP-44 decrypt failed: not addressed to this node.
// NIP-44 decrypt failed: not addressed to this node.
Ok(None) => continue,
Err(e) => {
tracing::warn!("Error unwrapping NIP-59 message: {}", e);
tracing::warn!("Error unwrapping incoming message: {}", e);
continue;
}
};
Expand Down
5 changes: 5 additions & 0 deletions src/config/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ pub const DEV_FEE_LIGHTNING_ADDRESS: &str = "pivotaldeborah52@walletofsatoshi.co
/// This ensures events are NOT replaceable, maintaining complete audit history
pub const DEV_FEE_AUDIT_EVENT_KIND: u16 = 8383;

/// Nostr event kind for protocol-v2 direct messages (NIP-44 direct transport)
/// Kind 14 carries Mostro protocol messages as signed events with NIP-44
/// encrypted content when `transport = "nip44"` (see docs/TRANSPORT_V2_SPEC.md)
pub const DM_EVENT_KIND: u16 = 14;

/// Nostr event kind for exchange rates (NIP-33 addressable event)
/// Kind 30078 is in the replaceable events range (30000-39999) per NIP-33
/// This allows the same Mostro instance to publish updated rates that replace previous events
Expand Down
13 changes: 13 additions & 0 deletions src/config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::config::types::{
NostrSettings, RpcSettings,
};
use crate::price::PriceSettings;
use mostro_core::transport::Transport;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

Expand Down Expand Up @@ -99,6 +100,18 @@ impl Settings {
MOSTRO_CONFIG.get()?.anti_abuse_bond.as_ref()
}

/// Wire transport for protocol messages. Falls back to the default
/// (`gift-wrap`, protocol v1) when the global settings haven't been
/// initialized yet — `send_dm()` sits on every reply path and must
/// degrade to v1 behavior rather than panic in unit tests that don't
/// bring up the full configuration, mirroring [`Settings::get_bond`].
pub fn get_transport() -> Transport {
MOSTRO_CONFIG
.get()
.map(|s| s.mostro.transport)
.unwrap_or_default()
}

/// Retrieve the multi-source price configuration from the global
/// `MOSTRO_CONFIG`. Returns `None` when the `[price]` block is absent
/// (Phase 1 synthesises a legacy default in that case) and also when the
Expand Down
Loading