test: adversarial threat-suite PoCs for bearer-note exposure options - #22
Merged
Conversation
One executable scenario per row of the T1-T10 scorecard from the LUD-25 design debate (comment-secret / ?p= / hash-keyed informational GET / blinded signatures), so candidate fixes get measured against the same attacks. Attack chains that succeed today assert current behavior with INVERTS WHEN markers (flipping red when the named fix lands); controls pin behavior that must never change. Takes no position on which option wins - documentation, not a fix.
…at suite Option G (LUD-05/LUD-13 linkingKey-gated redemption) scored separately as a second asset class, with the new T11 (offline handoff) row marking what it costs. T9 is now executable: pins that /p/cb silently drops a comment param today (INVERTS WHEN the comment-secret lands). Seed-recoverable notes documented as needing no protocol change beyond hash-keyed lookup.
TheCryptoDonkey
added a commit
to TheCryptoDonkey/lnurlcash-conformance
that referenced
this pull request
Aug 20, 2026
Non-normative scorecard of adversarial scenarios T1-T11 (verify race, routing-node race, log replays, at-rest axiom, operator correlation, legacy melt, offline verify gap, silent comment, merge URL budget, offline handoff) against candidate spec options A-G. Only pins-current rows describe required behavior; vulnerable pins exist to be inverted by the PR landing the named option. T10's URL budget is computed in the generator, and selfcheck recomputes it. Executable companion: lnurl-mint's tests/test_bearer_threat_suite_poc.py (dni/lnurl-mint#22).
… race) grep -q exits on first match and closes the pipe; curl then dies with SIGPIPE (exit 23) and the NixOS test driver's pipefail turns a passing match into a flaky failure. Without -q, grep consumes the whole stream, still exits non-zero on no match, and logs the matched line. Unrelated to the threat-suite content, but the red check blocks the PR.
dni
approved these changes
Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
An adversarial threat-suite for the bearer-note transport/exposure options currently debated for LUD-25 — one executable scenario per scorecard row, so candidate fixes get measured against the same attacks instead of argued about in the abstract.
This PR is documentation, not a fix. It takes no position on which option wins; it gives the debate a shared, runnable baseline. Fix PRs come later, one per chosen option, each flipping its marked tests.
Language-neutral twin: the same T1–T11 scorecard now also exists as conformance vectors at
lnurlcash-conformance→vectors/threat-suite.json, loadable from any implementation's test suite.Options under test
LNURLcash, Bearer assets lnurl/luds#301 as drafted)k1becomes<secret>:<preimage>, and the public LUD-21 preimage alone no longer redeems anything?p=everywhere": everyk1replaced in transport by thatk1encrypted to the mint/wbysha256(k1), neverk1)k1stays a short plaintext secret, but redemption requires an LUD-04 signature from the LUD-05/LUD-13linkingKeyregistered at mint/rotate time. Signature-gated — no ciphertext in URLs anywhere. Scored separately below, since the trades differ by note type.Scorecard
(✓ = attack fails / property holds, ✗ = attack succeeds)
tests/test_poc_verify_race.py(existing)test_t2_...(this PR)test_t3_...(this PR)test_t4_...(this PR)test_t5_...(this PR)test_t6_...(this PR)test_lnurlcash.py/test_verify.pymintPubkeyon record; spec-level gap, no endpoint to hittest_t9_...(this PR)test_t10_...(this PR)Option G (locked notes) — where it wins, what it costs
/p/cb, so the note is locked to itslinkingKeyfrom birth; a racer holding onlyPcannot redeem. No comment-secret needed for these notes.k1is useless without the key.That is the whole price: locked notes are registered claims, not cash. Bearer core (B/D) and locked notes (G) are complements, not competitors — ship the bearer-side race fixes now, spec G as the extension for claim-check use cases.
Why C loses T1/T2/T3: encrypting to the mint is a public operation (
mintPubkeyis advertised), so a racer wraps a leaked preimage himself and replays it; and a loggedpredeems exactly like a loggedk1— the mint honors the ciphertext, so the ciphertext is the note. Re-encrypting a bearer credential to the party that redeems it never shrinks its exposure set; the only encryption that helps is encrypting to the holder, which kills bearer-ness (G takes that trade deliberately, via signatures rather than ciphertext).Why T2 matters as much as T1: the race exists even with
VERIFY_ENABLED=false— every routing node on the mint payment's path learns the preimage as the HTLC settles. Verify only makes the race public; the comment-secret is what closes it in both forms.Seed-recoverable notes (needs no protocol change beyond D)
A WALLET that derives its note secrets deterministically from its seed (BIP32 — reusing LUD-05's own
m/138'/HMAC(domain) path trick, plus a counter) can restore outstanding notes from the seed alone: re-derive candidates, hash them, look them up bysha256— option D doubles as the restore API. Freshly minted notes (k1= the mint-generated preimage) are never seed-derived; they live in the wallet's Lightning payment history until rotate-on-receipt converts them into seed-derived ones — the security rule and the backup rule are the same act. Restore covers device loss, not theft: anyone who copied a circulating note may have spent it long before the restore runs. (Cashu's NUT-13 already does deterministic secrets — this is parity, not invention.) Pin ONE derivation convention in the spec, or wallets fragment and restores silently miss notes.Red/green policy
Same convention as
test_melt_restore_double_payout_poc.py: tests documenting an attack that succeeds today assert the current vulnerable behavior and carryINVERTS WHENin their docstring — the PR landing the named option flips them red, forcing the assertions to be rewritten against the fixed behavior. Control tests (T4, T5) pin behavior that must never change. All 223 tests pass as of this PR.Implementation notes for the option-B PR (found while writing this suite)
router._resolve_note'sHEX32_PATTERNrejects anyk1that isn't 64 lowercase hex — a composite<secret>:<preimage>k1dies at the door. That PR must relax the pattern (NoteStore ids staysha256over the composite's raw bytes, so payment-hash-keyed mints keep working).router.py's own docstrings already cite "the spec's Security considerations" for the rotate-immediately rule — a section LUD-25 does not yet contain. This suite is that section's executable skeleton.signmessageRPC it already uses (LUD-13's trick: a canonical, never-signable-on-request phrase,enc_priv = sha256(sig)), so no raw-ECDH RPC (LNDDeriveSharedKey/ CLNgetsharedsecret) is needed. The derived pubkey ≠ node id, so it must be published at payRequest time (next towithdrawLink) — the comment rides the callback request, before any invoice exists. Decrypt at/p/cbtime, store onlysha256(secret:P), never the ciphertext.One unrelated change, called out rather than buried
flake.nixdrops-qfrom threecurl | grepchecks in the NixOS VM test. Nothing to do with bearer notes, and happy to split it out if you would rather.grep -qexits on the first match and closes the pipe,curlthen dies ofSIGPIPE(exit 23), and the driver'spipefailturns a passing match into a failing step. It only bites when the response is large enough that curl is still writing when grep exits, which is why it reads as an intermittent CI flake rather than a broken assertion. Dropping-qcosts a line of log output and makes the check deterministic.