Skip to content

test: adversarial threat-suite PoCs for bearer-note exposure options - #22

Merged
dni merged 3 commits into
dni:mainfrom
TheCryptoDonkey:tests/bearer-threat-suite
Aug 25, 2026
Merged

test: adversarial threat-suite PoCs for bearer-note exposure options#22
dni merged 3 commits into
dni:mainfrom
TheCryptoDonkey:tests/bearer-threat-suite

Conversation

@TheCryptoDonkey

@TheCryptoDonkey TheCryptoDonkey commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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-conformancevectors/threat-suite.json, loadable from any implementation's test suite.

Options under test

  • A — status quo (LUD-25: LNURLcash, Bearer assets lnurl/luds#301 as drafted)
  • B — A + comment-secret: WALLET attaches a secret encrypted to the mint on the payRequest (LUD-12 comment); the note's k1 becomes <secret>:<preimage>, and the public LUD-21 preimage alone no longer redeems anything
  • C — "?p= everywhere": every k1 replaced in transport by that k1 encrypted to the mint
  • D — A + hash-keyed informational GET (poll /w by sha256(k1), never k1)
  • E — blinded signatures (chaumian model)
  • F — B + D
  • G — locked notes: a second asset class, not a bearer variant. The k1 stays a short plaintext secret, but redemption requires an LUD-04 signature from the LUD-05/LUD-13 linkingKey registered 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)

scenario A B C D E F covered by
T1 verify race tests/test_poc_verify_race.py (existing)
T2 routing-node race (no verify needed) test_t2_... (this PR)
T3 poll-log replay test_t3_... (this PR)
T4 callback-log replay (control) test_t4_... (this PR)
T5 note at rest (bearer axiom, control) test_t5_... (this PR)
T6 operator correlation test_t6_... (this PR)
T7 legacy LUD-03 melt melt tests in test_lnurlcash.py / test_verify.py
T8 first-contact offline verify analytical — needs mintPubkey on record; spec-level gap, no endpoint to hit
T9 comment silently ignored today test_t9_... (this PR)
T10 merge URL budget test_t10_... (this PR)
T11 offline handoff structural — no endpoint; the bearer property itself (the spec's Offline circulation section)

Option G (locked notes) — where it wins, what it costs

  • T1/T2 ✓ — the wallet authenticates at /p/cb, so the note is locked to its linkingKey from birth; a racer holding only P cannot redeem. No comment-secret needed for these notes.
  • T3/T4 ✓ — a logged k1 is useless without the key.
  • T5 ✓ — the only option that beats the at-rest axiom, precisely because it gives up the property the axiom protects.
  • T6 ✗ — the operator knows exactly which key owns which notes.
  • T7 ✗ — a plain LUD-03 wallet cannot lnurl-auth; no legacy story.
  • T11 ✗ — offline handoff dies; transfer requires an online re-lock via the mint.

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 (mintPubkey is advertised), so a racer wraps a leaked preimage himself and replays it; and a logged p redeems exactly like a logged k1 — 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 by sha256 — 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 carry INVERTS WHEN in 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's HEX32_PATTERN rejects any k1 that isn't 64 lowercase hex — a composite <secret>:<preimage> k1 dies at the door. That PR must relax the pattern (NoteStore ids stay sha256 over 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.
  • Encryption key management, resolved in discussion: the mint derives its encryption keypair from the signmessage RPC it already uses (LUD-13's trick: a canonical, never-signable-on-request phrase, enc_priv = sha256(sig)), so no raw-ECDH RPC (LND DeriveSharedKey / CLN getsharedsecret) is needed. The derived pubkey ≠ node id, so it must be published at payRequest time (next to withdrawLink) — the comment rides the callback request, before any invoice exists. Decrypt at /p/cb time, store only sha256(secret:P), never the ciphertext.

One unrelated change, called out rather than buried

flake.nix drops -q from three curl | grep checks in the NixOS VM test. Nothing to do with bearer notes, and happy to split it out if you would rather.

grep -q exits on the first match and closes the pipe, curl then dies of SIGPIPE (exit 23), and the driver's pipefail turns 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 -q costs a line of log output and makes the check deterministic.

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
dni merged commit 80e8630 into dni:main Aug 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants