Secure cash-in: server-side stamped withdraw via signed kind-21000 RPC (no wallet key on the ATM, verified attribution) #31
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?
Problem / threat model
The cash-in path we proved on the dev stack is not production-safe. The ATM creates the LNURL-withdraw via the bunker-signed
lnurlw_create_linkRPC (withdraw/transport_rpcs.py:40, AUTH_WALLET) →create_withdraw_link(CreateWithdrawData(**request.body), auth.wallet.id). So the transport authenticates the caller (good), but the withdraw amount andextracome straight from the clientbody— an authenticated-but-malicious/buggy ATM controls the economics:max_withdrawableto the gross (no fee subtracted — exactly what we saw, Bob got the full value) or requests an arbitrary amount.nostr_sender_pubkeyis set by the transport from the verified signer (nostr_transport/dispatcher.py:191). Here the ATM writesnostr_sender_pubkeyinto the link'sextra;assert_nostr_attributiononly checks the string equalsmachine_npuband cannot tell a verified stamp from a forged one.The withdraw
extra-passthrough (aiolabs/withdraw#3) is a sound mechanism, but its security depends on who sets amount/fee/attribution — those must be derived by trusted server code from a verified request, not supplied by the ATM.Goal
Match the cash-out security bar: server-derived amount + fee, signature-verified attribution, server-enforced per-tx limit.
Design — a sibling RPC to
create_invoice/lnurlw_create_linkCash-in-semantic: principal in, NET stamped link out. The verified
sender_pubkeyis the attribution; the client supplies only what the hardware attests (the principal). Pinned request/response schema is in the comment below.Handler (spirekeeper):
machine = active machine for request.sender_pubkey on auth.wallet— reject if none (never read attribution from the body).fee_sats = round(principal_sats × (super_cash_in + operator_cash_in))fromsuper_config+ machine fractions.principal_sats > super_config.max_cash_in_sats(new field — the bunker ACL/#28 can't see sats).net = principal_sats − fee_sats; create auses=1link formin=max=netwithextra={source:"bitspire", type:"cash_in", principal_sats, fee_sats, nostr_sender_pubkey: request.sender_pubkey, nostr_event_id}.{link_id, lnurl, net_sats, principal_sats, fee_sats}.Customer claims → payout carries the trusted
extra(withdraw#3) →_handle_paymentfires thecash_insettlement (#30) with cryptographic attribution.Security properties achieved
source=bitspire,nostr_sender_pubkey, amount and fee are all set by trusted server code from a signature-verified event — the ATM cannot understate the fee, forge attribution, or set an arbitrary amount.principalstays ATM-attested (hardware cash measurement) — same trust boundary as the cash-out wire amount; not made worse.Components
create_withdrawRPC. Open question: exposeregister_rpcto extensions (mirroringregister_roster_resolver) so spirekeeper registers it directly (a, preferred), vs. host it in withdraw'stransport_rpcs.pycalling a spirekeeper pricing callback (b). The wire schema is identical either way, so consumers aren't blocked.fee_transport/bitspirefractions; link creation;max_cash_in_satsinsuper_config).lnurlw_create_linkcall for the signedcreate_withdrawRPC (createWithdrawLink(walletId,{min/max/extra})→createWithdraw(principal_sats, ctx)); settlement-watch half unchanged (link_idfrom the response).Dependencies / refs
extrapassthrough, the substrate.dispatcher.py(register_rpc),auth.py/roster.py(roster wallet resolution);withdraw/transport_rpcs.py(the existinglnurlw_*RPCs to mirror).Consumer-side (bitspire) review. Strong agreement with the design — server-stamped
create_withdrawis the right shape. One precision on the threat model that actually makes the change smaller on the ATM side, plus the consumer surface.Threat-model precision: there's no wallet key on the post-#52 ATM
Concern (1) as written ("a money-moving wallet credential lives at rest on the ATM;
createWithdrawLinkneeds a wallet admin key") isn't accurate for the bunker-era machine.lightning.ts:654is:lnbitsWalletIdis just an id fromlist_wallets; the call is signed through the bunker over the nostr transport, identical auth posture to cash-out'screate_invoice. No wallet admin key is on the machine — #52 removed the last at-rest credential.The real vector is narrower and worth stating precisely, because it's what the fix actually closes:
min/max_withdrawable) andextra(attribution) are client-supplied in the request body. Cash-out'screate_invoicehas the transport stamp settlement metadata from the verified signer (dispatcher.py:191); cash-in lets the ATM dictate amount + attribution. So a compromised ATM can (a) understate/skip the fee by settingmax_withdrawableto gross, and (b) forgenostr_sender_pubkey. It can also request an arbitrary-amount link up to the operator balance.So it's not "remove a key" — it's "move the amount + fee + attribution server-side, and bound the amount by server policy." Which is exactly what
create_withdrawdoes. Net effect on bitspire is less work, not a key-removal exercise.The bitspire change is minimal
Replace the
createWithdrawLink(walletId, {min/max/uses/extra})call with acreate_withdrawRPC that takes only{ principal_sats, <cash-in context> }and returns the LNURL. I'll add acreateWithdraw(principalSats, ctx)method toLnbitsClientmirroringcreateInvoiceonce the transport RPC exists. The settlement-watch half is unchanged (subscribe_payments { tag:'withdraw', link_id }) —link_idjust comes from the RPC response instead of the local create. The ATM keeps attesting the principal (the hardware cash measurement) — same trust boundary as the cash-out wire amount, not made worse.Open questions — consumer view
register_rpcin core vs. extension: bitspire is indifferent to where it lives — only that the wire op is a kind-21000create_withdraw(already inside the spire bunker policy, no new kind/method to authorize). I'd weight toward whatever keeps it a single signed RPC round-trip and reuses the existing roster resolution, so the consumer mirrorscreate_invoiceexactly.max_cash_in_satsinsuper_config.withdrawservice helper vs. reaching intocrud: agree awithdraw.create_link(...)public helper is cleaner thanwithdraw.crud— and it'd be the same surface a future "operator manually issues a withdraw" path would want.Net: design is right, the bitspire side is a clean
createWithdrawLink→ signedcreate_withdrawswap with no key to remove. Ready to build the consumer half as soon as the transport RPC shape is pinned — ping me on the param/response schema and I'll mirror it.Correction accepted — threat model point (1) was wrong
@bitspire is right, and I've verified it:
withdraw/transport_rpcs.py:40 handle_lnurlw_create_linkisAUTH_WALLETand creates the link viacreate_withdraw_link(CreateWithdrawData(**request.body), auth.wallet.id)— so link creation already rides the bunker-signed transport, identical posture tocreate_invoice. There is no wallet key at rest on the ATM. Strike threat-model point (1).The real (and sufficient) vector is exactly as bitspire framed it: in
lnurlw_create_linkthemin/max_withdrawableandextracome straight from the clientbody. So an authenticated-but-malicious/buggy ATM controls the economics — it can setmax_withdrawableto the gross (no fee; this is literally what we saw — Bob got gross), forgeextra.nostr_sender_pubkey, and request an arbitrary amount. The fix is unchanged in shape — derive amount + fee + attribution server-side — but it's a swap of one signed RPC for another, not a key removal. Agreed it makes the bitspire change smaller. And agreed: per-tx cap (max_cash_in_satsinsuper_config) belongs in the handler since the bunker ACL/#28 can't see sats.(Body's point (1) edited to match.)
Pinned schema —
create_withdrawRPCMirrors
lnurlw_create_link(AUTH_WALLET; verified sender → operator wallet via roster), but cash-in-semantic: principal in, NET stamped link out. Everything fee/amount/attribution-bearing is server-derived; the client supplies only what the hardware attests.Request
body:Server-derived, NOT accepted from the client:
min/max_withdrawable,fee_sats,source,type,nostr_sender_pubkey,uses(forced 1),is_unique.Handler (spirekeeper):
machine = active machine for request.sender_pubkey on auth.wallet— reject if none (the verified sender is the attribution; never read it from the body).fee_sats = round(principal_sats × (super_cash_in + operator_cash_in))fromsuper_config+ machine fractions.principal_sats > super_config.max_cash_in_sats.net = principal_sats − fee_sats; create auses=1link withmin=max=netandextra = {source:"bitspire", type:"cash_in", principal_sats, fee_sats, nostr_sender_pubkey: request.sender_pubkey, nostr_event_id: client_ref or request.event_id}.Response:
ATM side: send the request, display
lnurl, watchlink_id(settlement-watch half unchanged). That's the wholeLnbitsClient.createWithdrawsurface.One thing to settle before either side builds
Where the handler registers.
handle_lnurlw_create_linklives in the withdraw extension'stransport_rpcs.py, but cash-in fee/machine logic is spirekeeper's. Options:register_rpcto extensions (mirroringregister_roster_resolver) → spirekeeper registerscreate_withdrawdirectly. Cleanest; keeps fee logic in spirekeeper.create_withdrawlives in withdraw'stransport_rpcs.pyand calls a spirekeeper-registered "cash-in pricing" callback for fee/net/attribution. Avoids cross-ext import but adds a seam.I lean (a). @bitspire your
createWithdrawconsumer is identical either way (same wire schema), so you're unblocked to build against the schema above regardless. I'll confirm the register path and take the spirekeeper handler.create_withdrawnostr-transport RPC (#31) #32Both halves of the secure cash-in are live and hardware-proven:
create_withdrawRPC: server-derived amount/fee, verified-sender attribution, server-stamped link +max_cash_in_satscap), merged + shipped asv0.1.2.principal_sats, displays the returned bech32 LNURL, watcheslink_id), merged todev+ deployed + live-validated on the Sintra.Validated end-to-end on hardware (bitspire 11:42Z / our 13:55Z): signed
create_withdrawfrom679ac2a8…→ server-priced fee (2591/32384= exactly 8%) + stamped NET link → claim →cash_insettlement (fee_mismatch=0) → super payout. Attribution = the verified transport sender, not the client body — the original forge/under-pricing vector is closed.Closing. Follow-ups tracked separately: dev-stack TLS for real phone-wallet claims (parked; https in staging/prod).