security: hash-based dedup on subscribe_payments push callbacks #50
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?
LnbitsClient.subscribePaymentscallbacks fire once per inbound push event from the relay. There is no de-duplication bypayment_hashon the ATM side. If the relay rebroadcasts a settlement push (post-reconnect re-delivery, second relay push, or buggy server-side fan-out),watchInvoice's callback (apps/machine/src/services/lightning.ts:1006-1011) and the LNURL-withdraw session callback (apps/machine/src/services/lightning.ts:~720-731) will fire twice for the same settlement.Whether the customer actually re-dispenses depends on state-machine idempotency that the cross-codebase audit did not fully trace. Even if the state machine is idempotent today, the callback layer should provide its own dedup primitive — both as defence in depth and because S3 (settlement receipts) will piggyback on the same callback path and needs hash-based dedup to verify each receipt only once.
Code sites
apps/machine/src/services/lightning.ts:watchInvoice—subscribePaymentsfiltered bypayment_hash. Callback firesonPaymentCallback(push.preimage)without dedup.apps/machine/src/services/lightning.ts:generateLnurlWithdraw—subscribePaymentsfiltered by{tag: 'withdraw', link_id}. Same callback shape, same gap.Fix
Maintain a small bounded
Set<string>(or LRU) of seenpayment_hashvalues per-subscription. Skip the callback invocation if the hash is already present.Prune on
unsubscribe()to bound memory.Acceptance
payment_hashon the same subscription invoke the callback exactly once.Why now
aiolabs/satmachineadmin#17cleanly — that work subscribes to receipt events on the same relay and needs each receipt processed once.LnbitsClient's relay reply handling.References
pnpm lintis broken across the workspace #53recordSeen(follow-up to #50) #54Shipped on
devat commit8c4be01. Builds on0dcbe44(#49): withev.idSchnorr-verified by the guard, it's now safe as a client-global dedup key.Implementation — two tiers:
seenEventIds: Set<string>onLnbitsClient(FIFO-capped at 1000). Checked inhandleReplyright after the#49guard. Catches exact-replay (same bytes re-delivered post-reconnect, etc.).seenPaymentHashes: Set<string>perActiveSubscription(FIFO-capped at 500). Checked at the push-dispatch site. Catches logical duplicates — server fan-out across two relays produces differentev.ids carrying the samepayment_hash.Shared
recordSeen()helper for FIFO eviction (Sets preserve insertion order in JS, sovalues().next()gives the oldest).Tests — 11/11 passing, 5 new for #50:
does not poison the seenEventIds cache with a forged event— this is the test that depends on #49's guard. A refactor that dropsisAuthenticServerEventwould let the forged event's id intoseenEventIds. Bitspire session's call: this is where the guard's contribution becomes load-bearing, not in the original "drops a forged event without resolving any pending RPC" test (which was indistinguishable from the MAC-failure path).onPushfires once viaseenEventIds.payment_hash→onPushfires once via per-subseenPaymentHashes. Sanity-assertsev1.id !== ev2.idto prove ev.id dedup wouldn't catch this case.Why two tiers, not one:
Why this matters in production: without dedup, a relay rebroadcast of a settlement push invokes
onPushtwice. The state machine'swatchInvoicecallback resolves on the first push and unsubscribes — but a race between the second push and the unsubscribe round-trip could land a seconddispenseCash()for the same cash-out. Customer walks away with double the cash. Per-subpayment_hashis the only field guaranteed-unique per settlement.Closing.