Skip to content

# Milestone 6: Gossip Message Fuzzing (BOLT 7) #71

@devvaansh

Description

@devvaansh

Implements structure-aware fuzzing of BOLT 7 gossip messages — channel_announcement,
node_announcement, channel_update, and announcement_signatures — with valid
secp256k1 signatures, so the fuzzer can penetrate gossip validation logic that
raw-bytes fuzzing cannot reach.

Scope: implements Milestone 6 from the IR Design and Implementation Plan (#5).
Non-goals: routing-table semantics, onion routing, BOLT 11 invoices.

1. Motivation

Every Lightning node accepts gossip from untrusted peers. Bugs in gossip parsing, signature verification, or storage have led to memory exhaustion, CPU DoS, and corrupted routing tables across implementations. The current
EncryptedBytesScenario only stresses early parsing, because raw bytes never satisfy:

  • four chained secp256k1 signatures over a double-SHA256 of the message tail
    (channel_announcement),
  • the lexicographic node_id_1 < node_id_2 ordering rule,
  • chain_hash and short_channel_id consistency between
    channel_announcementchannel_updateannouncement_signatures,
  • the must_be_one flag and htlc_minimum_msat ≤ htlc_maximum_msat ≤ capacity
    invariants in channel_update.

On Extending the IR with gossip operations lets the fuzzer construct structurally valid messages whose mutations exercise the deep code paths behind these checks.

2. Current State (baseline)

Area Status
BOLT 7 codecs only gossip_timestamp_filter exists
IR Operations BuildOpenChannel + load/compute primitives only
IR VariableTypes no Signature, ShortChannelId, or Timestamp types
Generators / executor not yet upstream (Milestones 1–5)
msg_type constants gossip types 256–259 missing from bolt.rs

This milestone assumes Milestone 1 (executor + ProgramBuilder + at least one Generator) has landed. It does not depend on Milestones 2–5.

3. High-Level Architecture

Image

4. Deliverables

4.1 BOLT 7 Codecs — smite/smite/src/bolt/

New modules, each exporting an encode / decode pair plus a struct that mirrors the wire layout. All re-exported from bolt.rs
and registered in msg_type:

File Type msg_type
channel_announcement.rs ChannelAnnouncement 256
node_announcement.rs NodeAnnouncement (+ Address enum: ipv4/ipv6/tor‑v3/dns) 257
channel_update.rs ChannelUpdate (+ MessageFlags, ChannelFlags bitfields) 258
announcement_signatures.rs AnnouncementSignatures 259
short_channel_id.rs ShortChannelId (3+3+2 byte packed u64)

Implementation notes:

  • Reuse the existing WireFormat trait in wire.rs
    (it already supports Signature, PublicKey, and [u8; N]).
  • For ChannelAnnouncement, expose a helper
    signing_hash(&self) -> [u8; 32] that returns the double‑SHA256 of the
    message starting at byte offset 256 (i.e. features onwards). The hash skips
    the four signature slots but covers any trailing bytes, per BOLT 7.
  • For NodeAnnouncement and ChannelUpdate, expose
    signing_hash(&self) -> [u8; 32] covering everything after the leading
    signature field.
  • Round-trip property tests + at least one BOLT‑7 spec vector per message.

4.2 IR Extensions — smite/smite-ir/src/

New VariableTypes (in variable.rs):
Signature, ShortChannelId, Timestamp, plus three compounds for parsed gossip if/when we add Recv* ops (ChannelAnnouncementData, NodeAnnouncementData, ChannelUpdateData) gated behind Extract*
operations following the existing AcceptChannel pattern.

New Operations (in operation.rs):

Load:
  LoadTimestamp(u32)
  LoadShortChannelId(u64)         # packed: (block << 40) | (tx << 16) | out
  LoadRgbColor([u8;3])
  LoadAlias([u8;32])
  LoadAddresses(Vec<u8>)          # already-encoded address descriptor list

Compute (reusable beyond gossip):
  BuildShortChannelId             # inputs: BlockHeight, U16 (tx_index), U16 (out)
                                  # ergonomic alternative to LoadShortChannelId
  DoubleSha256                    # input: Bytes -> Bytes(32)
  Sign                            # inputs: PrivateKey, Bytes(32) -> Signature
                                  # secp256k1 ECDSA, deterministic (RFC 6979)

Build (unsigned + signed pairs, see §4.3):
  BuildChannelAnnouncementUnsigned / BuildChannelAnnouncement
  BuildNodeAnnouncementUnsigned    / BuildNodeAnnouncement
  BuildChannelUpdateUnsigned       / BuildChannelUpdate
  BuildAnnouncementSignatures      # unsigned form not needed (sigs are inputs)

The Sign and DoubleSha256 ops are deliberately generic primitives, they are also prerequisites for future commitment-signature work in Milestones 2–3, so this milestone pays down shared infrastructure debt.

Lexicographic node ordering. BOLT 7 requires node_id_1 < node_id_2. The generator computes both pubkeys at IR-emit time and orders the four LoadPrivateKey instructions accordingly. Subsequent OperationParamMutator
or InputSwapMutator mutations may break the order — that is desirable, since it exercises the receiver's node_id_1 ≥ node_id_2 warning path. No runtime sort op is needed.

4.3 Build Operation Wiring (example — channel_announcement)

Image

Why split unsigned + signed? All four BOLT 7 signed messages sign
the message bytes themselves, with the signature(s) prepended. Encoding the
unsigned form first lets us:

  • feed it directly into a generic DoubleSha256(Bytes) -> Bytes op (no
    message-type-specific hashing logic in the executor);
  • emit each Sign as an independent SSA instruction that any mutator can
    delete, swap, or replace;
  • let OperationParamMutator mutate the unsigned body without invalidating
    signatures the fuzzer wants to keep — and conversely, mutate signatures
    without re-encoding the body. Both halves of that split reach distinct
    validation paths.

Hash regions. The signing region differs per message; the executor stays generic by exposing only DoubleSha256(Bytes), while each codec exposes a signing_region(&self) -> &[u8] helper that the unsigned-Build op uses internally:

Message Bytes hashed
channel_announcement bytes [256..] (skips 4× 64‑byte sig slots)
node_announcement bytes [64..] (skips 1× 64‑byte sig slot)
channel_update bytes [64..] (skips 1× 64‑byte sig slot)
announcement_signatures not signed; signatures come from the chan_ann hash

Concretely each Build*Unsigned op produces a Message variable whose bytes are already in the signing region (i.e. the leading sig slots are zero-filled and stripped before being passed to DoubleSha256). The matching
Build* op accepts the unsigned Message plus the freshly-produced Signature(s) and patches them into the leading slots before SendMessage.

4.4 Generators — smite/smite-ir/src/generators/gossip.rs

Three message generators plus one composing flow generator:

Generator Type Emits
ChannelAnnouncementMsg message 4 keypairs, sort, build unsigned, hash, 4× sign, build signed, SendMessage
NodeAnnouncementMsg message 1 keypair, timestamp, alias/color/addresses, build unsigned, hash, sign, send
ChannelUpdateMsg message scid + chain_hash + flags + fees, build unsigned, hash, sign, send
GossipFlow flow ChannelAnnouncementMsg → 2× ChannelUpdateMsg (one per direction) → 2× NodeAnnouncementMsg. All share the same scid, node_keys, and chain_hash via ProgramBuilder::pick_variable.

Each generator follows the existing pattern: ask ProgramBuilder for typed variables (75 % reuse / 15 % cross-pollinate / 10 % fresh) and emit instructions via builder.append. No generator manipulates indices directly.

AnnouncementSignaturesMsg is gated on the PostChannelOpenSetup snapshot (reuses local_keys / funding_outpoint from ProgramContext) and is
optional for this milestone added only if Milestone 2 has landed.

4.5 Snapshot Setup

Reuse the existing PostInitSetup from Milestone 1. The init message we send
during setup must:

  • advertise gossip_queries (so the target accepts our query stretches if the
    stretch goal is implemented), and
  • immediately follow init with
    GossipTimestampFilter::no_gossip(chain_hash)
    to silence inbound gossip and keep the executor's receive loop predictable.

A new binary entry point, <target>_ir_gossip, is added under
smite-scenarios/src/bin/
wiring IrScenario<T, PostInitSetup> for each of LND / LDK / CLN / Eclair.

Per-target gossip entry symbols the coverage comparison should target
(to be confirmed during implementation):

Target Suspected entry symbols
LND discovery.(*AuthenticatedGossiper).processNetworkAnnouncement, routing.ValidateChannelAnn
LDK lightning::routing::gossip::NetworkGraph::update_channel_from_announcement, update_node_from_announcement
CLN gossipd/queries.c:handle_channel_announcement, routing.c:routing_add_channel_announcement
Eclair fr.acinq.eclair.router.Validation.handleChannelAnnouncement, handleChannelUpdate

4.6 Mutator Interaction

No new mutators required. Existing planned mutators already cover gossip:

Mutator Effect on gossip programs
OperationParamMutator flips bits in LoadFeatures, LoadAlias, LoadTimestamp, LoadShortChannelId
InputSwapMutator swaps node_1node_2 keys (breaks ordering → exercises the node_id_1 ≥ node_id_2 warning path)
InstructionDeleteMutator drops the Sign step → exercises bad-signature path
GeneratorInsertionMutator inserts a duplicate BuildChannelUpdate with same timestamp but different fees → exercises the blacklist path

5. Sample Generated Program

v0  = LoadPrivateKey(0x01..)            # node_1 secret
v1  = DerivePoint(v0)
v2  = LoadPrivateKey(0x02..)            # node_2 secret
v3  = DerivePoint(v2)
v4  = LoadPrivateKey(0x03..)            # bitcoin_key_1 secret
v5  = DerivePoint(v4)
v6  = LoadPrivateKey(0x04..)            # bitcoin_key_2 secret
v7  = DerivePoint(v6)
v8  = LoadShortChannelId(0x0837A4_00034D_0001)
v9  = LoadChainHashFromContext()
v10 = LoadFeatures(0x)
v11 = BuildChannelAnnouncementUnsigned(v9, v8, v1, v3, v5, v7, v10)
v12 = DoubleSha256(v11)                 # hashes bytes [256..]
v13 = Sign(v0, v12)
v14 = Sign(v2, v12)
v15 = Sign(v4, v12)
v16 = Sign(v6, v12)
v17 = BuildChannelAnnouncement(v11, v13, v14, v15, v16)
SendMessage(v17)

v18 = LoadTimestamp(1_715_000_000)
v19 = LoadU8(0x01)                      # message_flags: must_be_one
v20 = LoadU8(0x00)                      # channel_flags: direction=node_1, enabled
v21 = LoadU16(144)                      # cltv_expiry_delta
v22 = LoadAmount(1_000)                 # htlc_minimum_msat
v23 = LoadAmount(1_000)                 # fee_base_msat
v24 = LoadAmount(100)                   # fee_proportional_millionths
v25 = LoadAmount(99_000_000)            # htlc_maximum_msat
v26 = BuildChannelUpdateUnsigned(v9, v8, v18, v19, v20, v21, v22, v23, v24, v25)
v27 = DoubleSha256(v26)
v28 = Sign(v0, v27)
v29 = BuildChannelUpdate(v26, v28)
SendMessage(v29)

6. Validation & Coverage Analysis

Unit tests

  • Round-trip codec tests for all four messages (encode → decode → encode).
  • BOLT 7 spec vectors where available (mainnet chain_hash, ordering rule).
  • Generator tests asserting node_id_1 < node_id_2 and matching scid /
    chain_hash between flow-generated channel_announcement and its two
    channel_updates.

End-to-end fuzzing

  • 24 h fuzzing runs on all four supported targets using the existing
    coverage-report.sh workflow.
  • Acceptance criterion: ir_gossip corpus must hit functions named
    *verify_channel_announcement*, *process_channel_update*,
    *store_node_announcement* (or per-target equivalents) that the
    encrypted_bytes corpus does not reach. Comparison published as a small
    table in the PR description.

7. Stretch Goals

  1. Gossip queries — add codecs + Build* ops for query_channel_range,
    reply_channel_range (incl. timestamps_tlv / checksums_tlv with
    CRC32C), query_short_channel_ids, and reply_short_channel_ids_end.
    These are unsigned, so they reuse Sign infrastructure trivially.
  2. Plausible funding outpoints — add a MineFundingTx action generator
    that, before emitting a channel_announcement, drives bitcoind (already
    available to the executor) to mine a transaction with a P2WSH output spending
    to 2-of-2 multisig(bitcoin_key_1, bitcoin_key_2). The resulting
    (block, tx_index, vout) is fed into BuildShortChannelId, allowing the
    message to pass on-chain validation in targets that perform UTXO lookup
    (e.g. CLN with --funding-confirms-required).
  3. Regtest splice announcements — once Milestone 5 lands, exercise the
    splice-driven channel_announcement regeneration path.

8. Risks & Open Questions

  • secp256k1 signing on the VM hot path. Each Sign op costs ~80 µs on
    modern x86. A GossipFlow runs ≤ 8 signatures (4 for chan_ann +
    2× chan_update + 2× node_ann) ≈ 0.6 ms — well below the dominant Nyx
    snapshot-restore cost. The unsigned/signed split (§4.3) means mutators
    don't need to re-sign on every byte flip, so the steady-state cost
    stays low.
  • Compound Recv* types deferred. Gossip is largely a one-way ingress
    surface; the target rarely replies synchronously. The executor's existing
    auto-pong loop already drains anything inbound. If/when a gossip oracle is
    added (e.g. "target must not re-broadcast a chan_ann with bad sigs"), we
    can add RecvChannelAnnouncement + Extract* following the
    AcceptChannel pattern.
  • short_channel_id realism. Without the §8 funding-outpoint stretch
    goal, generated SCIDs reference non-existent UTXOs. CLN and LND will reject
    channel_announcement early on UTXO lookup; LDK and Eclair are less strict
    in default config. To guarantee all four targets reach signature
    verification, the ir_gossip workload Dockerfiles disable on-chain UTXO
    validation where possible (e.g. CLN --dev-fast-gossip --dev-allow-localhost,
    LND regtest with funding-validation off). Each per-target tweak is documented
    in the corresponding workloads/<target>/Dockerfile change.
  • Spec ambiguity around must_be_one. Receivers are now required to
    ignore it, but several implementations historically rejected
    message_flags & 0x01 == 0. Exposed as a plain LoadU8 so the fuzzer can
    flip it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions