lightningd: check expiry bounds in createinvoice too
What changed, and why it matters
This commit fixes a bug in Core Lightning's createinvoice RPC. A malicious or malformed BOLT11 invoice could include an expiry time of 2^32 seconds or more. Because createinvoice decoded the invoice without checking signature validity, it would accept that oversized expiry and either crash the daemon (inside bolt11_encode) or cause the expiry timer to misbehave. The fix adds the same upper bound check that already existed for the invoice RPC, refusing such invoices with a clear error message instead of crashing or busy-looping.
Apply the patch and run the included regression test. Review other RPCs that decode BOLT11 without signature verification to ensure similar bounds are enforced consistently. Consider hardening bolt11_encode and the timer code against out-of-range expiry values as defense in depth.
Security signals we found
Denial-of-service vector: malformed input can crash daemon or cause busy-loop
Input validation gap: signature-not-required decode path bypasses existing expiry bound
Integer/overflow-related failure: expiry timer overflow at large values
Assertion abort in bolt11_encode() for expiry > 60 bits
Fix mirrors existing bound from invoice RPC into createinvoice RPC
Evidence from the diff
The createinvoice RPC decodes a supplied BOLT11 string via bolt11_decode() without requiring a valid signature. The decoded b11->expiry field is a varint and can therefore carry values >= 2^32. Previously, createinvoice passed this value straight into invoice creation. That triggered two problems: bolt11_encode() aborts when expiry exceeds 60 bits, and the expiry timer overflows its u64 nanosecond granularity at values far below 2^60. The patch adds an explicit b11->expiry < 2^32 check before re-encoding, mirroring the existing bound in the invoice RPC. A regression test crafts a junk-signed BOLT11 with expiry 2^32 and verifies it is rejected, while expiry 2^32-1 with a matching preimage still succeeds.
Changed components
lightningd/invoice.ccreateinvoice JSON-RPC commandBOLT11 decode/re-encode pathinvoice expiry timerInspect captured patch +56 / −0
### lightningd/invoice.c
@@ -1698,6 +1698,14 @@ static struct command_result *json_createinvoice(struct command *cmd,
NULL, chainparams, &hash, &sig, &have_n,
&fail);
if (b11) {
+ /* Same bound as the invoice RPC: past 60 bits
+ * bolt11_encode() below aborts, and the expiry timer
+ * overflows its u64 nanosecond grain far below that. */
+ if (b11->expiry >= (u64)1 << 32)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "expiry must be below 2^32 seconds"
+ " (~136 years)");
+
/* This adds the signature */
char *b11enc = bolt11_encode(cmd, b11, have_n,
hsm_sign_b11, cmd->ld);
### tests/test_invoices.py
@@ -1162,3 +1162,51 @@ def test_invoice_maxdesc(node_factory, chainparams):
# This should succeed.
inv = l1.rpc.invoice(123000, 'test_invoice_maxdesc3', maxdesc)
assert l1.rpc.decode(inv['bolt11'])['description'] == maxdesc
+
+
+def test_createinvoice_expiry_too_large(node_factory):
+ """createinvoice must apply the same expiry bound as invoice.
+
+ A crafted bolt11 can carry an expiry past the invoice RPC's gate:
+ decode does not verify the signature, so the huge `x` value flows
+ into invoice creation -- where bolt11_encode() aborts past 60 bits
+ and the expiry timer busy-loops far below that. Same bound, same
+ message as the invoice RPC.
+ """
+ import hashlib
+ import bitstring
+ from pyln.proto.invoice import tagged, tagged_bytes
+ from pyln.proto.bech32 import bech32_encode
+
+ def crafted_bolt11(payment_hash, expiry):
+ """A parseable-but-junk-signed bolt11: createinvoice decodes
+ without verifying the signature, so the 65 trailing bytes are
+ filler; every field the parser needs is real."""
+ data = bitstring.pack('uint:35', 12345678)
+ data += tagged_bytes('p', payment_hash)
+ data += tagged('d', bitstring.BitArray(b'crafted'))
+ xbits = bitstring.pack('uint:64', expiry)[4:]
+ while xbits.startswith('0b00000'):
+ xbits = xbits[5:]
+ while xbits.len % 5 != 0:
+ xbits.prepend('0b0')
+ data += tagged('x', xbits)
+ data += tagged_bytes('s', bytes(32))
+ data += bitstring.BitArray(bytes(65))
+ return bech32_encode(
+ 'lnbcrt', bytes([data[i:i + 5].uint
+ for i in range(0, data.len, 5)]))
+
+ l1 = node_factory.get_node()
+
+ with pytest.raises(RpcError, match='expiry must be below'):
+ l1.rpc.createinvoice(crafted_bolt11(bytes(32), 2**32),
+ 'label', '00' * 32)
+
+ # The exact boundary still works: a matching preimage and the
+ # largest in-bounds expiry recreate cleanly.
+ preimage = bytes(range(32))
+ ok = l1.rpc.createinvoice(
+ crafted_bolt11(hashlib.sha256(preimage).digest(), 2**32 - 1),
+ 'boundary-label', preimage.hex())
+ assert ok['bolt11']Why this scored 76/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.