lnutil.ChannelType: rm "discard unknown" part from discard_unknown_and_check
What changed, and why it matters
This commit removes a helper that silently stripped unknown bits from Lightning channel-type flags during channel setup. Previously, if a peer sent a channel type containing an unrecognized flag, Electrum would drop that flag and continue. Now it keeps the full value and enforces that the peer's channel type exactly matches what Electrum expects and supports. This is a hardening change: it closes a path where a malicious or buggy peer could trick Electrum into accepting a channel type it did not actually agree to.
Treat as a security-hardening fix and include in release notes. Users running Lightning nodes should upgrade to a version containing this commit to avoid the risk of accepting a manipulated channel type. No immediate incident response is indicated absent evidence of active exploitation.
Security signals we found
Removed silent stripping of unknown channel_type bits
Added explicit equality check between local and remote channel_type
Added assertion that local channel_type complies with local features before sending
complies_with_features now validates flag combinations
Commit message describes prior behavior as 'more dangerous than useful'
Evidence from the diff
The patch deletes ChannelType.discard_unknown_and_check() and replaces its uses with direct parsing plus explicit validation. In lnpeer.py, open_channel and accept_channel flows now keep the raw channel_type bytes, assert that the local channel type complies with local features, and require the remote channel_type to equal the local one exactly. complies_with_features() now calls check_combinations() internally, and lnworker.py adds an explicit check_combinations() call before funding. The removed helper used IntFlag.name to decide which bits were ‘known’ and silently masked off the rest, which could hide unsupported or variation flags and allow non-compliant channel types to pass validation.
Changed components
electrum/lnutil.py: ChannelType classelectrum/lnpeer.py: channel open/accept handshakeelectrum/lnworker.py: channel funding setuptests/test_lnutil.py: removed test for unknown-bit strippingInspect captured patch +9 / −24
diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py
index 6d8886d..6e62455 100644
--- a/electrum/lnpeer.py
+++ b/electrum/lnpeer.py
@@ -1007,6 +1007,7 @@ class Peer(Logger, EventListener):
our_channel_type |= ChannelType(ChannelType.OPTION_ZEROCONF)
# We do not set the option_scid_alias bit in channel_type because LND rejects it.
# Eclair accepts channel_type with that bit, but does not require it.
+ assert our_channel_type.complies_with_features(self.features), f"{our_channel_type=!r}, {self.features=!r}"
# if option_channel_type is negotiated: MUST set channel_type
# if it includes channel_type: MUST set it to a defined type representing the type it wants.
@@ -1092,9 +1093,11 @@ class Peer(Logger, EventListener):
their_channel_type = accept_channel_tlvs.get('channel_type')
if their_channel_type is None:
raise Exception("channel_type MUST be present in accept_channel, but missing")
- their_channel_type = ChannelType.from_bytes(their_channel_type['type'], byteorder='big').discard_unknown_and_check()
+ their_channel_type = ChannelType.from_bytes(their_channel_type['type'], byteorder='big')
+ # if channel_type does not match the channel_type from open_channel:
+ # MUST fail the channel.
if their_channel_type != our_channel_type:
- raise Exception("channel_type is not the one that we sent.")
+ raise Exception(f"channel_type is not the one that we sent. {our_channel_type=}. {their_channel_type=}.")
remote_config = RemoteConfig(
payment_basepoint=OnlyPubkeyKeypair(payload['payment_basepoint']),
@@ -1243,7 +1246,7 @@ class Peer(Logger, EventListener):
if channel_type is None:
raise Exception("channel_type MUST be present in open_channel, but missing")
# MUST fail the channel if channel_type is not suitable.
- channel_type = ChannelType.from_bytes(channel_type['type'], byteorder='big').discard_unknown_and_check()
+ channel_type = ChannelType.from_bytes(channel_type['type'], byteorder='big')
if not channel_type.complies_with_features(self.features):
raise Exception("sender has sent a channel type we don't support")
assert channel_type & ChannelType.OPTION_STATIC_REMOTEKEY, "new legacy channel?!"
diff --git a/electrum/lnutil.py b/electrum/lnutil.py
index 962bac3..bb992db 100644
--- a/electrum/lnutil.py
+++ b/electrum/lnutil.py
@@ -1622,21 +1622,6 @@ class ChannelType(IntFlag):
OPTION_SCID_ALIAS = 1 << 46 # variation flag
OPTION_ZEROCONF = 1 << 50 # variation flag
- def discard_unknown_and_check(self) -> 'ChannelType':
- """Discards unknown flags and checks flag combination."""
- flags = list_enabled_bits(self)
- known_channel_types = []
- for flag in flags:
- channel_type = ChannelType(1 << flag)
- if channel_type.name:
- known_channel_types.append(channel_type)
- final_channel_type = known_channel_types[0]
- for channel_type in known_channel_types[1:]:
- final_channel_type |= channel_type
-
- final_channel_type.check_combinations()
- return final_channel_type
-
def check_combinations(self):
"""Raises if invalid flag combination."""
basic_type = self & ~(ChannelType.OPTION_SCID_ALIAS | ChannelType.OPTION_ZEROCONF)
@@ -1644,7 +1629,7 @@ class ChannelType(IntFlag):
ChannelType.OPTION_STATIC_REMOTEKEY,
ChannelType.OPTION_ANCHORS | ChannelType.OPTION_STATIC_REMOTEKEY
]:
- raise ValueError("Channel type is not a valid flag combination.")
+ raise ValueError(f"Channel type is not a valid flag combination: {self}")
def complies_with_features(self, peer_features: LnFeatures) -> bool:
"""Returns whether channel_type complies with peer_features.
@@ -1655,6 +1640,7 @@ class ChannelType(IntFlag):
For example, even if opt_anchors is a negotiated peer_feature, (as per my reading of BOLT-02),
it is still allowed to open an SRK channel (by setting channel_type accordingly).
"""
+ self.check_combinations() # test if raises
cflags = list_enabled_bits(self)
# channel flags must be a SUBSET of peer_features
for cflag in cflags:
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 68127c1..a5be386 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -1690,6 +1690,7 @@ class LNWallet(Logger):
upfront_shutdown_script = b''
assert channel_type is not None
+ channel_type.check_combinations() # test if raises
if channel_type & ChannelType.OPTION_ANCHORS: # anchors
static_payment_key = self.static_payment_key
static_remotekey = None
diff --git a/tests/test_lnutil.py b/tests/test_lnutil.py
index cbb53e6..1bcccd1 100644
--- a/tests/test_lnutil.py
+++ b/tests/test_lnutil.py
@@ -1089,11 +1089,6 @@ class TestLNUtil(ElectrumTestCase):
ctype = ChannelType.OPTION_STATIC_REMOTEKEY | ChannelType.OPTION_ANCHORS
self.assertTrue(ctype.complies_with_features(pfeatures))
- def test_channel_type__ignore_unknown(self):
- # ignore unknown channel types
- channel_type = ChannelType(0b10000000001000000000010).discard_unknown_and_check()
- self.assertEqual(ChannelType(0b10000000001000000000000), channel_type)
-
@as_testnet
async def test_decode_imported_channel_backup_v0(self):
encrypted_cb = "channel_backup:Adn87xcGIs9H2kfp4VpsOaNKWCHX08wBoqq37l1cLYKGlJamTeoaLEwpJA81l1BXF3GP/mRxqkY+whZG9l51G8izIY/kmMSvnh0DOiZEdwaaT/1/MwEHfsEomruFqs+iW24SFJPHbMM7f80bDtIxcLfZkKmgcKBAOlcqtq+dL3U3yH74S8BDDe2L4snaxxpCjF0JjDMBx1UR/28D+QlIi+lbvv1JMaCGXf+AF1+3jLQf8+lVI+rvFdyArws6Ocsvjf+ANQeSGUwW6Nb2xICQcMRgr1DO7bO4pgGu408eYRr2v3ayJBVtnKwSwd49gF5SDSjTDAO4CCM0uj9H5RxyzH7fqotkd9J80MBr84RiBXAeXKz+Ap8608/FVqgQ9BOcn6LhuAQdE5zXpmbQyw5jUGkPvHuseR+rzthzncy01odUceqTNg=="
Why this scored 57/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.