What changed, and why it matters
This commit tightens how Electrum parses BOLT11 Lightning invoices. It now rejects duplicate 'n' (node pubkey) tags instead of silently keeping only the first one, and it rejects invoice timestamps that are negative or too far in the future. These are defensive correctness fixes that reduce the chance a malformed or malicious invoice could confuse the wallet, but they are follow-up cleanups rather than a fix for a known active attack.
Review and merge as a hardening improvement. Users and downstream integrators should ensure they are on a version that includes this follow-up if they process untrusted BOLT11 invoices.
Security signals we found
Stricter validation of invoice timestamp bounds
Duplicate 'n' tag now raises an exception instead of being silently dropped
Follow-up to prior PR #10940, indicating a recent area of security-sensitive review
Test changes confirm parser now rejects duplicate tags including 'n'
Evidence from the diff
The patch changes BOLT11 invoice decoding in electrum/bolt11.py. TIMESTAMP_SANE_MAX is reduced by one to 2^35-1 and the date setter now rejects values outside [0, TIMESTAMP_SANE_MAX]. More significantly, duplicate ‘n’ tags now raise BOLT11DecodeException instead of being silently ignored after the first. Tests are updated to expect these stricter checks and to include a payment secret in test invoices so signature recovery paths remain covered.
Changed components
electrum/bolt11.pytests/test_bolt11.pyInspect captured patch +14 / −23
### electrum/bolt11.py
@@ -22,7 +22,7 @@
if TYPE_CHECKING:
from .lnutil import LnFeatures
-TIMESTAMP_SANE_MAX = 2**35
+TIMESTAMP_SANE_MAX = 2**35 - 1
class BOLT11InvoiceException(Exception): pass
@@ -319,8 +319,8 @@ def date(self, value: int | float):
if isinstance(value, float):
# e.g. from time.time()
value = int(value)
- if value > TIMESTAMP_SANE_MAX:
- raise BOLT11InvoiceException(f"date must be sane, not above {TIMESTAMP_SANE_MAX!r}")
+ if not 0 <= value <= TIMESTAMP_SANE_MAX:
+ raise BOLT11InvoiceException(f"date must be in [0; {TIMESTAMP_SANE_MAX!r}]: {value}")
self._date = value
def get_amount_sat(self) -> Optional[Decimal]:
@@ -608,11 +608,9 @@ def _check_minimal_data5(tag: str, data5: Sequence[int]) -> None:
raise BOLT11DecodeException("Unexpected 's' tag")
addr.payment_secret = bytes(_convertbits_tag(tag, tagdata, 5, 8, False, length_range=(52, 52)))
elif tag == 'n':
- # if a writer offers more than one of any field type, it:
- # MUST specify the most-preferred field first, followed by less-preferred fields, in order.
- # as we store a single pubkey, we only store the first
+ # MAY include one n field
if addr.pubkey is not None:
- continue
+ raise BOLT11DecodeException("Unexpected 'n' tag")
pubkeybytes = bytes(_convertbits_tag(tag, tagdata, 5, 8, False, length_range=(53, 53)))
addr.pubkey = pubkeybytes
elif tag == 'c':
### tests/test_bolt11.py
@@ -165,6 +165,7 @@ def _encode_invoice_with_raw_sig(sig65, *, net=None) -> str:
hrp = 'ln' + net.BOLT11_HRP
data5 = list(int_to_data5(1615922274, bit_len=35))
data5 += list(tagged8('p', RHASH))
+ data5 += list(tagged8('s', PAYMENT_SECRET))
data5 += list(tagged8('d', b'test'))
return bech32_encode(segwit_addr.Encoding.BECH32, hrp, data5 + list(convertbits(sig65, 8, 5, False)))
@@ -278,7 +279,7 @@ def test_invalid_signature(self):
# the recovery id (the last byte) must be 0-3
for recid in (4, 27, 255):
with self.subTest(recid=recid):
- with self.assertRaises(BOLT11DecodeException):
+ with self.assertRaisesRegex(BOLT11DecodeException, r"^Invalid signature:"):
decode_bolt11_invoice(self._encode_invoice_with_raw_sig(r_ok + s_ok + bytes([recid])))
# r and s must be below the curve order
@@ -289,15 +290,15 @@ def test_invalid_signature(self):
('s == n+1', r_ok + (ecc.CURVE_ORDER + 1).to_bytes(32, 'big')),
('s == 2**256-1', r_ok + b'\xff' * 32)):
with self.subTest(sig=label):
- with self.assertRaises(BOLT11DecodeException):
+ with self.assertRaisesRegex(BOLT11DecodeException, r"^Invalid signature:"):
decode_bolt11_invoice(self._encode_invoice_with_raw_sig(sig64 + b'\x00'))
# in-range but unrecoverable signature
- with self.assertRaises(BOLT11DecodeException):
+ with self.assertRaisesRegex(BOLT11DecodeException, r"^Invalid signature:"):
decode_bolt11_invoice(self._encode_invoice_with_raw_sig(r_ok + s_ok + b'\x03'))
# an 'n' field that is not a valid curve point (this path uses ecdsa_verify, not recovery)
- with self.assertRaises(BOLT11DecodeException):
+ with self.assertRaisesRegex(BOLT11DecodeException, r"^Invalid signature:"):
decode_bolt11_invoice(self._encode_invoice_with_raw_tag('n', list(convertbits(bytes(33), 8, 5))))
def test_mandatory_tags(self):
@@ -328,25 +329,17 @@ def test_duplicate_tags(self):
s5 = convertbits(PAYMENT_SECRET, 8, 5)
d5 = convertbits(b'test', 8, 5)
h5 = convertbits(sha256(b'test').digest(), 8, 5)
+ n5 = convertbits(PUBKEY, 8, 5)
# a second copy of a field we only keep one value for is rejected
for tag, tags5 in (('p', [('p', p5), ('p', p5), ('s', s5), ('d', d5)]),
('s', [('p', p5), ('s', s5), ('s', s5), ('d', d5)]),
('d', [('p', p5), ('s', s5), ('d', d5), ('d', d5)]),
- ('h', [('p', p5), ('s', s5), ('h', h5), ('h', h5)])):
+ ('h', [('p', p5), ('s', s5), ('h', h5), ('h', h5)]),
+ ('n', [('p', p5), ('s', s5), ('d', d5), ('n', n5), ('n', n5)])):
with self.subTest(tag=tag):
- with self.assertRaises(BOLT11DecodeException):
+ with self.assertRaisesRegex(BOLT11DecodeException, f"^Unexpected (multiple )?'{tag}' tags?$"):
decode_bolt11_invoice(self._encode_invoice_with_raw_tags(tags5))
- # 'n' is the exception: BOLT #11 has writers put the most-preferred field first, so the
- # first one is kept and the rest ignored. Note the invoice is signed by PUBKEY, so if
- # the second 'n' were the one kept, signature validation against it would fail.
- other_pubkey = ecc.ECPrivkey(bytes(31) + b'\x02').get_public_key_bytes(compressed=True)
- self.assertNotEqual(PUBKEY, other_pubkey)
- lnaddr = decode_bolt11_invoice(self._encode_invoice_with_raw_tags(
- [('p', p5), ('s', s5), ('d', d5),
- ('n', convertbits(PUBKEY, 8, 5)), ('n', convertbits(other_pubkey, 8, 5))]))
- self.assertEqual(PUBKEY, lnaddr.pubkey.serialize())
-
def test_invalid_utf8_description(self):
# the 'd' field is UTF-8: an invalid encoding must be rejected, not crash the parser
with self.assertRaises(BOLT11DecodeException):Why this scored 40/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.