channeldb: add type-prefix to waiting proof store records
What changed, and why it matters
This commit is a forward-looking database format change for LND's 'waiting proof store,' which holds channel announcement proofs. It adds a one-byte type marker to each stored record and its lookup key so that a future gossip v2/Taproot version can safely store a different proof type in the same place. It also ships a database migration that rewrites all existing records to the new format. The change is defensive and structural, not a fix for an active vulnerability.
Treat as a normal schema-migration commit. Verify migration35 tests pass and that the migration is registered correctly. No immediate security response is required, but operators upgrading should ensure the migration completes cleanly because it rewrites waiting proof records in place.
Security signals we found
Database schema migration with key/value rewrite and key-format change
New explicit type discriminator to prevent cross-version record misinterpretation
Decode now rejects unknown waiting proof types
Migration includes sanity check that legacy key matches decoded record content
Atomic commit of migration and codec changes to avoid decode mismatch windows
Evidence from the diff
The patch extends WaitingProofKey from 9 to 10 bytes by prepending a proof-type byte, defines WaitingProofTypeV1 (0x00) for current AnnounceSignatures1 proofs, and updates Encode/Decode to always write/expect that prefix. Migration35 reads every legacy waiting proof record (legacy key: [scid(8)||isRemote(1)], value: isRemote + raw AnnounceSignatures), validates the key matches the decoded content, then rewrites each record with the new key [type(1)||scid(8)||isRemote(1)] and value prefixed by the type byte. Decode now rejects unknown proof types. The migration, codec changes, and tests are in one atomic commit to prevent a version mismatch where new code could read old records.
Changed components
channeldb WaitingProofStorechanneldb/waitingproof.go codecchanneldb/migration35 migration packagechanneldb/db.go migration registryInspect captured patch +498 / −10
diff --git a/channeldb/db.go b/channeldb/db.go
index 91f1886..999d2cd 100644
--- a/channeldb/db.go
+++ b/channeldb/db.go
@@ -30,6 +30,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb/migration32"
"github.com/lightningnetwork/lnd/channeldb/migration33"
"github.com/lightningnetwork/lnd/channeldb/migration34"
+ "github.com/lightningnetwork/lnd/channeldb/migration35"
"github.com/lightningnetwork/lnd/channeldb/migration_01_to_11"
"github.com/lightningnetwork/lnd/clock"
graphdb "github.com/lightningnetwork/lnd/graph/db"
@@ -303,6 +304,13 @@ var (
number: 33,
migration: migration33.MigrateMCStoreNameSpacedResults,
},
+ {
+ // We skip mandatory version 34 because that
+ // number is already associated with an
+ // optional migration package.
+ number: 35,
+ migration: migration35.MigrateWaitingProofStore,
+ },
}
// optionalVersions stores all optional migrations that are applied
diff --git a/channeldb/log.go b/channeldb/log.go
index fb2a85d..eab0170 100644
--- a/channeldb/log.go
+++ b/channeldb/log.go
@@ -13,6 +13,7 @@ import (
"github.com/lightningnetwork/lnd/channeldb/migration32"
"github.com/lightningnetwork/lnd/channeldb/migration33"
"github.com/lightningnetwork/lnd/channeldb/migration34"
+ "github.com/lightningnetwork/lnd/channeldb/migration35"
"github.com/lightningnetwork/lnd/channeldb/migration_01_to_11"
"github.com/lightningnetwork/lnd/kvdb"
)
@@ -48,5 +49,6 @@ func UseLogger(logger btclog.Logger) {
migration32.UseLogger(logger)
migration33.UseLogger(logger)
migration34.UseLogger(logger)
+ migration35.UseLogger(logger)
kvdb.UseLogger(logger)
}
diff --git a/channeldb/migration35/log.go b/channeldb/migration35/log.go
new file mode 100644
index 0000000..c39335d
--- /dev/null
+++ b/channeldb/migration35/log.go
@@ -0,0 +1,14 @@
+package migration35
+
+import (
+ "github.com/btcsuite/btclog/v2"
+)
+
+// log is a logger that is initialized as disabled. This means the package will
+// not perform any logging by default until a logger is set.
+var log = btclog.Disabled
+
+// UseLogger uses a specified logger to output package logging info.
+func UseLogger(logger btclog.Logger) {
+ log = logger
+}
diff --git a/channeldb/migration35/migration.go b/channeldb/migration35/migration.go
new file mode 100644
index 0000000..610ad64
--- /dev/null
+++ b/channeldb/migration35/migration.go
@@ -0,0 +1,197 @@
+package migration35
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+
+ lnwire "github.com/lightningnetwork/lnd/channeldb/migration/lnwire21"
+ "github.com/lightningnetwork/lnd/kvdb"
+)
+
+var (
+ // waitingProofsBucketKey is the top-level bucket that stores waiting
+ // proofs.
+ waitingProofsBucketKey = []byte("waitingproofs")
+
+ // byteOrder is the preferred DB byte order.
+ byteOrder = binary.BigEndian
+)
+
+// waitingProofType represents the type of a waiting proof record.
+type waitingProofType uint8
+
+const (
+ // waitingProofTypeV1 represents AnnounceSignatures1 proofs (gossip v1).
+ waitingProofTypeV1 waitingProofType = 0
+)
+
+// legacyWaitingProofKey is the key format used by legacy waiting proof
+// records: [scid(8) || isRemote(1)].
+type legacyWaitingProofKey [9]byte
+
+// waitingProofKey is the updated key format used by waiting proof records:
+// [proofType(1) || scid(8) || isRemote(1)].
+type waitingProofKey [10]byte
+
+// waitingProof is a migration-only representation of a waiting proof record.
+type waitingProof struct {
+ announceSignatures *lnwire.AnnounceSignatures
+ isRemote bool
+}
+
+// LegacyKey computes the legacy waiting proof store key.
+func (p *waitingProof) LegacyKey() legacyWaitingProofKey {
+ var key legacyWaitingProofKey
+ binary.BigEndian.PutUint64(
+ key[:8], p.announceSignatures.ShortChannelID.ToUint64(),
+ )
+
+ if p.isRemote {
+ key[8] = 1
+ }
+
+ return key
+}
+
+// Key computes the updated waiting proof store key.
+func (p *waitingProof) Key() waitingProofKey {
+ var key waitingProofKey
+ key[0] = byte(waitingProofTypeV1)
+
+ binary.BigEndian.PutUint64(
+ key[1:9], p.announceSignatures.ShortChannelID.ToUint64(),
+ )
+
+ if p.isRemote {
+ key[9] = 1
+ }
+
+ return key
+}
+
+// decodeLegacyWaitingProof decodes a pre-migration waiting proof in the
+// legacy format: isRemote + raw AnnounceSignatures payload.
+func decodeLegacyWaitingProof(v []byte) (*waitingProof, error) {
+ r := bytes.NewReader(v)
+
+ // Decode the legacy side bit first.
+ var isRemote bool
+ if err := binary.Read(r, byteOrder, &isRemote); err != nil {
+ return nil, err
+ }
+
+ // Decode the legacy AnnounceSignatures payload.
+ ann := &lnwire.AnnounceSignatures{}
+ if err := ann.Decode(r, 0); err != nil {
+ return nil, err
+ }
+
+ // Reconstruct the migration-local waiting proof representation.
+ return &waitingProof{
+ announceSignatures: ann,
+ isRemote: isRemote,
+ }, nil
+}
+
+// encodeUpdatedWaitingProof encodes a waiting proof in the new format:
+// type byte + isRemote + raw AnnounceSignatures payload.
+func encodeUpdatedWaitingProof(p *waitingProof) ([]byte, error) {
+ var b bytes.Buffer
+
+ // Prefix the payload with the explicit waiting proof type.
+ if err := binary.Write(&b, byteOrder, waitingProofTypeV1); err != nil {
+ return nil, err
+ }
+
+ // Preserve the side bit after the type prefix.
+ if err := binary.Write(&b, byteOrder, p.isRemote); err != nil {
+ return nil, err
+ }
+
+ // Encode the existing AnnounceSignatures payload unchanged.
+ if err := p.announceSignatures.Encode(&b, 0); err != nil {
+ return nil, err
+ }
+
+ return b.Bytes(), nil
+}
+
+// MigrateWaitingProofStore migrates waiting proofs to include a leading proof
+// type byte and rewrites record keys to include proof type as well.
+func MigrateWaitingProofStore(tx kvdb.RwTx) error {
+ log.Info("Migrating waiting proof store")
+
+ bucket := tx.ReadWriteBucket(waitingProofsBucketKey)
+
+ // If the bucket doesn't exist there is no data to migrate.
+ if bucket == nil {
+ return nil
+ }
+
+ type migratedProof struct {
+ oldKey []byte
+ newKey waitingProofKey
+ value []byte
+ }
+
+ var migratedProofs []migratedProof
+
+ err := bucket.ForEach(func(k, v []byte) error {
+ // Skip nested bucket references.
+ if v == nil {
+ return nil
+ }
+
+ proof, err := decodeLegacyWaitingProof(v)
+ if err != nil {
+ return fmt.Errorf("decode waiting proof for key %x: %w",
+ k, err)
+ }
+
+ // Sanity check: the key should match the proof content.
+ legacyKey := proof.LegacyKey()
+ if !bytes.Equal(k, legacyKey[:]) {
+ return fmt.Errorf("proof key (%x) does not "+
+ "match bucket key (%x)", legacyKey, k)
+ }
+
+ updatedProofValue, err := encodeUpdatedWaitingProof(proof)
+ if err != nil {
+ return fmt.Errorf("encode updated waiting "+
+ "proof for key %x: %w", k, err)
+ }
+
+ oldKey := make([]byte, len(k))
+ copy(oldKey, k)
+
+ migratedProofs = append(migratedProofs, migratedProof{
+ oldKey: oldKey,
+ newKey: proof.Key(),
+ value: updatedProofValue,
+ })
+
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ for _, proof := range migratedProofs {
+ if err := bucket.Delete(proof.oldKey); err != nil {
+ return fmt.Errorf(
+ "delete legacy waiting proof key %x: %w",
+ proof.oldKey, err,
+ )
+ }
+
+ if err := bucket.Put(proof.newKey[:], proof.value); err != nil {
+ return fmt.Errorf(
+ "put updated waiting proof key %x: %w",
+ proof.newKey, err,
+ )
+ }
+ }
+
+ return nil
+}
diff --git a/channeldb/migration35/migration_test.go b/channeldb/migration35/migration_test.go
new file mode 100644
index 0000000..685f575
--- /dev/null
+++ b/channeldb/migration35/migration_test.go
@@ -0,0 +1,201 @@
+package migration35
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "testing"
+
+ "github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcec/v2/ecdsa"
+ lnwire "github.com/lightningnetwork/lnd/channeldb/migration/lnwire21"
+ "github.com/lightningnetwork/lnd/channeldb/migtest"
+ "github.com/lightningnetwork/lnd/kvdb"
+)
+
+var (
+ testRBytes, _ = hex.DecodeString("8ce2bc69281ce27da07e6683571" +
+ "319d18e949ddfa2965fb6caa1bf0314f882d7")
+ testSBytes, _ = hex.DecodeString("299105481d63e0f4bc2a" +
+ "88121167221b6700d72a0ead154c03be696a292d24ae")
+ testRScalar = new(btcec.ModNScalar)
+ testSScalar = new(btcec.ModNScalar)
+ _ = testRScalar.SetByteSlice(testRBytes)
+ _ = testSScalar.SetByteSlice(testSBytes)
+ testECDSA = ecdsa.NewSignature(testRScalar, testSScalar)
+ testSig, _ = lnwire.NewSigFromSignature(testECDSA)
+)
+
+// encodeLegacyProof encodes a waiting proof in the pre-migration format.
+func encodeLegacyProof(p *waitingProof) []byte {
+ var b bytes.Buffer
+
+ err := binary.Write(&b, byteOrder, p.isRemote)
+ if err != nil {
+ panic(err)
+ }
+
+ err = p.announceSignatures.Encode(&b, 0)
+ if err != nil {
+ panic(err)
+ }
+
+ return b.Bytes()
+}
+
+// newAnnSig creates a deterministic announce signatures test message.
+func newAnnSig(scid uint64, chanIDByte byte) *lnwire.AnnounceSignatures {
+ return &lnwire.AnnounceSignatures{
+ ChannelID: lnwire.ChannelID{chanIDByte},
+ ShortChannelID: lnwire.NewShortChanIDFromInt(scid),
+ NodeSignature: testSig,
+ BitcoinSignature: testSig,
+ ExtraOpaqueData: []byte{chanIDByte, 1, 2, 3},
+ }
+}
+
+// makeHappyPathSetup creates pre- and post-migration callbacks for the
+// successful migration test case.
+func makeHappyPathSetup() (func(tx kvdb.RwTx) error,
+ func(tx kvdb.RwTx) error) {
+
+ proof1 := &waitingProof{
+ announceSignatures: newAnnSig(10, 1),
+ isRemote: false,
+ }
+ proof2 := &waitingProof{
+ announceSignatures: newAnnSig(11, 2),
+ isRemote: true,
+ }
+
+ legacyKey1 := proof1.LegacyKey()
+ legacyKey2 := proof2.LegacyKey()
+
+ before := func(tx kvdb.RwTx) error {
+ bucket, err := tx.CreateTopLevelBucket(waitingProofsBucketKey)
+ if err != nil {
+ return err
+ }
+
+ err = bucket.Put(legacyKey1[:], encodeLegacyProof(proof1))
+ if err != nil {
+ return err
+ }
+
+ return bucket.Put(legacyKey2[:], encodeLegacyProof(proof2))
+ }
+
+ expected := map[waitingProofKey]*waitingProof{
+ proof1.Key(): proof1,
+ proof2.Key(): proof2,
+ }
+
+ after := func(tx kvdb.RwTx) error {
+ bucket := tx.ReadWriteBucket(waitingProofsBucketKey)
+ if bucket == nil {
+ return fmt.Errorf("waiting proofs bucket not found")
+ }
+
+ for key, proof := range expected {
+ migrated := bucket.Get(key[:])
+ if migrated == nil {
+ return fmt.Errorf("migrated key %x "+
+ "not found", key)
+ }
+
+ expectedBytes, err := encodeUpdatedWaitingProof(proof)
+ if err != nil {
+ return err
+ }
+
+ if !bytes.Equal(migrated, expectedBytes) {
+ return fmt.Errorf("unexpected "+
+ "migrated bytes for "+
+ "key %x", key)
+ }
+ }
+
+ if bucket.Get(legacyKey1[:]) != nil {
+ return fmt.Errorf(
+ "legacy key %x still exists", legacyKey1,
+ )
+ }
+
+ if bucket.Get(legacyKey2[:]) != nil {
+ return fmt.Errorf(
+ "legacy key %x still exists", legacyKey2,
+ )
+ }
+
+ return nil
+ }
+
+ return before, after
+}
+
+// makeKeyMismatchSetup creates pre- and post-migration callbacks for the key
+// mismatch failure case.
+func makeKeyMismatchSetup() (func(tx kvdb.RwTx) error,
+ func(tx kvdb.RwTx) error) {
+
+ proof := &waitingProof{
+ announceSignatures: newAnnSig(15, 4),
+ isRemote: false,
+ }
+ wrongKey := legacyWaitingProofKey{}
+ binary.BigEndian.PutUint64(wrongKey[:8], 99)
+
+ before := func(tx kvdb.RwTx) error {
+ bucket, err := tx.CreateTopLevelBucket(waitingProofsBucketKey)
+ if err != nil {
+ return err
+ }
+
+ return bucket.Put(wrongKey[:], encodeLegacyProof(proof))
+ }
+
+ after := func(tx kvdb.RwTx) error {
+ return nil
+ }
+
+ return before, after
+}
+
+// TestMigrateWaitingProofStore verifies the waiting proof migration behavior.
+func TestMigrateWaitingProofStore(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ setup func() (
+ func(tx kvdb.RwTx) error, func(tx kvdb.RwTx) error,
+ )
+ shouldFail bool
+ }{
+ {
+ name: "happy path",
+ setup: makeHappyPathSetup,
+ shouldFail: false,
+ },
+ {
+ name: "key mismatch fails",
+ setup: makeKeyMismatchSetup,
+ shouldFail: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ before, after := tc.setup()
+ migtest.ApplyMigration(
+ t, before, after, MigrateWaitingProofStore,
+ tc.shouldFail,
+ )
+ })
+ }
+}
diff --git a/channeldb/waitingproof.go b/channeldb/waitingproof.go
index 0c3913f..63fe054 100644
--- a/channeldb/waitingproof.go
+++ b/channeldb/waitingproof.go
@@ -181,10 +181,20 @@ func (s *WaitingProofStore) Get(key WaitingProofKey) (*WaitingProof, error) {
return proof, err
}
-// WaitingProofKey is the proof key which uniquely identifies the waiting
-// proof object. The goal of this key is distinguish the local and remote
-// proof for the same channel id.
-type WaitingProofKey [9]byte
+// WaitingProofKey is the proof key which uniquely identifies the waiting proof
+// object. The key includes proof type, short channel ID, and side
+// (local/remote) to avoid cross-version collisions.
+type WaitingProofKey [10]byte
+
+// WaitingProofType represents the type of proof encoded in a waiting proof
+// record.
+type WaitingProofType uint8
+
+const (
+ // WaitingProofTypeV1 represents a waiting proof containing an
+ // AnnounceSignatures1 message (gossip v1, P2WSH channels).
+ WaitingProofTypeV1 WaitingProofType = 0
+)
// WaitingProof is the storable object, which encapsulate the half proof and
// the information about from which side this proof came. This structure is
@@ -207,28 +217,34 @@ func NewWaitingProof(isRemote bool,
// OppositeKey returns the key which uniquely identifies opposite waiting proof.
func (p *WaitingProof) OppositeKey() WaitingProofKey {
- var key [9]byte
- binary.BigEndian.PutUint64(key[:8], p.ShortChannelID.ToUint64())
+ var key WaitingProofKey
+ key[0] = byte(WaitingProofTypeV1)
+ binary.BigEndian.PutUint64(key[1:9], p.ShortChannelID.ToUint64())
if !p.isRemote {
- key[8] = 1
+ key[9] = 1
}
return key
}
// Key returns the key which uniquely identifies waiting proof.
func (p *WaitingProof) Key() WaitingProofKey {
- var key [9]byte
- binary.BigEndian.PutUint64(key[:8], p.ShortChannelID.ToUint64())
+ var key WaitingProofKey
+ key[0] = byte(WaitingProofTypeV1)
+ binary.BigEndian.PutUint64(key[1:9], p.ShortChannelID.ToUint64())
if p.isRemote {
- key[8] = 1
+ key[9] = 1
}
return key
}
// Encode writes the internal representation of waiting proof in byte stream.
func (p *WaitingProof) Encode(w io.Writer) error {
+ if err := binary.Write(w, byteOrder, WaitingProofTypeV1); err != nil {
+ return err
+ }
+
if err := binary.Write(w, byteOrder, p.isRemote); err != nil {
return err
}
@@ -250,6 +266,15 @@ func (p *WaitingProof) Encode(w io.Writer) error {
// Decode reads the data from the byte stream and initializes the
// waiting proof object with it.
func (p *WaitingProof) Decode(r io.Reader) error {
+ var proofType WaitingProofType
+ if err := binary.Read(r, byteOrder, &proofType); err != nil {
+ return err
+ }
+
+ if proofType != WaitingProofTypeV1 {
+ return fmt.Errorf("unknown waiting proof type: %v", proofType)
+ }
+
if err := binary.Read(r, byteOrder, &p.isRemote); err != nil {
return err
}
diff --git a/channeldb/waitingproof_test.go b/channeldb/waitingproof_test.go
index 7155a6c..a3916fa 100644
--- a/channeldb/waitingproof_test.go
+++ b/channeldb/waitingproof_test.go
@@ -1,6 +1,8 @@
package channeldb
import (
+ "bytes"
+ "encoding/binary"
"errors"
"reflect"
"testing"
@@ -55,3 +57,42 @@ func TestWaitingProofStore(t *testing.T) {
t.Fatal(err)
}
}
+
+// TestWaitingProofEncodePrefix asserts that waiting proofs are encoded with the
+// V1 waiting proof type prefix.
+func TestWaitingProofEncodePrefix(t *testing.T) {
+ t.Parallel()
+
+ proof := NewWaitingProof(true, &lnwire.AnnounceSignatures1{
+ NodeSignature: wireSig,
+ BitcoinSignature: wireSig,
+ ExtraOpaqueData: []byte{1, 2, 3},
+ })
+
+ var encoded bytes.Buffer
+ require.NoError(t, proof.Encode(&encoded))
+
+ var proofType WaitingProofType
+ require.NoError(t, binary.Read(&encoded, byteOrder, &proofType))
+ require.Equal(t, WaitingProofTypeV1, proofType)
+}
+
+// TestWaitingProofDecodeUnknownType asserts that decoding fails for unknown
+// waiting proof type prefixes.
+func TestWaitingProofDecodeUnknownType(t *testing.T) {
+ t.Parallel()
+
+ var encoded bytes.Buffer
+ require.NoError(t, binary.Write(&encoded, byteOrder, uint8(99)))
+ require.NoError(t, binary.Write(&encoded, byteOrder, true))
+
+ msg := &lnwire.AnnounceSignatures1{
+ NodeSignature: wireSig,
+ BitcoinSignature: wireSig,
+ }
+ require.NoError(t, msg.Encode(&encoded, 0))
+
+ var proof WaitingProof
+ err := proof.Decode(&encoded)
+ require.ErrorContains(t, err, "unknown waiting proof type")
+}
Why this scored 26/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.