channeldb: make waiting proof migration repeatable
What changed, and why it matters
This commit fixes a database migration in LND so it can safely run twice. Previously, if a certain recovery process re-ran migration 35 on a database that already had newer-format 'waiting proof' records, the migration would likely fail or corrupt data. The fix makes the migration skip already-updated records while still converting old-format records. It is a robustness improvement, not a remote attack vector.
Include this fix in releases that support the missing-version recovery path. Users on v0.21+ who have run migration 35 and may use recovery tooling should upgrade before running recovery. No immediate remote mitigation is required.
Security signals we found
Database migration idempotency/robustness fix
Potential data corruption or node startup failure if migration re-run against mixed-format bucket
No input validation of attacker-controlled data; issue is local/state-recovery only
Evidence from the diff
Migration 35 in channeldb converts legacy 9-byte waiting proof keys to a typed waiting proof key format. The original migration assumed every record in the waitingProofsBucket was legacy. On databases created directly by v0.21 that already contain typed keys, re-running migration 35 (via the missing-version recovery path) would attempt to decode a typed key/value as legacy and fail. The patch adds a length check: legacy-length keys are migrated, typed-length keys are skipped, and any other key length returns an error. A test case verifies a bucket with both legacy and typed proofs migrates correctly without modifying the typed proof.
Changed components
channeldb/migration35/migration.gochanneldb/migration35/migration_test.goLND waiting proof store migrationInspect captured patch +93 / −0
diff --git a/channeldb/migration35/migration.go b/channeldb/migration35/migration.go
index 610ad64..1160f57 100644
--- a/channeldb/migration35/migration.go
+++ b/channeldb/migration35/migration.go
@@ -143,6 +143,20 @@ func MigrateWaitingProofStore(tx kvdb.RwTx) error {
return nil
}
+ switch len(k) {
+ case len(legacyWaitingProofKey{}):
+ // Legacy records continue below and are migrated.
+
+ case len(waitingProofKey{}):
+ // The record already uses the typed key format. This
+ // makes the migration safe to re-run during missing
+ // version key recovery.
+ return nil
+
+ default:
+ return fmt.Errorf("unexpected waiting proof key %x", k)
+ }
+
proof, err := decodeLegacyWaitingProof(v)
if err != nil {
return fmt.Errorf("decode waiting proof for key %x: %w",
diff --git a/channeldb/migration35/migration_test.go b/channeldb/migration35/migration_test.go
index 1d30c3f..bbb5178 100644
--- a/channeldb/migration35/migration_test.go
+++ b/channeldb/migration35/migration_test.go
@@ -162,6 +162,80 @@ func makeKeyMismatchSetup() (func(tx kvdb.RwTx) error,
return before, after
}
+// makeMixedLegacyAndTypedSetup creates pre- and post-migration callbacks for a
+// bucket that already contains a typed waiting proof.
+func makeMixedLegacyAndTypedSetup() (func(tx kvdb.RwTx) error,
+ func(tx kvdb.RwTx) error) {
+
+ legacyProof := &waitingProof{
+ announceSignatures: newAnnSig(20, 5),
+ isRemote: false,
+ }
+ typedProof := &waitingProof{
+ announceSignatures: newAnnSig(21, 6),
+ isRemote: true,
+ }
+
+ legacyKey := legacyProof.LegacyKey()
+ migratedKey := legacyProof.Key()
+ typedKey := typedProof.Key()
+ typedValue, err := encodeUpdatedWaitingProof(typedProof)
+ if err != nil {
+ panic(err)
+ }
+
+ before := func(tx kvdb.RwTx) error {
+ bucket, err := tx.CreateTopLevelBucket(waitingProofsBucketKey)
+ if err != nil {
+ return err
+ }
+
+ err = bucket.Put(legacyKey[:], encodeLegacyProof(legacyProof))
+ if err != nil {
+ return err
+ }
+
+ return bucket.Put(typedKey[:], typedValue)
+ }
+
+ after := func(tx kvdb.RwTx) error {
+ bucket := tx.ReadWriteBucket(waitingProofsBucketKey)
+ if bucket == nil {
+ return fmt.Errorf("waiting proofs bucket not found")
+ }
+
+ if bucket.Get(legacyKey[:]) != nil {
+ return fmt.Errorf("legacy key %x still exists",
+ legacyKey)
+ }
+
+ migratedValue := bucket.Get(migratedKey[:])
+ if migratedValue == nil {
+ return fmt.Errorf("migrated key %x not found",
+ migratedKey)
+ }
+
+ expectedMigratedValue, err := encodeUpdatedWaitingProof(
+ legacyProof,
+ )
+ if err != nil {
+ return err
+ }
+
+ if !bytes.Equal(migratedValue, expectedMigratedValue) {
+ return fmt.Errorf("unexpected migrated value")
+ }
+
+ if !bytes.Equal(bucket.Get(typedKey[:]), typedValue) {
+ return fmt.Errorf("typed proof was modified")
+ }
+
+ return nil
+ }
+
+ return before, after
+}
+
// TestMigrateWaitingProofStore verifies the waiting proof migration behavior.
func TestMigrateWaitingProofStore(t *testing.T) {
t.Parallel()
@@ -183,6 +257,11 @@ func TestMigrateWaitingProofStore(t *testing.T) {
setup: makeKeyMismatchSetup,
shouldFail: true,
},
+ {
+ name: "mixed legacy and typed proofs",
+ setup: makeMixedLegacyAndTypedSetup,
+ shouldFail: false,
+ },
}
for _, tc := range testCases {
Why this scored 22/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.