lightningd: refuse channels whose channel_id is already in use
What changed, and why it matters
This update fixes a crash bug in Core Lightning. A malicious or careless peer could open a new Lightning channel using the same funding transaction details as an old, forgotten channel. Because the channel ID is derived from those details, the new channel would share the same ID. When either channel later closed, the program hit an internal 'this should never happen' assertion and crashed, then kept crashing on restart because the duplicate ID was stored in the database. The fix refuses to commit any new channel whose ID is already used by a live or closed channel, and allows the database to hold duplicates for already-affected nodes so they can close and restart safely.
Upgrade to a release containing this commit. Nodes that have already encountered duplicate channel_id crashes should be able to start and close affected channels after applying this patch, thanks to the duplicates-allowed closed_channel_map. Monitor logs for 'channel_id already in use' or 'Funding outpoint already in use' messages indicating rejected duplicate open attempts.
Security signals we found
Remote peer can trigger an assertion abort and persistent crash-on-restart by reusing a funding outpoint
Denial-of-service via protocol-level channel open, no authentication bypass required
Fix adds explicit duplicate channel_id rejection across all channel-opening paths
Database-level change allows graceful recovery for nodes already affected
Changelog explicitly labels this as a fixed security/crash issue
Evidence from the diff
The patch addresses a duplicate channel_id collision. A v1 channel_id is derived from the funding outpoint (txid + index), chosen by the peer and only verifiable after confirmation. Previously, Core Lightning only rejected a funding outpoint already used by a live channel with the same peer, so a second channel could reuse an outpoint from a closed/forgotten channel and share its channel_id. On close, closed_channel_map_add() asserted uniqueness and aborted; the duplicate persisted in the db, causing repeated crashes on restart or onchaind activity. The fix introduces channel_id_in_use(), checks live channels across all peers and the closed_channel map, and rejects duplicates in the fundee, funder, and dual-funding commit paths. It also converts closed_channel_map from a no-duplicates to a duplicates-allowed hash table so existing corrupted databases remain loadable/closable. A previously xfail test is now expected to pass.
Changed components
lightningd/channel.clightningd/channel.hlightningd/closed_channel.hlightningd/dual_open_control.clightningd/opening_control.clightningd/peer_control.ctests/test_opening.pyInspect captured patch +60 / −7
### lightningd/channel.c
@@ -11,6 +11,7 @@
#include <lightningd/channel.h>
#include <lightningd/channel_gossip.h>
#include <lightningd/channel_state_names_gen.h>
+#include <lightningd/closed_channel.h>
#include <lightningd/connect_control.h>
#include <lightningd/hsm_control.h>
#include <lightningd/notification.h>
@@ -877,6 +878,27 @@ struct channel *channel_by_cid(struct lightningd *ld,
return NULL;
}
+bool channel_id_in_use(struct lightningd *ld,
+ const struct channel_id *cid,
+ const struct channel *ignore)
+{
+ struct peer *p;
+ struct channel *channel;
+ struct peer_node_id_map_iter it;
+ struct closed_channel_map_iter cc_it;
+
+ for (p = peer_node_id_map_first(ld->peers, &it);
+ p;
+ p = peer_node_id_map_next(ld->peers, &it)) {
+ list_for_each(&p->channels, channel, list) {
+ if (channel != ignore && channel_id_eq(&channel->cid, cid))
+ return true;
+ }
+ }
+
+ return closed_channel_map_getfirst(ld->closed_channels, cid, &cc_it) != NULL;
+}
+
struct channel *find_channel_by_id(const struct peer *peer,
const struct channel_id *cid)
{
### lightningd/channel.h
@@ -922,6 +922,12 @@ struct channel *any_channel_by_scid(struct lightningd *ld,
struct channel *channel_by_cid(struct lightningd *ld,
const struct channel_id *cid);
+/* Is this channel_id used by a channel with any peer (other than @ignore),
+ * or by a closed channel? */
+bool channel_id_in_use(struct lightningd *ld,
+ const struct channel_id *cid,
+ const struct channel *ignore);
+
/* Find this channel within peer */
struct channel *find_channel_by_id(const struct peer *peer,
const struct channel_id *cid);
### lightningd/closed_channel.h
@@ -46,7 +46,10 @@ static inline bool closed_channel_eq_cid(const struct closed_channel *cc, const
return channel_id_eq(cid, &cc->cid);
}
-HTABLE_DEFINE_NODUPS_TYPE(struct closed_channel, keyof_closed_channel, hash_cid, closed_channel_eq_cid,
- closed_channel_map);
+/* We refuse new channels whose channel_id is already in use, but older nodes
+ * may have committed several channels with the same channel_id to the db:
+ * we must still be able to load (and close) them all. */
+HTABLE_DEFINE_DUPS_TYPE(struct closed_channel, keyof_closed_channel, hash_cid, closed_channel_eq_cid,
+ closed_channel_map);
#endif /* LIGHTNING_LIGHTNINGD_CLOSED_CHANNEL_H */
### lightningd/dual_open_control.c
@@ -3814,6 +3814,16 @@ static void handle_commit_ready(struct subd *dualopend,
/* First time (not an RBF) */
if (channel->state == DUALOPEND_OPEN_INIT) {
+ /* Never commit a second channel with the same channel_id
+ * (this frees the uncommitted channel). */
+ if (channel_id_in_use(ld, &channel->cid, channel)) {
+ channel_internal_error(channel,
+ "channel_id %s already in use",
+ fmt_channel_id(tmpctx,
+ &channel->cid));
+ return;
+ }
+
/* Now we know if it's public or not, we can init channel_gossip */
assert(channel->channel_gossip == NULL);
channel_gossip_init(channel, NULL);
### lightningd/opening_control.c
@@ -424,6 +424,14 @@ static void opening_funder_finished(struct subd *openingd, const u8 *resp,
/* Saved with channel to disk */
derive_channel_id(&cid, &funding);
+ /* Refuse to reuse the funding outpoint of an existing (or closed)
+ * channel: the channel_id would collide. */
+ if (channel_id_in_use(ld, &cid, NULL)) {
+ uncommitted_channel_disconnect(fc->uc, LOG_UNUSUAL,
+ "Funding outpoint already in use");
+ goto cleanup;
+ }
+
/* old_remote_per_commit not valid yet, copy valid one. */
channel_info.old_remote_per_commit = channel_info.remote_per_commit;
@@ -535,9 +543,12 @@ static void opening_fundee_finished(struct subd *openingd,
derive_channel_id(&cid, &funding);
/* A funding outpoint funds at most one channel; don't accept a second
- * channel reusing one we already have. Drop the connection so the
- * peer's open fails cleanly instead of waiting for funding_signed. */
- if (find_channel_by_funding_outpoint(uc->peer, &funding)) {
+ * channel reusing one we already have, with this or any other peer,
+ * open or closed: the channel_id would collide. Drop the connection
+ * so the peer's open fails cleanly instead of waiting for
+ * funding_signed. */
+ if (find_channel_by_funding_outpoint(uc->peer, &funding)
+ || channel_id_in_use(ld, &cid, NULL)) {
force_peer_disconnect(ld, uc->peer,
"Funding outpoint already in use");
return;
### lightningd/peer_control.c
@@ -2114,6 +2114,7 @@ void handle_peer_spoke(struct lightningd *ld, const u8 *msg)
u64 connectd_counter;
struct channel *channel;
struct closed_channel *closed_channel;
+ struct closed_channel_map_iter cc_it;
struct channel_id channel_id;
struct peer *peer;
bool dual_fund;
@@ -2286,7 +2287,8 @@ void handle_peer_spoke(struct lightningd *ld, const u8 *msg)
case WIRE_CHANNEL_REESTABLISH:
/* Maybe a previously closed channel? */
- closed_channel = closed_channel_map_get(peer->ld->closed_channels, &channel_id);
+ closed_channel = closed_channel_map_getfirst(peer->ld->closed_channels,
+ &channel_id, &cc_it);
if (closed_channel && closed_channel->their_shachain) {
send_reestablish(peer, &closed_channel->cid,
closed_channel->their_shachain,
### tests/test_opening.py
@@ -4185,7 +4185,6 @@ def test_v2_lockin_single_spend_watch(node_factory, bitcoind):
assert len([l for l in node.daemon.logs if state_change_re.search(l)]) == 1
-@pytest.mark.xfail(strict=True, reason="duplicate channel_ids in the db crash lightningd on close and on startup")
@pytest.mark.openchannel('v1')
@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "Uses db_manip on sqlite3")
def test_duplicate_channel_id_in_db(node_factory, bitcoind):Why this scored 72/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.