What changed, and why it matters
This commit tightens how Electrum's submarine-swap client matches Nostr direct-message replies to the requests it sent out. Previously, the client keyed pending replies only by the original event ID. That could let a malicious or misconfigured relay/peer inject a reply from a different swap server and have it accepted. The patch now also checks the reply author's public key, so replies are only accepted from the server that was actually asked. It is a defensive hardening fix rather than a complete exploit chain.
Treat as a low-to-moderate security hardening patch. Users running submarine swaps over Nostr should upgrade to a version containing this commit. Review whether additional authentication (e.g., signed response envelopes, replay protection, timeout/cleanup of stale dm_replies entries) is warranted, because the patch only binds replies to the intended server pubkey.
Security signals we found
key lookup widened from single identifier to composite (pubkey, event_id)
prevents cross-pubkey reply confusion / injection
sanity-check style hardening in P2P messaging layer
no explicit CVE or security advisory referenced in commit
Evidence from the diff
In electrum/submarine_swaps.py, NostrTransport’s dm_replies dictionary changes key from event_id (str) to (server_pubkey, event_id) tuple. send_direct_message now resolves the server’s npub to its raw pubkey and stores the pending Future under that pair. On receiving a reply, _handle_reply now looks up the Future using both the reply_to event id and the event’s pubkey. This prevents a reply from an unrelated pubkey from satisfying a pending swap request. The change is small and partial: send_direct_message still accepts retries=1 and the broader request/response flow is not otherwise authenticated or encrypted beyond Nostr’s normal scheme.
Changed components
electrum/submarine_swaps.pyNostrTransportsubmarine swap client Nostr DM reply handlingInspect captured patch +10 / −7
diff --git a/electrum/submarine_swaps.py b/electrum/submarine_swaps.py
index 4b008d6..0be57dc 100644
--- a/electrum/submarine_swaps.py
+++ b/electrum/submarine_swaps.py
@@ -1480,7 +1480,7 @@ class NostrTransport(SwapServerTransport):
self.private_key = keypair.privkey
self.nostr_private_key = to_nip19('nsec', keypair.privkey.hex())
self.nostr_pubkey = keypair.pubkey.hex()[2:]
- self.dm_replies = {} # type: Dict[str, asyncio.Future[dict]]
+ self.dm_replies = {} # type: Dict[tuple[str, str], asyncio.Future[dict]]
self.ssl_context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=ca_path)
self.relay_manager = None # type: Optional[aionostr.Manager]
self.taskgroup = OldTaskGroup()
@@ -1636,11 +1636,12 @@ class NostrTransport(SwapServerTransport):
self.logger.debug(f"swapserver req: method: {method} relays: {self.relays}")
request_data['method'] = method
server_npub = self.config.SWAPSERVER_NPUB
- event_id = await self.send_direct_message(server_npub, json.dumps(request_data), retries=1)
+ server_pubkey = aionostr.util.from_nip19(server_npub)['object'].hex()
+ event_id = await self.send_direct_message(server_pubkey, json.dumps(request_data), retries=1)
if not event_id:
raise SwapServerError()
- self.dm_replies[event_id] = f = asyncio.Future()
- response = await f
+ self.dm_replies[(server_pubkey, event_id)] = fut = asyncio.Future()
+ response = await fut
assert isinstance(response, dict)
if 'error' in response:
self.logger.warning(f"error from swap server [DO NOT TRUST THIS MESSAGE]: {response['error']}")
@@ -1767,9 +1768,11 @@ class NostrTransport(SwapServerTransport):
content['event_id'] = event.id
content['event_pubkey'] = event.pubkey
if not self.sm.is_server and 'reply_to' in content:
- reply_to = content['reply_to']
- if reply_to in self.dm_replies:
- self.dm_replies[reply_to].set_result(content)
+ prev_event_id = content['reply_to']
+ server_pubkey = event.pubkey
+ fut = self.dm_replies.get((server_pubkey, prev_event_id))
+ if fut:
+ fut.set_result(content)
elif self.sm.is_server and 'method' in content:
try:
await self._handle_request(content)
Why this scored 57/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.