security: verify reply event signature in LnbitsClient.sendRpc #49
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
packages/lnbits/src/client.ts:sendRpcmatches replies byrequest_idstring alone — there is no signature verification on the inbound reply event before its decrypted content is used to resolve the pending Promise.A compromised relay can forge a kind-21000 event spoofing the LNbits server pubkey (the relay can publish any event with any author claim — the relay isn't bound by signature on rebroadcast unless the consumer checks). With a stolen / observed
request_id, the relay can return a doctored response — e.g. "payment.status = success" when the actual payment never settled, or a tamperedpayment_requestvalue redirecting customer payments.The current code at
packages/lnbits/src/client.ts:447-481(approx) decrypts and uses the reply event's content directly:There is no
verifyEvent()call against the configuredserverPubkey.Why this matters
Fix
Before resolving the pending promise, verify the inbound event:
Two-step check: author pubkey matches config AND Schnorr sig is valid. Both checks are cheap;
verifyEventis what we already use to validate any inbound kind-21000.Acceptance
LnbitsClient.sendRpcrejects reply events whosepubkey≠ configuredserverPubkey.LnbitsClient.sendRpcrejects reply events whose Schnorr signature does not verify.References
aiolabs/lamassu-next#24(Nostr-native LNURL completion — also needs sig verify on inbound notification events).pnpm lintis broken across the workspace #53Shipped on
devat commit0dcbe44.Implementation:
isAuthenticServerEvent(ev, expectedPubkey)exported frompackages/lnbits/src/client.ts:51-78. Two checks:ev.pubkey === expectedPubkey(defends against relay ignoringauthorsfilter) +verifyEvent(ev)(catches forged sig / tampered body).handleReply()calls it beforedecryptContentV2.aiolabs/lnbitscommit4ebcd959(NsecBunkerAdminClient._match_response) and the server-side check atnostr_transport/relay_pool.py:~320.Tests:
packages/lnbits/src/__tests__/client.test.ts— 4/4 passing. Covers:Threat-model nuance worth noting in retrospect: the original issue body claimed a compromised relay could forge "payment settled" → ATM dispenses. That's not actually possible against the current code because NIP-44 v2 already binds the ciphertext to the sender's pubkey via ECDH(server_priv, recipient_pub) — a relay without the server's nsec can't produce content that decrypts cleanly. So
decryptContentV2alone provides sender authentication in the current code path.The patch is still valuable as:
ev.pubkey), the guard prevents regression.Gotcha discovered:
finalizeEventstampsevent[verifiedSymbol] = trueto cacheverifyEvent's result. Object spread copies symbol-keyed properties. So a tampered event constructed via{...ev, content: 'tampered'}inherits the cachedtrueandverifyEventshort-circuits. The test JSON-round-trips throughstripVerifiedCache()to drop the cache before forging. Noting this here so future Nostr-related tests in the codebase don't trip on the same thing.Closing.