Skip to content

bolt11: reject duplicate payment hashes in invoices - #1357

Open
Roasbeef wants to merge 5 commits into
masterfrom
bolt11-single-payment-hash
Open

Roasbeef wants to merge 5 commits into
masterfrom
bolt11-single-payment-hash

Conversation

@Roasbeef

Copy link
Copy Markdown
Collaborator

In this PR, we modify the spec to reject an invoice with duplicate payment hashes.

Unfortunately there was a recent hack o fa popular telegram p2p bot that exploited this ambiguity. Some libraries took the first payment hash, while some took the last. If you were running a system that exhibited both variants of the behavior (diff between libraries, or nodes, etc), then your system could be tricked into thinking it never paid out a withdrawl, thereby leading to a vuln that could drain the system.

IMO there's no reason an invoice should have > 1 payment hash, so we should just reject it.

Test vectors have been updated accordingly.

In this commit, we correct the existing tagged-field vector to match the reader requirement for fixed-length fields. The vector previously labeled malformed p, h, s, and n fields as ignored, even though a reader must fail the payment when their lengths are invalid.

We now mark the invoice and those fields as invalid, while leaving the unknown fallback version marked as ignored.
In this commit, we require readers to fail a payment when an invoice contains more than one p field. The prior payer rule selected the first field, which allowed two components to interpret the same signed invoice using different payment hashes.

A valid invoice now has one unambiguous p field, and the payer must use it.
In this commit, we add two signed invalid invoices with two valid-length p fields. One uses distinct hashes to catch first- or last-field selection, while the other repeats the same hash to ensure implementations count fields instead of deduplicating values.

Both vectors use the spec's documented key and have valid signatures and checksums, leaving the duplicate p fields as the intended rejection condition.
In this commit, we move the duplicate payment hash invoice vectors into a machine-readable JSON file. The BOLT 11 document now links to the canonical vectors instead of embedding a second copy.
In this commit, we move every encoded BOLT 11 invoice into the machine-readable test vector file. The prose keeps the detailed breakdowns, while the JSON file becomes the single source for both valid and invalid invoice strings.

@TheBlueMatt TheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh. Yea, should do, obviously.

Comment thread 11-payment-encoding.md

# Examples

Machine-readable invoice test vectors for all examples below are provided in

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems weird to move the invoice data to a json file but leave the descriptions here, makes it much harder to parse.

@MPins

MPins commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Concept ACK on the duplicate rule.

One suggestion on the test vectors. Marking the "fields which must be ignored" invoice as invalid is correct, but it was also the only example exercising two forward-compatibility paths. I walked the tagged fields of all 28 vectors in the proposed bolt11/invoice-test.json: the unknown field type 2 and the f field with unknown version 19 appear in exactly one invoice — the one this PR marks "valid": false — and none of the 15 valid vectors carries an unknown field type or an unknown fallback version. So after this PR nothing positive covers either path.

Comment thread 11-payment-encoding.md
> ### Same, but with unknown fields and invalid fixed-length fields.

> ### Same, but including fields which must be ignored.
> lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2qrqqqfppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqppnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhpnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqhp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsp4qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnp5qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnpkqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqz599y53s3ujmcfjp5xrdap68qxymkqphwsexhmhr8wdz5usdzkzrse33chw6dlp3jhuhge9ley7j2ayx36kawe7kmgg8sv5ugdyusdcqzn8z9x

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marking this invoice as invalid is correct, but it was also the only example exercising two forward-compatibility paths:

  • the unknown field type 2
  • the f field with unknown version 19

@saubyk saubyk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept Ack

@ekzyis ekzyis left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concept ACK

