| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
| |
Receipts were published at QoS 0, so a printer that was briefly offline
(WiFi blip, keepalive timeout) never received them. QoS 1 lets the broker
queue them for printers that hold a persistent session.
LNURL-pay invoices carry the raw metadata array as their memo when the
payer leaves no comment; unwrap the text/plain entry before printing.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_011cCouc5ASRXHDp5esLciEK
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
cashu-ts 2.9.0 only derives v0 keyset ids, so CashuWallet rejected the
mint's current keyset and receive() could never run. Build the wallet
from keys preloaded off our own mint instead of verifying, decode tokens
with the keysets (2.9.0 shortens v1 ids in the tokens it encodes, and
won't expand them otherwise), and treat an unreadable house-pool value
as empty rather than throwing — the pool key had been found holding a
bare integer, which surfaced as "n.startsWith is not a function" on
every redeem. The pool write is awaited so a dropped set can't strand
freshly swapped proofs.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_0166Ci2XdCair1B9ych7w8dq
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Removes the admin-gated endpoints and the account flags they set, plus a
batch of hardening that had accumulated uncommitted in the tree. These were
deployed and verified together, so they land as one change.
admin surface:
- drop POST /hidepay, POST /unlimit and GET /users, their handlers, and
the now-unused admin preValidation export
- `unlimited` was only ever written, never read; `hidepay` only appeared
in output projections. Both flags go, along with their whitelist,
public-fields and type entries.
request log:
- redactBody now recurses. It only masked top-level keys, so a body that
nests credentials one level down wrote them to the log in plaintext,
and pin fields were not on the list at all.
payments:
- sendOnchain/build gate `aid` through requireAccount, so a caller can
only spend from an account it owns
- sendOnchain signs only a tx we composed, matched against a short-lived
per-account build receipt
- raise xpay retry_for to 60s; route-finding preferred a drained zero-fee
hop and burned the whole budget on it. check()'s reversal window keeps
its 2x margin.
ecash / invoices / webhooks:
- resolve a token's mint through the IP-pinning fetcher instead of plain
fetch, closing an unauthenticated SSRF path into the internal network
- reject non-positive amounts before the balance write
- /sign requires the caller to own the address, rather than signing under
any address the hot wallet controls
- webhook delivery no longer disables TLS verification; the body carries
the merchant's shared secret
registration:
- refuse a username the v3 registrar holds unless the caller's own nostr
key is the holder (lib/names.ts)
liquid:
- honour the deposit kill switch at invoice generation and at credit
elements image:
- pin 23.3.4 and verify the release tarball checksum
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0112TGi9GGNWqji1XQ5jZRzc
|
| |
|
|
|
|
|
|
|
|
| |
credit() now returns without writing for a missing user, but the
listener's persistence check after credit() then threw and kept the
cursor stuck. Check the account up front and skip like an unknown
invoice, recording the preimage in `missing`.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
|
| |
|
|
|
|
|
|
|
|
|
|
| |
getUser(inv.uid) returns nothing when the account was deleted but its
invoice or bolt12 offer still receives (OCEAN payouts to a deleted
user's offer). The destructure on the next line threw inside the
lightning listener, which never advanced pay_index and crash-looped the
app every 50s, blocking all incoming lightning for two hours. Record the
preimage in `missing` and return, as an unknown invoice already does.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
|
| |
|
|
|
|
|
|
|
|
| |
The handler wrote every request's config.txt and littlefs.img to the same
paths, so two overlapping requests could hand one user the other's wifi
key and API token. Use a per-request mkdtemp dir, read the image into
memory, and remove the dir when done.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMi2Y1oKPZ3TeeFmLej47y
|
| |
|
|
|
|
|
|
|
|
|
|
| |
POS terminals log into the websocket with the "<uid>-ro" token that the
web flasher pre-fills. track() looked that id up as a user, failed, and
closed the socket a second later, dropping the invoice subscription, so
devices were listening about one second in six. Strip the suffix the
way lib/auth.ts does for HTTP, flag the socket read-only, and forward
only "payment" events to it.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_014gVYGi29FjTeLdNTiutkZx
|
| |
|
|
|
|
|
|
|
|
|
|
| |
nutshell >= 0.20.3.1 melts via POST /v1/xpay (form field invstring),
while older CLNRest wallets post /v1/pay form-encoded under `invoice`
or `bolt11`. The guard only parsed JSON `bolt11`, so form bodies never
yielded an amount and were rejected, and an xpay backend would have
bypassed the caps entirely. Accept both endpoints and all three field
names, forward as form on the same endpoint preserving fee params.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
keysend blocks until it settles, so a successful return already carries
the preimage. Previously only the caller was expected to confirm the
record, and the NWC pay_keysend path never did, leaving successful
keysends stuck pending forever (check() can't rescue them since the
label isn't a bolt11). Add finalizeKeysend(): flip pending->confirmed,
derive the real fee from amount_sent_msat, refund the unused fee
reserve, and refuse to re-create a record a concurrent reverse() already
removed.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_014uxZLvYS4gH9UqUX2Ey8TV
|
| |
|
|
|
|
|
|
|
|
| |
The web flasher config endpoint wrote ./printer/config.txt without
creating the directory, so on a fresh deploy it threw ENOENT and the
JSON error body was flashed to devices as the LittleFS partition.
Create the directory, validate inputs, and route errors through bail.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_014gVYGi29FjTeLdNTiutkZx
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
When a fund's secret id has been rotated (invalidating an exfiltrated id list),
its old id 404s. Let a legit owner who bookmarked the old link find the fund
again — but reveal the new id ONLY to an authenticated manager of the new fund,
via a `fund:rotated:<oldid>` -> newid map. An anonymous caller or a non-owner
gets the same "fund not found" as before, so an attacker replaying a leaked old
id learns nothing (an open old->new redirect would defeat the rotation). The
/fund/:id route now runs `optional` auth so req.user is available without
requiring it; bearer funds with no managers are intentionally not recoverable
this way. rotate-funds.ts records the mapping on any future rotation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Add scripts/pay-hard.ts: a payment helper for well-connected but inbound-scarce
destinations (exchange nodes) that cost-first payers (xpay/renepay) can't reach.
When the normal xpay attempt fails, it enumerates the destination's peers, keeps
the ones we can reach (preferring peers we already channel with enough outbound),
and for each builds a route forced through peer->dest with the invoice's correct
final CLTV, probes it with an unclaimable random hash, and pays through the first
path that reaches the destination. Probe-before-send, refuses to start with a
payment already pending, and stops rather than risk a second in-flight payment.
Works against either the main node (cl) or the boltz node (bcln).
Add scripts/boltz-recover-stuck.ts: a cron watcher that finds submarine swaps
stuck at invoice.pending (lockup confirmed, invoice unexpired, idle past a
threshold) and runs pay-hard against bcln to complete them, then lets boltz
reconcile (restarting it only if it hasn't picked up the preimage within a grace
period).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The listener-backlog probe fired a failure whenever a paid invoice existed
beyond the durable cursor — but that is almost always the listener mid-
processing that very payment, a normal sub-second lag, not a wedged listener.
Those transient catches produced spurious "listener backlog" health failures
that then recovered on their own.
Grace-check instead: on detecting a paid invoice beyond the cursor, wait
briefly and re-read the cursor; if it advanced, the listener is alive and this
was transient (pass). Only fail if the cursor did not move at all — the actual
"stuck listener" condition the probe exists to catch.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The prior guard resolved the target host, checked the IP against the
private/loopback/link-local/CGNAT/metadata blocklist, then let got fetch the
URL — which re-resolves DNS independently when it connects, leaving a rebinding
window where a low-TTL host can pass the check as public and connect as
internal.
Reimplement safeGot on node http/https with a custom lookup that validates and
returns the exact IP the socket then connects to, so the validation and the
connection target are one and the same (no resolve/connect gap). Redirects are
followed manually and every hop is re-validated the same way; IP-literal hosts
(which bypass the lookup hook) are checked up front; the 2MB cap is enforced on
the streamed body rather than a spoofable content-length header. Scheme stays
restricted to http(s) with an 8s timeout. safeGot's contract is unchanged, so
the three lnurl callers are untouched.
Verified in-container: every internal target (loopback, docker-internal,
169.254 metadata, CGNAT, ULA, IP-literal, octal-literal) is blocked by the
guard, a server-side redirect into the metadata endpoint is blocked at the hop,
and legit external resolution (including http->https redirects) still works
with correct TLS against the real hostname over the pinned IP.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Remove the unauthenticated POST /echo route — a request-body reflector with
no place in production; it served only to echo attacker probe payloads.
Raise the health-check listener-backlog probe timeout from 5s to 15s. The
waitanyinvoice probe normally returns in ~1s but can intermittently take ~8s
while lightningd is briefly busy; the 5s cap turned those transient spikes
into health-check failures (and, at 3 consecutive, an unnecessary process
exit / container restart). 15s absorbs the busy spikes while a genuinely
wedged RPC still fails the check.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DKkev76U4YRnRAqM6jqbae
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
verifyRecaptcha failed closed on ANY error from the Google siteverify call, so
a transient coinos<->Google network blip (or a Google slowdown) turned every
non-admin login into a "failed captcha" 401 until connectivity recovered — a
self-inflicted login outage from an external dependency.
Distinguish a real verdict from an infrastructure failure: fail CLOSED only on
a genuine success:false from Google; on a thrown error (network, timeout, 5xx)
fail OPEN and log it. Reaching Google is not part of authentication — the
password and the per-IP login rate limits still gate the request, and captcha
is only bot-mitigation, so allowing a login through when the verifier is
unreachable is the safer failure mode. Also bound the call with a 6s timeout so
a slow verifier can't hang the login.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The /take fund-claim path could pay out unbacked balance: the only ceiling on a
take is sats(fiat / rates[currency]) from the authorization, and a
crypto-denominated authorization (rate ~1) or a non-finite/zero rate turns that
ceiling astronomically large, so a take is no longer bounded by the authorized
fiat value. Combined with the fund debit/credit running per-call, an authorizer
account could be drained far beyond intent.
- Add a `fund:disabled` redis kill-switch to both /authorize and /take that
fails the fund/authorize/take mechanism closed while it is audited.
- Reject non-finite/zero rates, non-fiat (crypto) authorization currencies, and
non-positive/NaN fiat and amounts, so the fiat cap can no longer be defeated.
- Require the authorizer account to exist before debiting it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Damus's one-click setup derives its NWC secret deterministically from the
user's nsec, so after the credential invalidation its re-mint reused the
same retired pubkey and could never recover; meanwhile GET /app/:pubkey
crashed into a 500 on missing records instead of the 404 the client needs
to know to re-create the connection.
We store client-supplied connection secrets (verified: all 2744 Damus app
records in prod carry one), so a disclosed DB means disclosed secrets and
re-authorizing the same pubkey would re-authorize the leaked bearer
credential. POST /app therefore rejects any pubkey that is quarantined or
predates nwc:credential-cutoff with an explicit 403 telling the client to
mint a fresh secret; a fresh secret yields a fresh pubkey and passes.
Owner posts refresh the record's created epoch so live connections stay
clear of the cutoff, and GET /apps drops stale set members instead of
500ing the listing.
Damus needs a versioned derivation salt so one-click can mint a genuinely
new secret; PR submitted separately.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
| |
|
|
|
|
|
|
|
| |
Incident-response scripts and the victim list carry account identifiers and
must never reach the public repo; peerswap/ holds runtime panic logs. Ignore
them so they stay local-only and can't be committed by accident.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
audit-lightning-backlog.ts walks settled invoices from the stored pay_index to
surface receives the listener may have missed crediting (companion to the
listener backlog probe). merge-kvrocks-archives.ts overlays one Kvrocks/Redis
archive onto a base without materializing keys in memory (raw SCAN to survive
cursors beyond JS safe-integer range), with kvrocks-merge-lowmem.conf as the
low-memory server config for the merge. All parameterized via CLI flags; no
embedded hosts or secrets.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Fastify 4.x is EOL with no backported fixes; all six open alerts
(critical http-proxy header stripping, static path traversal + auth
bypass, fastify body-validation bypass / host spoofing / DoS) are only
patched on the v5 line. Bump fastify to 5.12.0 and all @fastify/*
plugins to their v5-compatible majors, and migrate the two options v5
deprecated (disableRequestLogging -> logController, maxParamLength ->
routerOptions).
Co-Authored-By: Claude Fable 5 <[email protected]>
|
| |
|
|
|
|
|
|
| |
Extend the request-log redaction set beyond password/confirm so NWC connection
secrets and TOTP seeds never reach the req/res logs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Give waitanyinvoice an explicit 30s server-side timeout so a dead/half-open
socket can no longer hold the listener indefinitely; CLN's 904 timeout during a
quiet period is treated as a clean re-arm without counting as a failure. Split
the listener into waiting vs processing phases so the watchdog never resets a
healthy socket merely because a ledger write is slow, and never starts a second
concurrent processor — a stalled processing phase is surfaced loudly instead.
Process the settlement and confirm the durable payment pointer BEFORE advancing
pay_index, so a store load/restart between the cursor write and credit() cannot
drop a received payment. Expose listener status and add a backlog probe to the
health check, and re-enable startHealthCheck().
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Thread the remaining allowance through every spend path (pay_invoice, pay,
pay_keysend, internal transfer) as maxTotal, and have debit() assert the spend
stays within it after it reloads the invoice and captures the tip — so a
delegated NWC credential can no longer exceed its configured budget, and a
mutated tip can't slip a small-budget check and drain via the internal path.
checkBudget now returns the remaining allowance alongside any error; a blank
budget is treated as deliberately unlimited and a positive budget requires a
known renewal period.
Add operator-controlled containment for a suspected credential disclosure, all
gated behind Redis flags with no hardcoded scope:
nwc:disabled fail every method closed
nwc:credential-cutoff retire connections created before an epoch
nwc:quarantined block an individual disclosed connection
nwc:guard:dormant-keysend quarantine the first keysend on a long-idle app
Fix an ownership check that read the bare pubkey instead of app:<pubkey>, which
let an authenticated user overwrite another connection's config, and validate
budget/fee/renewal inputs.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A transaction our own wallet contributed inputs to carries a "send" detail;
every receive output on it is then our own change or an internal move, never
an external deposit. Crediting such an output mints phantom balance against
funds already in custody — the misclassification an external report flagged in
the on-chain send path (wallet ownership is not account ownership).
Guard the credit at the settlement layer, which is the correct boundary and
covers both bitcoin and liquid uniformly: if the tx has any send detail, skip
every receive output (log a tripwire for amounts >= 300 sats so continued
probing is visible). Rejecting these at send time instead would break
legitimate withdrawals, whose change routinely lands on a reused deposit
address that resolves to an invoice.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XiwgmQKwKnRxUmVDd4xSf7
|
| |
|
|
|
|
|
|
|
|
|
| |
name
A claimed name's NIP-05 belongs to its wallet key in the registrar, so query
NAMES_URL/.well-known/nostr.json first and fall back to the local lookup for
legacy accounts. Return an empty names object for an unknown name per NIP-05
instead of throwing a 500.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
| |
A NIP-47 request whose pubkey/user no longer exists (deleted app or migrated
user) was silently dropped, leaving the client spinning. Send an UNAUTHORIZED
error reply so the client can surface it. Still not logged (not a server fault).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
/v1/ws proxy
Forward cf-connecting-ip (or first x-forwarded-for) upstream so the mints' per-IP
rate limiter buckets by the real client instead of demux's socket IP. Partner
hosts that funnel many users through one IP can be listed in whitelist.json with
a pool size; their bucket key rotates across pool synthetic keys for a
proportionally larger allowance. Reloaded every 60s. Also proxy /v1/ws
(subscriptions) through to the new mint.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
Register patches/@asoltys+clightning-client+0.1.10.patch via patchedDependencies
so bun install re-applies it. The library's reconnect() reused an already-dead
socket and never recovered after a cl restart (looping connect ENOENT, leaking a
socket+timer); the patch rebuilds a fresh socket + readline each reconnection and
clears its retry guard before reconnecting. Companion to the app-side
dropClient()/listener-recovery fixes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Replace bare `client = null` with dropClient(): stop the clightning-client
library's internal reconnect loop (clear reconnectTimeout, neuter reconnect,
destroy the socket, drop listeners). A dead client was otherwise orphaned and
spun forever retrying `connect ENOENT`, spamming logs and leaking a socket+timer
on every cl restart. Guard the error handler on `c === client` so a stale
client's late error can't drop a newer, healthy one.
Add lnListen.onDrop so the invoice listener recycles immediately when its
long-poll socket dies (bump epoch, re-arm) instead of waiting up to 5 minutes for
the stall watchdog. Stop counting LightningUnavailableError toward the listener's
process.exit escalation, so a cl restart no longer makes the app self-exit — it
retries until cl returns.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The pending reconciliation set is keyed by bolt11, shared across attempts
at the same invoice. A retry made after a prior attempt's CLN parts
resolved (but before check() reconciled its debit) would pass the
listpays-only guard, and the retry's own reverse()/finalize() then
removed the shared entry — permanently hiding the first attempt's debit
from check(), so a failed send was never auto-refunded.
- sendLightning: refuse to pay while the invoice's bolt11 is still in
the pending set; the caller is told to retry shortly.
- check(): an entry whose payment record no longer exists in db or
archive is dropped from the set instead of skipped, so a dead entry
can't block its invoice forever under the new guard.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
Melts routed to the retired mint were failing before their proofs were
even read: wallets blind their fee-return change outputs to the current
active keyset, which the old mint doesn't know and rejects outright.
Strip change outputs on that path (recovery melts forfeit change).
Also map the caller's melt quote id to the old mint's re-quoted id so
melt responses and GET status polls report against the id the wallet
knows, and log the connecting IP on the recovery path.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
16 tests covering S1 (jwt.verify vs forged tokens), S2 (bcrypt cost 12 +
legacy detection), S3 (adminpass fails closed on omitted/empty password),
S5 (session cookie flags), S6 (atomic SET NX dedup), S7 (/upload auth),
fnd-003/004 (memo truncation), fnd-006 (deleteSelf sub-account guard),
and S8/fnd-007 (spend/cash mutex serialization). Black-box HTTP + redis
against the live regtest stack; run with `bun run test:regtest`.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
| |
Regenerated via `bun install` inside the app container (the bind-mounted
node_modules is root-owned, so host-side bun install hits EEXIST). Pins
sharp at the CVE-fixed 0.35.3 in the lockfile so a frozen-lockfile build
resolves the fixed tree.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The house Cashu holdings live as one token under redis `cash`. claim()
and mint() decoded the current proofs, swapped at the mint, then wrote
back — with no lock. Two concurrent claims read the same proofs and the
later write clobbered the earlier, so already-swapped proofs vanished
from the token while both users stayed credited (an insolvency leak,
triggerable via the unauthenticated /ecash/:id callback). Wrap both
read-swap-write sequences in an in-process mutex.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
| |
The "withdraw your balance before deleting" guard summed only the main
account, but the deletion loop wipes every sub-account's balance:/pending:
keys unconditionally. A user with dust in the main account and savings in
a sub-account passed the guard and silently lost the sub-account funds.
Sum balance+pending across all ${id}:accounts before refusing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
credit() ran fail("memo too long") on memos >5000 chars, but it runs
AFTER settlement: an incoming lightning/bolt12 deposit has already
advanced pay_index, and an internal send has already debited the sender.
The throw was only logged, replay() hit the same throw, and the sats sat
in the house wallet uncredited. The memo is attacker-controllable and was
uncapped via the lnurl comment param and the bolt12 payer_note.
Truncate the memo in credit() instead of throwing (a clipped note is
harmless), and cap the lnurl comment to the advertised 512 at ingress.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
- pay_keysend never called checkBudget and never recorded to
${pubkey}:payments, so a budget-limited connection string was a
full-drain credential. Budget-check it, honor max_fee, and record the
debit so subsequent budgeted calls count it (S4).
- Replace the non-atomic zScore-then-(unawaited)-zAdd dedup with an
awaited SET NX claim, so two concurrent deliveries of the same signed
event can't both reach handle() and pay twice; also reject
future-dated created_at beyond a small skew (S6).
- Serialize budget-affecting methods per app with an async mutex so a
concurrent request can't read a stale spent-total and overspend (S8).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
| |
POST /upload/:type fed the raw uploaded image straight into sharp with
no auth preValidation (unlike neighboring routes), reaching a native
libvips decoder in the container that holds the JWT secret and CLN hot
wallet. Require auth, and bump sharp 0.33.5 -> 0.35.3 (out of the
vulnerable < 0.35.0 libvips range, GHSA-f88m-g3jw-g9cj et al.).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
The request logger persisted full req.headers to the "req" log,
including the Authorization bearer token and the token cookie — both
non-expiring JWTs, so log-read access became account takeover. Redact
the authorization and cookie headers. Also set httpOnly/secure/sameSite
on the token cookie so it isn't readable by injected JS or sent over
plaintext HTTP. (Revocation is already handled by the evicted set.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The master-password path (isAdmin = password === config.adminpass) logs
in as any user, skipping password, 2FA, and captcha. Worse, when
adminpass is unset an omitted password field made undefined === undefined
true, authenticating as anyone. Require both the configured value and the
supplied password to be non-empty and match exactly, across every
adminpass comparison (login, recaptcha bypass, support email, freeze).
Also scrub config.ts.sample: the sample shipped a working-looking JWT
secret, two server nsec keys, and adminpass "pwa". Replace with
fail-closed placeholders and regenerate instructions. Rotate any
deployment that ever shipped the sample values.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
| |
Registration, password change, and admin reset all hashed at bcrypt
cost 4 (16 rounds, ~1,300 guesses/s/thread) — the single factor gating
the custodial balance and the nip49 passphrase for the stored nostr key.
Raise all three sites to cost 12, and on successful login re-hash any
legacy cost-4 hash now that we hold the verified plaintext.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
The websocket auth gate used jwt.decode(), which parses the payload but
never checks the signature — anyone could forge {id: <victim uid>}
(alg:none or any key), register their socket under the victim's uid, and
receive every emit for that account. Switch both track() and the "login"
gate to jwt.verify(token, config.jwt), rejecting forged/altered tokens.
Also project the full-record socket emits through pick(user, whitelist)
so a "user" event can never carry the bcrypt hash, TOTP secret, or PIN —
matching the shape /login and /me already return.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Serving local accounts unconditionally sent migrated names (whose legacy
user record still exists) back to the old account, and left v3-only
custom names 500ing — only npub1* names had a Cloudflare redirect.
Resolution order is now: registrar-claimed -> registrar (response handed
through, cached 60s, fail-open to local if the registrar is down), else
local account, else 404. The migrated flag stays out of it: register.ts
stamps it on essentially every account. No loop: we defer only for names
the registrar HAS, and it never queries us for those.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A recent change made this coinos.io lnurlp endpoint defer any
migrated-flagged account to the v3 names registrar and 404 when the
registrar didn't have the name. But registration stamps that flag on
essentially every account and never syncs names to the registrar, so
this broke lightning-address resolution for nearly all accounts.
The v3 registrar is the front for v3 accounts and falls back to this
endpoint, so this endpoint is the fallback target: it must serve its own
accounts directly and must not defer back (which also avoids a loop once
the registrar's fallback is live). Serve the local account; 404 only when
no local account exists.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
The stored replaceable 13194 info event was intermittently disappearing
from the relay, leaving NWC clients (Alby etc.) with "no info event"
until the next hourly existence-check happened to republish it. Drop the
check-then-publish dance and just republish both server info events every
2 minutes — a replaceable event overwrites in place, so it's cheap and
self-heals immediately. Silence the per-publish success log since it now
fires on every interval.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
|
| |
|
|
|
|
|
|
|
|
|
|
| |
A `migrated` flag on the user, settable through /user and visible on the
public user endpoint. It changes exactly one thing: lnurlp for that
username defers to the names registrar, so payments to [email protected]
arrive in the person's v3 wallet. The account itself is untouched — same
username, same history, still usable here — which is why this is a flag
and not a rename or a shutdown.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Clamp a non-internal receipt's tip to the settled amount so a tip
mutated larger than the amount actually received can't produce a
negative, reversible payment record on the recipient.
Also reject an autowithdraw whose destination resolves back to the
sender: that self-send re-fires completePayment and, since the balance
nets out, never drops below threshold, looping unbounded. The route's
create() already has the equivalent self-send guard; the internal
pay() helper lacked it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dpqu6wizYe1c1g4k5Gd8JK
|
| |
|
|
|
|
|
|
|
| |
An address at this domain may now belong to a coinos v3 wallet rather
than a local account. When the username isn't ours, ask the names
registrar and pass its payRequest through unchanged — its callback is
absolute, so the payer talks to it directly from there.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
sendKeysend debited, ran ln.keysend, and on a throw decided whether to
refund by calling listpays({payment_hash: hash}) with the CALLER's id (a
Nostr ev.id on the NWC path). keysend randomizes its own payment hash —
the CLN command takes no preimage arg — so that lookup could never match:
it returned [], `failed` was always true, and reverse() ran on EVERY
throw. A keysend that threw but actually settled was re-credited while
the sats had left the node, a repeatable hot-wallet drain. Nothing
corrected it either: unlike sendLightning, keysends are never enrolled in
`pending`, so check() never revisits them.
Recovering the real hash from the error payload doesn't work — verified
on CLN v26.06.2 that keysend errors carry no payment_hash under any key
(the error is {code, message, attempts}), so that approach never reverses
and instead eats the user's money on every ordinary routing failure.
Instead tag the payment with our own unique label and record where the
sendpays index stands first (`wait sendpays created 0` returns the
current index without blocking), so a throw can list only sendpays
created since — no full scan — and match ours by label whatever the error
looks like. CLN writes the sendpay record BEFORE sending the HTLC, so no
record at all proves nothing left the node and the debit is safe to
reverse. Verified both directions on regtest: an unroutable keysend is
refunded, and a settled one is correctly detected and left alone.
Known gap: a keysend still in flight when the RPC throws stays debited,
since keysends are not enrolled in `pending` for check() to reconcile.
Reported-by: Muhammed Shekho (@SYR-ROOT) <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
|