connectd: pad messages with dummy pings if needed to make size uniform.
What changed, and why it matters
This change makes every encrypted network packet sent between Core Lightning peers the same fixed size (1460 bytes). When a real message is smaller than that, the software now stuffs the leftover space with a harmless dummy 'ping' message before sending. This is a privacy improvement: an outside observer watching the encrypted traffic can no longer guess what kind of message is being sent just by looking at packet lengths. It is not a fix for an active exploit, and it does not change what attackers can do.
No immediate action required. This is a defensive privacy improvement. Operators should upgrade through normal release channels if they want the traffic-shape protection. Reviewers may want to confirm that the padding ping cannot be abused as a DoS vector and that the queue accounting cannot underflow or leak memory under backpressure.
Security signals we found
Traffic-shape privacy hardening: uniform packet sizes hide message-length metadata.
Padding uses ignored ping messages (num_pong_bytes=65535) so peers do not respond with pongs.
No cryptographic or protocol parsing changes; only output buffering and batching logic changed.
Test re-enabled to verify constant packet size behavior.
Evidence from the diff
The commit reworks connectd’s output path so that all writes to a peer are batched and padded to a uniform UNIFORM_MESSAGE_SIZE (1460 bytes). Previously, MAX_MESSAGE_SIZE only capped individual writes. Now, encrypt_append() appends encrypted messages to a per-peer queue, and write_to_peer() loops until the queued encrypted bytes are at least UNIFORM_MESSAGE_SIZE; if no real messages remain, pad_encrypted_queue() injects an ignored ping (num_pong_bytes=65535) with enough padding bytes to fill the slot. The test test_constant_packet_size is re-enabled (xfail removed), and a gossip test’s ping tolerance is raised to account for the extra dummy pings.
Changed components
connectd/multiplex.cconnectd/connectd.cconnectd/connectd.htests/test_connection.pytests/test_gossip.pyInspect captured patch +142 / −55
diff --git a/connectd/connectd.c b/connectd/connectd.c
index 8421b905..aec89d24 100644
--- a/connectd/connectd.c
+++ b/connectd/connectd.c
@@ -128,7 +128,7 @@ static struct peer *new_peer(struct daemon *daemon,
peer->cs = *cs;
peer->subds = tal_arr(peer, struct subd *, 0);
peer->peer_in = NULL;
- peer->encrypted_peer_out = NULL;
+ peer->encrypted_peer_out = tal_arr(peer, u8, 0);
peer->encrypted_peer_out_off = 0;
peer->encrypted_peer_out_sent = 0;
peer->urgent = false;
diff --git a/connectd/connectd.h b/connectd/connectd.h
index 451abf7a..1fdc95c4 100644
--- a/connectd/connectd.h
+++ b/connectd/connectd.h
@@ -89,7 +89,7 @@ struct peer {
struct msg_queue *peer_outq;
/* Encrypted peer sending buffer */
- const u8 *encrypted_peer_out;
+ u8 *encrypted_peer_out;
size_t encrypted_peer_out_off;
size_t encrypted_peer_out_sent;
diff --git a/connectd/multiplex.c b/connectd/multiplex.c
index abc31d3b..0ac2f80d 100644
--- a/connectd/multiplex.c
+++ b/connectd/multiplex.c
@@ -25,8 +25,8 @@
#include <wire/peer_wire.h>
#include <wire/wire_io.h>
-/* Maximum write(), to create uniform size packets. */
-#define MAX_MESSAGE_SIZE 1460
+/* Size of write(), to create uniform size packets. */
+#define UNIFORM_MESSAGE_SIZE 1460
struct subd {
/* Owner: we are in peer->subds[] */
@@ -360,9 +360,9 @@ static bool UNNEEDED is_urgent(enum peer_wire type)
/* Process and eat protocol_batch_element messages, encrypt each element message
* and return the encrypted messages as one long byte array. */
-static u8 *process_batch_elements(struct peer *peer, const u8 *msg TAKES)
+static u8 *process_batch_elements(const tal_t *ctx, struct peer *peer, const u8 *msg TAKES)
{
- u8 *ret = tal_arr(peer, u8, 0);
+ u8 *ret = tal_arr(ctx, u8, 0);
size_t ret_size = 0;
const u8 *cursor = msg;
size_t plen = tal_count(msg);
@@ -457,34 +457,112 @@ static struct io_plan *msg_out_dev_disconnect(struct peer *peer, const u8 *msg)
abort();
}
+/* Do we have enough bytes without padding? */
+static bool have_full_encrypted_queue(const struct peer *peer)
+{
+ size_t bytes = tal_bytelen(peer->encrypted_peer_out) - peer->encrypted_peer_out_off;
+ return bytes >= UNIFORM_MESSAGE_SIZE;
+}
+
+/* Do we have nothing in queue? */
+static bool have_empty_encrypted_queue(const struct peer *peer)
+{
+ size_t bytes = tal_bytelen(peer->encrypted_peer_out) - peer->encrypted_peer_out_off;
+ return bytes == 0;
+}
+
/* (Continue) writing the encrypted_peer_out array */
static struct io_plan *write_encrypted_to_peer(struct peer *peer)
{
- size_t max = tal_bytelen(peer->encrypted_peer_out) - peer->encrypted_peer_out_off;
- if (max > MAX_MESSAGE_SIZE)
- max = MAX_MESSAGE_SIZE;
+ assert(have_full_encrypted_queue(peer));
return io_write_partial(peer->to_peer,
peer->encrypted_peer_out + peer->encrypted_peer_out_off,
- max,
+ UNIFORM_MESSAGE_SIZE,
&peer->encrypted_peer_out_sent,
write_to_peer, peer);
}
-static struct io_plan *encrypt_and_send(struct peer *peer, const u8 *msg TAKES)
+/* Close the connection if this fails */
+static bool encrypt_append(struct peer *peer, const u8 *msg TAKES)
{
int type = fromwire_peektype(msg);
+ u8 *enc;
+ size_t prev_size;
/* Special message type directing us to process batch items. */
if (type == WIRE_PROTOCOL_BATCH_ELEMENT) {
- peer->encrypted_peer_out = process_batch_elements(peer, msg);
- if (!peer->encrypted_peer_out)
- return io_close(peer->to_peer);
- }
- else {
- peer->encrypted_peer_out = cryptomsg_encrypt_msg(peer, &peer->cs, msg);
+ enc = process_batch_elements(tmpctx, peer, msg);
+ if (!enc)
+ return false;
+ } else {
+ enc = cryptomsg_encrypt_msg(tmpctx, &peer->cs, msg);
}
- return write_encrypted_to_peer(peer);
+ prev_size = tal_bytelen(peer->encrypted_peer_out);
+ tal_resize(&peer->encrypted_peer_out, prev_size + tal_bytelen(enc));
+ memcpy(peer->encrypted_peer_out + prev_size, enc, tal_bytelen(enc));
+ return true;
+}
+
+static void pad_encrypted_queue(struct peer *peer)
+{
+ size_t needed, pingpad, bytes;
+ u8 *ping;
+
+ /* BOLT #8:
+ *
+ * ```
+ * +-------------------------------
+ * |2-byte encrypted message length|
+ * +-------------------------------
+ * | 16-byte MAC of the encrypted |
+ * | message length |
+ * +-------------------------------
+ * | |
+ * | |
+ * | encrypted Lightning |
+ * | message |
+ * | |
+ * +-------------------------------
+ * | 16-byte MAC of the |
+ * | Lightning message |
+ * +-------------------------------
+ * ```
+ *
+ * The prefixed message length is encoded as a 2-byte big-endian integer,
+ * for a total maximum packet length of `2 + 16 + 65535 + 16` = `65569` bytes.
+ */
+ assert(!have_full_encrypted_queue(peer));
+ bytes = tal_bytelen(peer->encrypted_peer_out) - peer->encrypted_peer_out_off;
+
+ needed = UNIFORM_MESSAGE_SIZE - bytes;
+
+ /* BOLT #1:
+ * 1. type: 18 (`ping`)
+ * 2. data:
+ * * [`u16`:`num_pong_bytes`]
+ * * [`u16`:`byteslen`]
+ * * [`byteslen*byte`:`ignored`]
+ */
+ /* So smallest possible ping is 6 bytes (2 byte type field) */
+ if (needed < 2 + 16 + 16 + 6)
+ needed += UNIFORM_MESSAGE_SIZE;
+
+ pingpad = needed - (2 + 16 + 16 + 6);
+ /* Note: we don't bother --dev-disconnect here */
+ /* BOLT #1:
+ * A node receiving a `ping` message:
+ * - if `num_pong_bytes` is less than 65532:
+ * - MUST respond by sending a `pong` message, with `byteslen` equal to `num_pong_bytes`.
+ * - otherwise (`num_pong_bytes` is **not** less than 65532):
+ * - MUST ignore the `ping`.
+ */
+ ping = make_ping(NULL, 65535, pingpad);
+ if (!encrypt_append(peer, take(ping)))
+ abort();
+
+ assert(have_full_encrypted_queue(peer));
+ assert((tal_bytelen(peer->encrypted_peer_out) - peer->encrypted_peer_out_off) % UNIFORM_MESSAGE_SIZE == 0);
}
/* Kicks off write_to_peer() to look for more gossip to send from store */
@@ -1092,48 +1170,58 @@ static const u8 *next_msg_for_peer(struct peer *peer)
static struct io_plan *write_to_peer(struct io_conn *peer_conn,
struct peer *peer)
{
- const u8 *msg;
- struct io_plan *dev_override;
-
assert(peer->to_peer == peer_conn);
/* Write any remainder. */
peer->encrypted_peer_out_off += peer->encrypted_peer_out_sent;
- if (peer->encrypted_peer_out_off < tal_bytelen(peer->encrypted_peer_out))
- return write_encrypted_to_peer(peer);
-
- /* Free last sent one (if any) */
- peer->encrypted_peer_out = tal_free(peer->encrypted_peer_out);
- peer->encrypted_peer_out_off = 0;
-
- /* Pop tail of send queue (or gossip) */
- msg = next_msg_for_peer(peer);
- if (!msg) {
- /* Draining? Shutdown socket (to avoid losing msgs) */
- if (peer->draining_state == WRITING_TO_PEER) {
- status_peer_debug(&peer->id, "draining done, shutting down");
- io_wake(&peer->peer_in);
- return io_sock_shutdown(peer_conn);
- }
-
- /* Tell them to read again, */
- io_wake(&peer->subds);
- io_wake(&peer->peer_in);
-
- /* Wait for them to wake us */
- return msg_queue_wait(peer_conn, peer->peer_outq, write_to_peer, peer);
+ peer->encrypted_peer_out_sent = 0;
+ /* If all used, clean up */
+ if (peer->encrypted_peer_out_off == tal_bytelen(peer->encrypted_peer_out)) {
+ peer->encrypted_peer_out_off = 0;
+ tal_resize(&peer->encrypted_peer_out, 0);
}
- if (peer->draining_state == WRITING_TO_PEER)
- status_peer_debug(&peer->id, "draining, but sending %s.",
- peer_wire_name(fromwire_peektype(msg)));
-
- dev_override = msg_out_dev_disconnect(peer, msg);
- if (dev_override) {
- tal_free(msg);
- return dev_override;
+ while (!have_full_encrypted_queue(peer)) {
+ const u8 *msg;
+ struct io_plan *dev_override;
+
+ /* Pop tail of send queue (or gossip) */
+ msg = next_msg_for_peer(peer);
+ if (!msg) {
+ /* Nothing to send at all? We're done */
+ if (have_empty_encrypted_queue(peer)) {
+ /* Draining? Shutdown socket (to avoid losing msgs) */
+ if (peer->draining_state == WRITING_TO_PEER) {
+ status_peer_debug(&peer->id, "draining done, shutting down");
+ io_wake(&peer->peer_in);
+ return io_sock_shutdown(peer_conn);
+ }
+
+ /* Tell them to read again, */
+ io_wake(&peer->subds);
+ io_wake(&peer->peer_in);
+
+ /* Wait for them to wake us */
+ return msg_queue_wait(peer_conn, peer->peer_outq, write_to_peer, peer);
+ }
+ /* OK, add padding. */
+ pad_encrypted_queue(peer);
+ } else {
+ if (peer->draining_state == WRITING_TO_PEER)
+ status_peer_debug(&peer->id, "draining, but sending %s.",
+ peer_wire_name(fromwire_peektype(msg)));
+
+ dev_override = msg_out_dev_disconnect(peer, msg);
+ if (dev_override) {
+ tal_free(msg);
+ return dev_override;
+ }
+
+ if (!encrypt_append(peer, take(msg)))
+ return io_close(peer->to_peer);
+ }
}
- return encrypt_and_send(peer, take(msg));
+ return write_encrypted_to_peer(peer);
}
static struct io_plan *read_from_subd(struct io_conn *subd_conn,
diff --git a/tests/test_connection.py b/tests/test_connection.py
index b93179f6..5dd5b768 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -4773,7 +4773,6 @@ def test_networkevents(node_factory, executor):
'type': 'connect'}]}
-@pytest.mark.xfail(strict=True)
def test_constant_packet_size(node_factory, tcp_capture):
"""
Test that TCP packets between nodes are constant size. This will be skipped unless
diff --git a/tests/test_gossip.py b/tests/test_gossip.py
index be548465..b4c373da 100644
--- a/tests/test_gossip.py
+++ b/tests/test_gossip.py
@@ -2393,7 +2393,7 @@ def test_gossip_force_broadcast_channel_msgs(node_factory, bitcoind):
# Make sure the noise is within reasonable bounds
assert tally['query_short_channel_ids'] <= 1
assert tally['query_channel_range'] <= 1
- assert tally['ping'] <= 1
+ assert tally['ping'] <= 3
assert tally['gossip_filter'] >= 1
del tally['query_short_channel_ids']
del tally['query_channel_range']
Why this scored 35/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.