lnwire: add bounded introNode BlindedPath codec
What changed, and why it matters
This commit adds a new, self-contained code module for encoding and decoding 'blinded paths' used in the Lightning Network protocol. It does not change any existing code paths or callers. The new code is designed defensively: it rejects malformed input, limits memory allocations, and validates cryptographic points before use. There is no indication this commit fixes a known vulnerability or introduces one.
No immediate action required. Treat as routine defensive code addition. When subsequent commits migrate OnionMessagePayload and bolt12 structs to this codec, review those changes for correct integration and any relaxation of these bounds.
Security signals we found
Defensive input validation added for BOLT 4 blinded path encoding/decoding
io.LimitedReader used to bound variable-length decoder allocations
Fail-closed encoder rejects invalid data before bytes hit the wire
On-curve checks for public keys during encode and decode
Allocation guards prevent oversize num_hops or enclen from causing large memory allocation
Evidence from the diff
The commit introduces lnwire.BlindedPath/BlindedPaths TLV codecs with a sealed IntroductionNode sum-type (PubkeyIntro and SciddirIntro). Encoding validates non-nil introduction nodes, on-curve blinding points, non-empty hop lists, hop-count limits, and encrypted-data length ceilings before writing. Decoding uses io.LimitedReader to bound every variable-length subfield, rejects invalid introduction-node discriminators, and prevents allocation bombs by checking remaining bytes before allocating hop or encrypted-data slices. The commit is a pure addition with no existing callers migrated yet; tests cover round-trip behavior and rejection of malformed inputs.
Changed components
lnwire/blinded_path.golnwire/blinded_path_test.golnwire/bounds.golnwire/intro_node.goInspect captured patch +963 / −0
diff --git a/lnwire/blinded_path.go b/lnwire/blinded_path.go
new file mode 100644
index 0000000..14e247a
--- /dev/null
+++ b/lnwire/blinded_path.go
@@ -0,0 +1,333 @@
+package lnwire
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/lightningnetwork/lnd/tlv"
+)
+
+var (
+ // ErrInvalidIntroNode is returned when a blinded path's introduction
+ // node discriminator is not one of the spec-defined values.
+ ErrInvalidIntroNode = errors.New("invalid blinded-path introduction " +
+ "node discriminator")
+
+ // ErrEmptyBlindedPath is returned when a blinded path has zero hops.
+ ErrEmptyBlindedPath = errors.New("blinded path with zero hops")
+)
+
+// BlindedPath holds the introduction node, blinding point, and encrypted hops
+// of a single blinded path.
+type BlindedPath struct {
+ // IntroductionNode is the variant-defined introduction node for this
+ // blinded path.
+ IntroductionNode IntroductionNode
+
+ // BlindingPoint is the blinding point for this path, used to derive the
+ // blinded node IDs and encrypt the hop payloads.
+ BlindingPoint *btcec.PublicKey
+
+ // Hops is the ordered list of blinded hops in this path.
+ Hops []BlindedHop
+}
+
+// BlindedPaths holds one or more blinded paths.
+type BlindedPaths struct {
+ Paths []BlindedPath
+}
+
+// BlindedHop represents a single hop in a blinded path.
+type BlindedHop struct {
+ // BlindedNodeID is the blinded public key for this hop.
+ BlindedNodeID *btcec.PublicKey
+
+ // EncryptedData is the encrypted payload for this hop.
+ EncryptedData []byte
+}
+
+var (
+ _ tlv.RecordProducer = (*BlindedPath)(nil)
+ _ tlv.RecordProducer = (*BlindedPaths)(nil)
+)
+
+// Record returns a TLV record for a single BlindedPath at the BOLT 4 reply_path
+// TLV type. Used directly by OnionMessagePayload's reply_path encoding.
+func (p *BlindedPath) Record() tlv.Record {
+ return tlv.MakeDynamicRecord(
+ replyPathType, p,
+ func() uint64 {
+ return blindedPathSize(p)
+ },
+ encodeBlindedPath,
+ decodeBlindedPath,
+ )
+}
+
+// blindedPathSize returns the on-wire size of a single BlindedPath.
+func blindedPathSize(p *BlindedPath) uint64 {
+ var introLen uint64
+ if p.IntroductionNode != nil {
+ introLen = p.IntroductionNode.encodedLen()
+ }
+
+ // introduction_node (variant-defined) + blinding_point (33) +
+ // num_hops (1).
+ size := introLen + pubKeyLen + 1
+ for _, h := range p.Hops {
+ // blinded_node_id (33) + enclen (2) + enc_data.
+ size += pubKeyLen + 2 + uint64(len(h.EncryptedData))
+ }
+
+ return size
+}
+
+// encodeBlindedPath writes a single blinded path. No bytes are written if the
+// path fails validation.
+func encodeBlindedPath(w io.Writer, val any, buf *[8]byte) error {
+ p, ok := val.(*BlindedPath)
+ if !ok {
+ return fmt.Errorf("expected *BlindedPath, got %T", val)
+ }
+
+ return writeBlindedPath(w, p, buf)
+}
+
+// writeBlindedPath validates the path and writes a single blinded path to w.
+func writeBlindedPath(w io.Writer, p *BlindedPath, buf *[8]byte) error {
+ if p.IntroductionNode == nil {
+ return fmt.Errorf("nil intro node")
+ }
+
+ if err := p.IntroductionNode.validate(); err != nil {
+ return err
+ }
+
+ if p.BlindingPoint == nil {
+ return fmt.Errorf("nil blinding point")
+ }
+
+ if !p.BlindingPoint.IsOnCurve() {
+ return fmt.Errorf("blinding point not on curve")
+ }
+
+ if len(p.Hops) == 0 {
+ return ErrEmptyBlindedPath
+ }
+ if len(p.Hops) > maxBlindedPathHops {
+ return fmt.Errorf("%d hops exceeds limit %d", len(p.Hops),
+ maxBlindedPathHops)
+ }
+
+ if err := p.IntroductionNode.encode(w); err != nil {
+ return err
+ }
+ blindingBytes := p.BlindingPoint.SerializeCompressed()
+ if _, err := w.Write(blindingBytes); err != nil {
+ return err
+ }
+
+ buf[0] = uint8(len(p.Hops))
+ if _, err := w.Write(buf[:1]); err != nil {
+ return err
+ }
+
+ for hIdx := range p.Hops {
+ if err := writeBlindedHop(w, &p.Hops[hIdx], buf); err != nil {
+ return fmt.Errorf("hop %d: %w", hIdx, err)
+ }
+ }
+
+ return nil
+}
+
+// decodeBlindedPath reads a single blinded path framed at the TLV-value level.
+func decodeBlindedPath(r io.Reader, val any, buf *[8]byte, l uint64) error {
+ p, ok := val.(*BlindedPath)
+ if !ok {
+ return fmt.Errorf("expected *BlindedPath, got %T", val)
+ }
+
+ lr := &io.LimitedReader{R: r, N: int64(l)}
+
+ if err := readBlindedPath(lr, p, buf); err != nil {
+ return err
+ }
+
+ if lr.N != 0 {
+ return fmt.Errorf("trailing %d bytes after blinded path", lr.N)
+ }
+
+ return nil
+}
+
+// readBlindedPath decodes a single blinded path from lr.
+func readBlindedPath(lr *io.LimitedReader, p *BlindedPath,
+ buf *[8]byte) error {
+
+ intro, err := decodeIntroductionNode(lr, buf)
+ if err != nil {
+ return err
+ }
+ p.IntroductionNode = intro
+
+ var blindingBytes [pubKeyLen]byte
+ if _, err := io.ReadFull(lr, blindingBytes[:]); err != nil {
+ return fmt.Errorf("read blinding point: %w", err)
+ }
+ blinding, err := btcec.ParsePubKey(blindingBytes[:])
+ if err != nil {
+ return fmt.Errorf("blinding point: %w", err)
+ }
+ p.BlindingPoint = blinding
+
+ if _, err := io.ReadFull(lr, buf[:1]); err != nil {
+ return fmt.Errorf("read num_hops: %w", err)
+ }
+ numHops := int(buf[0])
+ if numHops == 0 {
+ return ErrEmptyBlindedPath
+ }
+
+ if int64(numHops)*minBlindedHopBytes > lr.N {
+ return fmt.Errorf("num_hops %d exceeds remaining %d bytes",
+ numHops, lr.N)
+ }
+
+ p.Hops = make([]BlindedHop, numHops)
+ for i := range p.Hops {
+ if err := readBlindedHop(lr, &p.Hops[i], buf); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// Record returns a TLV record for BlindedPaths.
+func (bp *BlindedPaths) Record() tlv.Record {
+ return tlv.MakeDynamicRecord(
+ 0, bp,
+ func() uint64 {
+ return blindedPathsSize(bp)
+ },
+ encodeBlindedPaths,
+ decodeBlindedPaths,
+ )
+}
+
+// blindedPathsSize returns the on-wire size of multiple BlindedPaths.
+func blindedPathsSize(bp *BlindedPaths) uint64 {
+ var size uint64
+ for i := range bp.Paths {
+ size += blindedPathSize(&bp.Paths[i])
+ }
+
+ return size
+}
+
+// encodeBlindedPaths writes the multi-path TLV value as concatenated paths.
+// Fails closed under the same conditions as encodeBlindedPath.
+func encodeBlindedPaths(w io.Writer, val any, buf *[8]byte) error {
+ bp, ok := val.(*BlindedPaths)
+ if !ok {
+ return fmt.Errorf("expected *BlindedPaths, got %T", val)
+ }
+
+ for pIdx := range bp.Paths {
+ err := writeBlindedPath(w, &bp.Paths[pIdx], buf)
+ if err != nil {
+ return fmt.Errorf("blinded path %d: %w", pIdx, err)
+ }
+ }
+
+ return nil
+}
+
+// decodeBlindedPaths reads concatenated blinded paths. The LimitedReader gates
+// each variable-length subfield against the bytes still on the wire, so an
+// oversize hop count cannot force a large allocation before io.ReadFull
+// notices the bytes are absent.
+func decodeBlindedPaths(r io.Reader, val any, buf *[8]byte, l uint64) error {
+ bp, ok := val.(*BlindedPaths)
+ if !ok {
+ return fmt.Errorf("expected *BlindedPaths, got %T", val)
+ }
+
+ lr := &io.LimitedReader{R: r, N: int64(l)}
+
+ for lr.N > 0 {
+ var p BlindedPath
+ if err := readBlindedPath(lr, &p, buf); err != nil {
+ return err
+ }
+ bp.Paths = append(bp.Paths, p)
+ }
+
+ return nil
+}
+
+// writeBlindedHop emits BlindedNodeID + enclen + encrypted data. The size cap
+// is checked first so no bytes hit the writer on rejection.
+func writeBlindedHop(w io.Writer, h *BlindedHop, buf *[8]byte) error {
+ if h.BlindedNodeID == nil {
+ return fmt.Errorf("nil blinded node id")
+ }
+
+ if !h.BlindedNodeID.IsOnCurve() {
+ return fmt.Errorf("blinded node id not on curve")
+ }
+
+ if len(h.EncryptedData) > maxEncryptedDataLen {
+ return fmt.Errorf("encrypted data %d exceeds limit %d",
+ len(h.EncryptedData), maxEncryptedDataLen)
+ }
+
+ nodeIDBytes := h.BlindedNodeID.SerializeCompressed()
+ if _, err := w.Write(nodeIDBytes); err != nil {
+ return err
+ }
+
+ binary.BigEndian.PutUint16(buf[:2], uint16(len(h.EncryptedData)))
+ if _, err := w.Write(buf[:2]); err != nil {
+ return err
+ }
+ if _, err := w.Write(h.EncryptedData); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// readBlindedHop decodes a single blinded hop. The enclen guard against lr.N
+// bounds the EncryptedData allocation.
+func readBlindedHop(lr *io.LimitedReader, h *BlindedHop, buf *[8]byte) error {
+ var nodeBytes [pubKeyLen]byte
+ if _, err := io.ReadFull(lr, nodeBytes[:]); err != nil {
+ return fmt.Errorf("read blinded node: %w", err)
+ }
+ node, err := btcec.ParsePubKey(nodeBytes[:])
+ if err != nil {
+ return fmt.Errorf("blinded node id: %w", err)
+ }
+ h.BlindedNodeID = node
+
+ if _, err := io.ReadFull(lr, buf[:2]); err != nil {
+ return fmt.Errorf("read enclen: %w", err)
+ }
+ encLen := binary.BigEndian.Uint16(buf[:2])
+ if int64(encLen) > lr.N {
+ return fmt.Errorf("enclen %d exceeds remaining %d", encLen,
+ lr.N)
+ }
+
+ h.EncryptedData = make([]byte, encLen)
+ if _, err := io.ReadFull(lr, h.EncryptedData); err != nil {
+ return fmt.Errorf("read encrypted data: %w", err)
+ }
+
+ return nil
+}
diff --git a/lnwire/blinded_path_test.go b/lnwire/blinded_path_test.go
new file mode 100644
index 0000000..207e08a
--- /dev/null
+++ b/lnwire/blinded_path_test.go
@@ -0,0 +1,414 @@
+package lnwire
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/stretchr/testify/require"
+)
+
+// validPubkeyIntro returns an on-curve PubkeyIntro plus the matching
+// *btcec.PublicKey for assertions.
+func validPubkeyIntro(t *testing.T) (PubkeyIntro, *btcec.PublicKey) {
+ t.Helper()
+
+ priv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+ pub := priv.PubKey()
+
+ return PubkeyIntro{Pubkey: pub}, pub
+}
+
+// validBlindingPoint returns an on-curve pubkey suitable for use as a
+// BlindingPoint or BlindedNodeID in tests.
+func validBlindingPoint(t *testing.T) *btcec.PublicKey {
+ t.Helper()
+
+ priv, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ return priv.PubKey()
+}
+
+// oversizeEncDataPaths returns a BlindedPaths with a single hop whose
+// EncryptedData is one byte over the wire-format limit, used by the
+// encode-rejects test.
+func oversizeEncDataPaths(t *testing.T, intro IntroductionNode) *BlindedPaths {
+ t.Helper()
+
+ return &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: intro,
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{{
+ BlindedNodeID: validBlindingPoint(t),
+ EncryptedData: make(
+ []byte, maxEncryptedDataLen+1,
+ ),
+ }},
+ }},
+ }
+}
+
+// TestBlindedPathRoundTrip pins encode→decode parity across both
+// IntroductionNode variants and across single- and multi-path framings, so
+// concrete variant types survive the round-trip with byte-identical output.
+func TestBlindedPathRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ pubkeyIntro, _ := validPubkeyIntro(t)
+ sciddirIntro := SciddirIntro{
+ Direction: 0x01,
+ SCID: [8]byte{
+ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
+ },
+ }
+
+ hop := func(payload byte) BlindedHop {
+ return BlindedHop{
+ BlindedNodeID: validBlindingPoint(t),
+ EncryptedData: []byte{payload, payload ^ 0xff},
+ }
+ }
+
+ pubkeyPath := BlindedPath{
+ IntroductionNode: pubkeyIntro,
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{
+ hop(0xde),
+ hop(0xad),
+ },
+ }
+ sciddirPath := BlindedPath{
+ IntroductionNode: sciddirIntro,
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{hop(0xbe)},
+ }
+
+ tests := []struct {
+ name string
+ paths []BlindedPath
+ }{
+ {
+ name: "single pubkey path",
+ paths: []BlindedPath{pubkeyPath},
+ },
+ {
+ name: "single sciddir path",
+ paths: []BlindedPath{sciddirPath},
+ },
+ {
+ name: "mixed multi-path",
+ paths: []BlindedPath{pubkeyPath, sciddirPath},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ bp := &BlindedPaths{Paths: tc.paths}
+
+ var buf bytes.Buffer
+ require.NoError(t, encodeBlindedPaths(
+ &buf, bp, new([8]byte),
+ ))
+
+ var decoded BlindedPaths
+ err := decodeBlindedPaths(
+ bytes.NewReader(buf.Bytes()), &decoded,
+ new([8]byte), uint64(buf.Len()),
+ )
+ require.NoError(t, err)
+ require.Equal(t, bp.Paths, decoded.Paths)
+
+ // Single-path framing must round-trip too: the
+ // reply_path TLV carries one BlindedPath, not a list.
+ if len(tc.paths) == 1 {
+ var single bytes.Buffer
+ require.NoError(t, encodeBlindedPath(
+ &single, &tc.paths[0], new([8]byte),
+ ))
+
+ var decodedSingle BlindedPath
+ err := decodeBlindedPath(
+ bytes.NewReader(single.Bytes()),
+ &decodedSingle, new([8]byte),
+ uint64(single.Len()),
+ )
+ require.NoError(t, err)
+ require.Equal(
+ t, tc.paths[0], decodedSingle,
+ )
+ }
+ })
+ }
+}
+
+// TestDecodeBlindedPathsRejects covers every malformed-input branch the
+// decoder must refuse: bad discriminators, allocation bombs, and short reads.
+// The catch-all is that the decoder never allocates more memory than the
+// remaining wire bytes can justify.
+func TestDecodeBlindedPathsRejects(t *testing.T) {
+ t.Parallel()
+
+ // validKey is a 33-byte compressed SEC1 pubkey that the on-curve
+ // decoder accepts; reused as both intro pubkey and blinding point so
+ // the tests can exercise post-pubkey decode branches.
+ validKey := validBlindingPoint(t).SerializeCompressed()
+
+ // hopAllocOverflow declares num_hops=255 with no hop payload. Without
+ // the remaining-bytes guard the decoder would make([]BlindedHop, 255)
+ // before io.ReadFull notices the bytes are absent.
+ hopAllocOverflow := func() []byte {
+ out := make([]byte, 0, 67)
+ out = append(out, validKey...)
+ out = append(out, validKey...)
+ out = append(out, 0xff)
+
+ return out
+ }
+
+ // enclenOverflow declares enclen=65535 on a hop with no payload. The
+ // guard against lr.N must reject before make([]byte, 65535).
+ enclenOverflow := func() []byte {
+ out := make([]byte, 0, 70)
+ out = append(out, validKey...)
+ out = append(out, validKey...)
+ out = append(out, 0x01)
+ out = append(out, validKey...)
+ out = append(out, 0xff, 0xff)
+
+ return out
+ }
+
+ // shortIntroPubkey truncates after the discriminator + 5 of 33 bytes
+ // of intro pubkey, exercising io.ReadFull's short-read error.
+ shortIntroPubkey := func() []byte {
+ return append([]byte{0x02}, bytes.Repeat([]byte{0x00}, 5)...)
+ }
+
+ // shortBlindingPoint truncates after a full intro pubkey plus 5 of the
+ // 33 blinding-point bytes, exercising io.ReadFull's short-read path
+ // past the discriminator.
+ shortBlindingPoint := func() []byte {
+ out := make([]byte, 0, pubKeyLen+5)
+ out = append(out, validKey...)
+ out = append(out, bytes.Repeat([]byte{0x00}, 5)...)
+
+ return out
+ }
+
+ tests := []struct {
+ name string
+ data []byte
+ wantErr error
+ wantMsg []string
+ }{
+ {
+ name: "invalid discriminator 0x04",
+ data: []byte{0x04},
+ wantErr: ErrInvalidIntroNode,
+ },
+ {
+ name: "invalid discriminator 0x05",
+ data: []byte{0x05},
+ wantErr: ErrInvalidIntroNode,
+ },
+ {
+ name: "invalid discriminator 0xff",
+ data: []byte{0xff},
+ wantErr: ErrInvalidIntroNode,
+ },
+ {
+ name: "hop alloc overflow",
+ data: hopAllocOverflow(),
+ wantMsg: []string{"num_hops", "exceeds remaining"},
+ },
+ {
+ name: "enclen alloc overflow",
+ data: enclenOverflow(),
+ wantMsg: []string{"enclen", "exceeds remaining"},
+ },
+ {
+ name: "short intro pubkey",
+ data: shortIntroPubkey(),
+ wantMsg: []string{"read intro pubkey"},
+ },
+ {
+ name: "short blinding point",
+ data: shortBlindingPoint(),
+ wantMsg: []string{"read blinding point"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var bp BlindedPaths
+ err := decodeBlindedPaths(
+ bytes.NewReader(tc.data), &bp, new([8]byte),
+ uint64(len(tc.data)),
+ )
+ require.Error(t, err)
+
+ if tc.wantErr != nil {
+ require.ErrorIs(t, err, tc.wantErr)
+ }
+ for _, msg := range tc.wantMsg {
+ require.Contains(t, err.Error(), msg)
+ }
+ })
+ }
+}
+
+// TestEncodeBlindedPathsRejects pins the encoder's fail-closed guards. Any
+// case here must not emit bytes — invalid input cannot be retracted from the
+// wire once flushed.
+func TestEncodeBlindedPathsRejects(t *testing.T) {
+ t.Parallel()
+
+ validIntro, _ := validPubkeyIntro(t)
+ validHop := BlindedHop{BlindedNodeID: validBlindingPoint(t)}
+
+ tests := []struct {
+ name string
+ paths *BlindedPaths
+ wantErr error
+ wantMsg []string
+ wantNoWrite bool
+ }{
+ {
+ name: "nil intro",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{validHop},
+ }},
+ },
+ wantMsg: []string{"nil intro node"},
+ wantNoWrite: true,
+ },
+ {
+ name: "nil pubkey in PubkeyIntro",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: PubkeyIntro{},
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{
+ validHop,
+ },
+ }},
+ },
+ wantErr: ErrInvalidIntroNode,
+ wantNoWrite: true,
+ },
+ {
+ name: "invalid sciddir direction 0x02",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: SciddirIntro{
+ Direction: 0x02,
+ },
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{validHop},
+ }},
+ },
+ wantErr: ErrInvalidIntroNode,
+ wantNoWrite: true,
+ },
+ {
+ name: "invalid sciddir direction 0xff",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: SciddirIntro{
+ Direction: 0xff,
+ },
+ BlindingPoint: validBlindingPoint(t),
+ Hops: []BlindedHop{validHop},
+ }},
+ },
+ wantErr: ErrInvalidIntroNode,
+ wantNoWrite: true,
+ },
+ {
+ name: "nil blinding point",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: validIntro,
+ Hops: []BlindedHop{
+ validHop,
+ },
+ }},
+ },
+ wantMsg: []string{"nil blinding point"},
+ wantNoWrite: true,
+ },
+ {
+ name: "zero hops",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: validIntro,
+ BlindingPoint: validBlindingPoint(t),
+ Hops: nil,
+ }},
+ },
+ wantErr: ErrEmptyBlindedPath,
+ wantNoWrite: true,
+ },
+ {
+ name: "hop overflow",
+ paths: &BlindedPaths{
+ Paths: []BlindedPath{{
+ IntroductionNode: validIntro,
+ BlindingPoint: validBlindingPoint(t),
+ Hops: func() []BlindedHop {
+ hops := make([]BlindedHop,
+ maxBlindedPathHops+1)
+ pub := validBlindingPoint(t)
+ for i := range hops {
+ // Write to hop.
+ h := &hops[i]
+ h.BlindedNodeID = pub
+ }
+
+ return hops
+ }(),
+ }},
+ },
+ wantMsg: []string{"exceeds limit"},
+ wantNoWrite: true,
+ },
+ {
+ name: "oversize encrypted data",
+ paths: oversizeEncDataPaths(t, validIntro),
+ wantMsg: []string{"exceeds limit"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ var buf bytes.Buffer
+ err := encodeBlindedPaths(
+ &buf, tc.paths, new([8]byte),
+ )
+ require.Error(t, err)
+
+ if tc.wantErr != nil {
+ require.ErrorIs(t, err, tc.wantErr)
+ }
+ for _, msg := range tc.wantMsg {
+ require.Contains(t, err.Error(), msg)
+ }
+ if tc.wantNoWrite {
+ require.Equal(t, 0, buf.Len(),
+ "encoder wrote bytes on fail-closed "+
+ "path")
+ }
+ })
+ }
+}
diff --git a/lnwire/bounds.go b/lnwire/bounds.go
new file mode 100644
index 0000000..8cb79bb
--- /dev/null
+++ b/lnwire/bounds.go
@@ -0,0 +1,35 @@
+package lnwire
+
+import (
+ "math"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+)
+
+// BOLT 4 blinded-path field bounds. Each constant matches the format ceiling
+// imposed by the spec encoding (uint8 num_hops, uint16 enclen).
+const (
+ // pubKeyLen aliases the upstream compressed-pubkey length for shorter
+ // usage in this package.
+ pubKeyLen = btcec.PubKeyBytesLenCompressed
+
+ // sciddirLen is the on-wire length of a sciddir introduction node
+ // (1-byte direction + 8-byte SCID).
+ sciddirLen = 9
+
+ // scidLen is the byte length of a short channel ID.
+ scidLen = 8
+
+ // maxBlindedPathHops bounds the number of hops a single blinded path
+ // may declare. The spec encodes num_hops as a uint8, so 255 is the
+ // format's absolute ceiling.
+ maxBlindedPathHops = math.MaxUint8
+
+ // maxEncryptedDataLen bounds the encrypted-data field in a single
+ // blinded hop. The spec encodes the length as a uint16.
+ maxEncryptedDataLen = math.MaxUint16
+
+ // minBlindedHopBytes is the on-wire footprint of the smallest possible
+ // blinded hop: BlindedNodeID(33) + enclen(2) + 0 enc_data.
+ minBlindedHopBytes = pubKeyLen + 2
+)
diff --git a/lnwire/intro_node.go b/lnwire/intro_node.go
new file mode 100644
index 0000000..769da11
--- /dev/null
+++ b/lnwire/intro_node.go
@@ -0,0 +1,181 @@
+package lnwire
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+)
+
+// IntroductionNode is the sealed sum-type for a blinded path's introduction
+// node. {0x02, 0x03} → PubkeyIntro; {0x00, 0x01} → SciddirIntro. The unexported
+// method seals the variant set so foreign packages cannot satisfy the interface
+// with an unrecognised wire form.
+type IntroductionNode interface {
+ isIntroductionNode()
+
+ encodedLen() uint64
+
+ encode(w io.Writer) error
+
+ // validate checks that the discriminator byte is valid for the variant.
+ validate() error
+
+ // Bytes returns the wire-format encoding of the introduction node for
+ // callers that need it outside an io.Writer (RPC surfaces).
+ Bytes() []byte
+}
+
+// PubkeyIntro is the 33-byte compressed-pubkey variant. The SEC1 parity byte
+// (0x02 or 0x03) doubles as the wire discriminator. Use the constructor to
+// ensure the non-nil, on-curve invariant is upheld.
+type PubkeyIntro struct {
+ Pubkey *btcec.PublicKey
+}
+
+// NewPubkeyIntro constructs the pubkey introduction-node variant, establishing
+// the non-nil, on-curve invariant at construction time so callers receive an
+// error up front rather than relying on every method to re-guard a nil key.
+func NewPubkeyIntro(pubkey *btcec.PublicKey) (PubkeyIntro, error) {
+ p := PubkeyIntro{Pubkey: pubkey}
+ if err := p.validate(); err != nil {
+ return PubkeyIntro{}, err
+ }
+
+ return p, nil
+}
+
+// SciddirIntro is the 9-byte sciddir variant. Direction is the wire
+// discriminator; SCID is the 8-byte short channel ID. Use the constructor to
+// ensure the direction is valid at construction time.
+type SciddirIntro struct {
+ Direction byte
+ SCID [scidLen]byte
+}
+
+// NewSciddirIntro constructs the sciddir introduction-node variant, rejecting
+// an invalid direction discriminator at construction time.
+func NewSciddirIntro(direction byte, scid [scidLen]byte) (SciddirIntro, error) {
+ s := SciddirIntro{Direction: direction, SCID: scid}
+ if err := s.validate(); err != nil {
+ return SciddirIntro{}, err
+ }
+
+ return s, nil
+}
+
+var (
+ _ IntroductionNode = PubkeyIntro{}
+ _ IntroductionNode = SciddirIntro{}
+)
+
+// decodeIntroductionNode reads the discriminator byte and dispatches to the
+// matching variant.
+func decodeIntroductionNode(r io.Reader,
+ buf *[8]byte) (IntroductionNode, error) {
+
+ if _, err := io.ReadFull(r, buf[:1]); err != nil {
+ return nil, fmt.Errorf("read intro node type: %w", err)
+ }
+
+ disc := buf[0]
+ switch disc {
+ case 0x00, 0x01:
+ var scid [scidLen]byte
+ if _, err := io.ReadFull(r, scid[:]); err != nil {
+ return nil, fmt.Errorf("read sciddir: %w", err)
+ }
+
+ return NewSciddirIntro(disc, scid)
+
+ case 0x02, 0x03:
+ var b [pubKeyLen]byte
+ b[0] = disc
+ if _, err := io.ReadFull(r, b[1:]); err != nil {
+ return nil, fmt.Errorf("read intro pubkey: %w", err)
+ }
+ pub, err := btcec.ParsePubKey(b[:])
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w",
+ ErrInvalidIntroNode, err)
+ }
+
+ return NewPubkeyIntro(pub)
+
+ default:
+ return nil, fmt.Errorf("%w: 0x%02x", ErrInvalidIntroNode, disc)
+ }
+}
+
+func (PubkeyIntro) isIntroductionNode() {}
+
+func (p PubkeyIntro) encodedLen() uint64 { return pubKeyLen }
+
+func (p PubkeyIntro) encode(w io.Writer) error {
+ if p.Pubkey == nil {
+ return fmt.Errorf("nil intro pubkey")
+ }
+ _, err := w.Write(p.Pubkey.SerializeCompressed())
+
+ return err
+}
+
+func (p PubkeyIntro) validate() error {
+ if p.Pubkey == nil {
+ return fmt.Errorf("%w: nil pubkey", ErrInvalidIntroNode)
+ }
+
+ if !p.Pubkey.IsOnCurve() {
+ return fmt.Errorf("%w: pubkey not on curve",
+ ErrInvalidIntroNode)
+ }
+
+ return nil
+}
+
+// Bytes returns the wire-format encoding of the pubkey variant.
+func (p PubkeyIntro) Bytes() []byte {
+ var buf bytes.Buffer
+ buf.Grow(pubKeyLen)
+
+ // We ignore errors because we have validated that the pubkey is non nil
+ // at construction time.
+ _ = p.encode(&buf)
+
+ return buf.Bytes()
+}
+
+func (SciddirIntro) isIntroductionNode() {}
+
+func (s SciddirIntro) encodedLen() uint64 { return sciddirLen }
+
+func (s SciddirIntro) encode(w io.Writer) error {
+ if _, err := w.Write([]byte{s.Direction}); err != nil {
+ return err
+ }
+ _, err := w.Write(s.SCID[:])
+
+ return err
+}
+
+func (s SciddirIntro) validate() error {
+ switch s.Direction {
+ case 0x00, 0x01:
+ return nil
+ }
+
+ return fmt.Errorf("%w: 0x%02x", ErrInvalidIntroNode, s.Direction)
+}
+
+// Bytes returns the wire-format encoding of the sciddir variant.
+func (s SciddirIntro) Bytes() []byte {
+ var buf bytes.Buffer
+ buf.Grow(sciddirLen)
+
+ // We ignore the error because encode only writes the fixed-size
+ // direction and SCID to an in-memory buffer, which cannot fail.
+ _ = s.encode(&buf)
+
+ return buf.Bytes()
+}
Why this scored 12/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.