Fwiw, I was confused about the sender vs. receiver requirements for parsing fields with a fixed length (#1305). I mentioned in the LDK Discord that these two statements seemed to be in conflict if the invoice contained both a valid and an invalid p field:

[a payer] SHOULD use the first p field as the payment hash.

[a reader] MUST fail the payment if any field with fixed data_length (p, h, s, n) does not have the correct length (52, 52, 52, 53).

It was confusing because it wasn't clear whether the fixed-length check applies after we pick the first p, or whether it applies to any p, which implies there might be multiple.

I mentioned that I would try to make this less confusing. Maybe I would have found this vulnerability if I had done that 😅

I think that with "MUST use the first p field as the payment hash" and "MUST fail the payment if more than one p field is present" from this PR, it is now clearer how to handle p.

@ziggie1984

Copy link
Copy Markdown
Contributor

Concept ACK. Two things, one substantive and one about the vector format.

The same rule arguably belongs on s

The writer requirements already mandate both:

  • MUST include exactly one p field.
  • MUST include exactly one s field.

but this PR only adds the matching reader rule for p. The reader side for s is still phrased in the singular with no rule for what happens when several are present:

  • if a valid s field is not provided:
    • MUST fail the payment.
  • otherwise:
    • MUST use the s field as payment_secret

That is the same ambiguity being closed for p: "the s field" does not say which one, so one implementation takes the first and another takes the last, and two components fed the same signed invoice disagree on the payment_secret. The consequences differ from the payment-hash case — a mismatched secret mostly means the payment is rejected at the recipient rather than a bookkeeping split — but a system that decides "paid" or "not paid" by matching secrets has the same shape of bug available to it, and @ekzyis's point above is that the p wording was already confusing enough to hide one.

Since the writer rule exists for both fields, the cheapest fix is to extend the new line:

  • MUST fail the payment if more than one p or s field is present.

Or, more generally, fail on any repeat of a field the writer section specifies as "exactly one" or "one" (p, s, d/h, x, c, n, m), leaving f, r and b repeatable. I'd understand keeping this PR to p if the broader rule needs its own discussion, but s seems in scope given the writer rule is already there.

The vectors record the verdict but not the reason

Building on @TheBlueMatt's point about the split between the JSON and the descriptions: as it stands each entry carries only valid: true|false, which lets an implementation pass a vector by rejecting the invoice for an unrelated reason.

Concretely, I ran the 28 proposed vectors through lnd's BOLT 11 decoder, before and after lightningnetwork/lnd#11190 (which implements this PR's p rule):

  • base: 22/28 agree, after: 25/28. The three that flip are exactly the duplicate-p cases, which is the intended effect.
  • Unknown fields and invalid fixed-length fields is marked invalid here because several fixed-length fields have the wrong data_length. lnd rejects it with invoice contains multiple payment hashes — the right verdict for the wrong reason, and it would keep passing even if lnd never implemented the length rule. (That invoice now violates two separate MUSTs, so it can't distinguish them.)
  • Three valid: false vectors are accepted outright by lnd today: Unknown required feature 100, Missing required s field, and Non canonical signature (high-S) with 'n' field defined. Those are pre-existing divergences, not something this PR causes — but they're invisible in a pass/fail-only format until someone reads the output by hand.

Suggestion: give each invalid vector an expected-failure tag (e.g. "failure": "duplicate_payment_hash" / "invalid_field_length" / "unknown_required_feature"), and give the valid ones the expected decoded values (payment_hash, amount_msat, payment_secret, …). If the JSON is going to be the machine-readable source of truth, it may as well carry the assertions rather than just the verdict — that also gives the duplicate-p rule a vector that can only be passed by implementing it.

Roasbeef added a commit to Roasbeef/lnd that referenced this pull request Sep 23, 2026
In this commit, we address the reviewer feedback on the duplicate payment
hash rejection.

We move the release notes entry from BOLT Spec Updates to Bug Fixes in
the v0.22.0 notes, since rejecting a second payment hash field is
deliberately ahead of the current BOLT 11 text; the entry now carries an
interop note and links lightning/bolts#1357 as the motivation. We also
add the same entry to the v0.20.5 and v0.21.4 notes, as the fix will be
backported to those releases.

We replace the stale comment above parse32Bytes, which quoted the old
BOLT 11 rule that a reader must skip over 32-byte fields with an
unsupported length. The current reader requirements say the opposite: a
reader must fail the payment if a fixed-length field (p, h, s, n) does not
have the correct length. The new comment states that reporting the field
as absent is a known divergence, and explains why the end result for the
payment hash is the same either way.

In the tests, we keep a valid variant of the flipped vector that drops
the duplicate payment hash but retains the wrong-length h and n fields,
so the coverage that such fields are skipped survives. The duplicate
vector stays in the decode table, now pinning ErrDuplicatePaymentHash
through the new wantErr field rather than asserting any decode error. We
also replace the rapid property test with two table rows in
TestParseTaggedFields covering identical and distinct payment hashes,
since the drawn bytes could not influence the outcome.
Roasbeef added a commit to Roasbeef/lnd that referenced this pull request Sep 23, 2026
In this commit, we address the reviewer feedback on the duplicate payment
hash rejection.

We move the release notes entry from BOLT Spec Updates to Bug Fixes in
the v0.22.0 notes, since rejecting a second payment hash field is
deliberately ahead of the current BOLT 11 text; the entry now carries an
interop note and links lightning/bolts#1357 as the motivation. We also
add the same entry to the v0.20.5 and v0.21.4 notes, as the fix will be
backported to those releases.

We replace the stale comment above parse32Bytes, which quoted the old
BOLT 11 rule that a reader must skip over 32-byte fields with an
unsupported length. The current reader requirements say the opposite: a
reader must fail the payment if a fixed-length field (p, h, s, n) does not
have the correct length. The new comment states that reporting the field
as absent is a known divergence, and explains why the end result for the
payment hash is the same either way.

In the tests, we keep a valid variant of the flipped vector that drops
the duplicate payment hash but retains the wrong-length h and n fields,
so the coverage that such fields are skipped survives. The duplicate
vector stays in the decode table, now pinning ErrDuplicatePaymentHash
through the new wantErr field rather than asserting any decode error. We
also replace the rapid property test with two table rows in
TestParseTaggedFields covering identical and distinct payment hashes,
since the drawn bytes could not influence the outcome.
Roasbeef added a commit to Roasbeef/lnd that referenced this pull request Sep 23, 2026
In this commit, we reject BOLT 11 invoices that contain more than one
payment hash (`p`) field. `zpay32.Decode` previously kept the first
supported payment hash and ignored later fields, so the interpreted
payment hash depended on field order.

The decoder now tracks whether a `p` field has appeared separately from
whether its contents parsed successfully, and any later `p` field returns
`ErrDuplicatePaymentHash`. This covers identical hashes, distinct hashes,
and a malformed first field followed by a valid one. The change is
deliberately stricter than the current BOLT 11 text, which tells a reader
to use the first `p` field; it is motivated by lightning/bolts#1357, and
will be backported to the v0.20.5 and v0.21.4 releases.

We also replace the stale comment above parse32Bytes, which quoted the
old BOLT 11 rule that a reader must skip over 32-byte fields with an
unsupported length. The current reader requirements say the opposite: a
reader must fail the payment if a fixed-length field (p, h, s, n) does
not have the correct length. The new comment states that reporting the
field as absent is a known divergence, and explains why the end result
for the payment hash is the same either way.

In the tests, we cover identical and distinct duplicate hashes, a
malformed first field followed by a valid one, and the signed vectors. A
valid variant of the flipped vector keeps the wrong-length h and n
fields, so the coverage that such fields are skipped survives. The
duplicate vector in the decode table pins ErrDuplicatePaymentHash
through a new wantErr field rather than asserting any decode error.
ziggie1984 pushed a commit to lightningnetwork/lnd that referenced this pull request Sep 23, 2026
In this commit, we reject BOLT 11 invoices that contain more than one
payment hash (`p`) field. `zpay32.Decode` previously kept the first
supported payment hash and ignored later fields, so the interpreted
payment hash depended on field order.

The decoder now tracks whether a `p` field has appeared separately from
whether its contents parsed successfully, and any later `p` field returns
`ErrDuplicatePaymentHash`. This covers identical hashes, distinct hashes,
and a malformed first field followed by a valid one. The change is
deliberately stricter than the current BOLT 11 text, which tells a reader
to use the first `p` field; it is motivated by lightning/bolts#1357, and
will be backported to the v0.20.5 and v0.21.4 releases.

We also replace the stale comment above parse32Bytes, which quoted the
old BOLT 11 rule that a reader must skip over 32-byte fields with an
unsupported length. The current reader requirements say the opposite: a
reader must fail the payment if a fixed-length field (p, h, s, n) does
not have the correct length. The new comment states that reporting the
field as absent is a known divergence, and explains why the end result
for the payment hash is the same either way.

In the tests, we cover identical and distinct duplicate hashes, a
malformed first field followed by a valid one, and the signed vectors. A
valid variant of the flipped vector keeps the wrong-length h and n
fields, so the coverage that such fields are skipped survives. The
duplicate vector in the decode table pins ErrDuplicatePaymentHash
through a new wantErr field rather than asserting any decode error.

(cherry picked from commit e2f2706)
ziggie1984 pushed a commit to lightningnetwork/lnd that referenced this pull request Sep 23, 2026
In this commit, we reject BOLT 11 invoices that contain more than one
payment hash (`p`) field. `zpay32.Decode` previously kept the first
supported payment hash and ignored later fields, so the interpreted
payment hash depended on field order.

The decoder now tracks whether a `p` field has appeared separately from
whether its contents parsed successfully, and any later `p` field returns
`ErrDuplicatePaymentHash`. This covers identical hashes, distinct hashes,
and a malformed first field followed by a valid one. The change is
deliberately stricter than the current BOLT 11 text, which tells a reader
to use the first `p` field; it is motivated by lightning/bolts#1357, and
will be backported to the v0.20.5 and v0.21.4 releases.

We also replace the stale comment above parse32Bytes, which quoted the
old BOLT 11 rule that a reader must skip over 32-byte fields with an
unsupported length. The current reader requirements say the opposite: a
reader must fail the payment if a fixed-length field (p, h, s, n) does
not have the correct length. The new comment states that reporting the
field as absent is a known divergence, and explains why the end result
for the payment hash is the same either way.

In the tests, we cover identical and distinct duplicate hashes, a
malformed first field followed by a valid one, and the signed vectors. A
valid variant of the flipped vector keeps the wrong-length h and n
fields, so the coverage that such fields are skipped survives. The
duplicate vector in the decode table pins ErrDuplicatePaymentHash
through a new wantErr field rather than asserting any decode error.

(cherry picked from commit e2f2706)

@Abdulkbk Abdulkbk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cACK

Comment thread 11-payment-encoding.md
A reader:
- MUST skip over `f` fields that use an unknown `version`.
- MUST fail the payment if any field with fixed `data_length` (`p`, `h`, `s`, `n`) does not have the correct length (52, 52, 52, 53).
- MUST fail the payment if more than one `p` field is present.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we rephrase this to something like "MUST fail unless exactly one p field is present" such that it's clear we should fail if p < 1?

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.

7 participants