From 11e063568d91dedb19fa3e31dc507a533bb4d7bb Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:40 +0200 Subject: [PATCH 01/24] shachain: add IndependentStore for non-shachain secrets IndependentStore is a Store for per-commitment secrets that have no shachain relationship, as on a channel with option_independent_secrets. It accepts any secret, keeps the two most recent in memory (enough for channel_reestablish, including mid-revocation), and starts its encoding with a 0xff marker, which a RevocationStore encoding never does. NewStoreFromBytes decodes either kind. --- shachain/independent_store.go | 209 +++++++++++++++++++++++++++++ shachain/independent_store_test.go | 141 +++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 shachain/independent_store.go create mode 100644 shachain/independent_store_test.go diff --git a/shachain/independent_store.go b/shachain/independent_store.go new file mode 100644 index 00000000000..e90c106ca12 --- /dev/null +++ b/shachain/independent_store.go @@ -0,0 +1,209 @@ +package shachain + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/btcsuite/btcd/chaincfg/chainhash" +) + +const ( + // independentStoreMarker is the first byte of an encoded + // IndependentStore. An encoded RevocationStore starts with its bucket + // count, which is at most maxHeight (48), so the two encodings can + // never be confused. + independentStoreMarker byte = 0xff + + // independentStoreVersion is the version of the IndependentStore + // encoding. + independentStoreVersion byte = 0x01 +) + +var ( + // ErrSecretNotRetained is returned by IndependentStore.LookUp for an + // index other than the two most recent ones. On channels with + // option_independent_secrets every revoked secret is persisted with + // its revocation log entry instead, which is where callers must look + // for older states. + ErrSecretNotRetained = errors.New("per-commitment secret not " + + "retained by the store; look it up in the revocation log") + + // errBadIndependentStore is returned when an encoded IndependentStore + // is malformed. + errBadIndependentStore = errors.New("malformed independent secret " + + "store") +) + +// IndependentStore is the Store used for channels that negotiated +// option_independent_secrets. On such channels the remote party may generate +// its per-commitment secrets by any method (from a seed, at random, or +// jointly across several signers), so the secrets can't be checked against, +// or compressed into, a shachain. +// +// The store therefore keeps only the two most recent secrets, which is all +// the channel needs in memory: channel_reestablish sends the last one back to +// the peer as your_last_per_commitment_secret. The one before it is kept so +// that a channel_reestablish built between receiving a revoke_and_ack and +// advancing the remote commitment (which still names the earlier height) +// can be answered too. Every secret is also persisted alongside the +// revocation log entry of the commitment it revokes, which is where a breach +// retribution reads it from. +type IndependentStore struct { + // numEntries is the number of secrets added so far. The next secret + // added gets this index. + numEntries uint64 + + // latest is the most recently added secret, at index numEntries-1. + latest chainhash.Hash + + // previous is the secret added before latest, at index numEntries-2. + previous chainhash.Hash +} + +// A compile time check to ensure IndependentStore implements the Store +// interface. +var _ Store = (*IndependentStore)(nil) + +// NewIndependentStore creates an empty IndependentStore. +func NewIndependentStore() *IndependentStore { + return &IndependentStore{} +} + +// LookUp returns the secret at index v, which must be one of the two most +// recently added. Older secrets are not retained in memory; +// ErrSecretNotRetained is returned for them. +// +// NOTE: This is part of the Store interface. +func (s *IndependentStore) LookUp(v uint64) (*chainhash.Hash, error) { + if s.numEntries == 0 || v >= s.numEntries { + return nil, fmt.Errorf("unable to get hash at index %d: no "+ + "such entry", v) + } + + switch v { + case s.numEntries - 1: + latest := s.latest + return &latest, nil + + case s.numEntries - 2: + previous := s.previous + return &previous, nil + + default: + return nil, ErrSecretNotRetained + } +} + +// AddNextEntry records the next secret. Unlike RevocationStore it does not +// check that the secret derives the earlier ones: on these channels there is +// no such relationship. The caller is still responsible for checking that the +// secret generates the per-commitment point it revokes. +// +// NOTE: This is part of the Store interface. +func (s *IndependentStore) AddNextEntry(hash *chainhash.Hash) error { + if hash == nil { + return errors.New("nil per-commitment secret") + } + + s.previous = s.latest + s.latest = *hash + s.numEntries++ + + return nil +} + +// NumEntries returns the number of secrets added to the store so far. +func (s *IndependentStore) NumEntries() uint64 { + return s.numEntries +} + +// Encode writes the store to w: a marker byte that tells it apart from an +// encoded RevocationStore, a version byte, the number of entries, then the +// most recent secret and the one before it, as far as they exist. +// +// NOTE: This is part of the Store interface. +func (s *IndependentStore) Encode(w io.Writer) error { + if _, err := w.Write([]byte{ + independentStoreMarker, independentStoreVersion, + }); err != nil { + return err + } + + err := binary.Write(w, binary.BigEndian, s.numEntries) + if err != nil { + return err + } + + if s.numEntries == 0 { + return nil + } + + if _, err := w.Write(s.latest[:]); err != nil { + return err + } + + if s.numEntries == 1 { + return nil + } + + _, err = w.Write(s.previous[:]) + + return err +} + +// decodeIndependentStore reads an IndependentStore whose marker byte has +// already been consumed. +func decodeIndependentStore(r io.Reader) (*IndependentStore, error) { + var version [1]byte + if _, err := io.ReadFull(r, version[:]); err != nil { + return nil, err + } + if version[0] != independentStoreVersion { + return nil, fmt.Errorf("%w: unknown version %d", + errBadIndependentStore, version[0]) + } + + s := &IndependentStore{} + if err := binary.Read(r, binary.BigEndian, &s.numEntries); err != nil { + return nil, err + } + + if s.numEntries == 0 { + return s, nil + } + + if _, err := io.ReadFull(r, s.latest[:]); err != nil { + return nil, err + } + + if s.numEntries == 1 { + return s, nil + } + + if _, err := io.ReadFull(r, s.previous[:]); err != nil { + return nil, err + } + + return s, nil +} + +// NewStoreFromBytes decodes a Store written by either RevocationStore.Encode +// or IndependentStore.Encode, telling them apart by the first byte. It reads +// exactly the bytes of the encoded store, so further data may follow it in r. +func NewStoreFromBytes(r io.Reader) (Store, error) { + var first [1]byte + if _, err := io.ReadFull(r, first[:]); err != nil { + return nil, err + } + + if first[0] == independentStoreMarker { + return decodeIndependentStore(r) + } + + return NewRevocationStoreFromBytes( + io.MultiReader(bytes.NewReader(first[:]), r), + ) +} diff --git a/shachain/independent_store_test.go b/shachain/independent_store_test.go new file mode 100644 index 00000000000..92d4db71c44 --- /dev/null +++ b/shachain/independent_store_test.go @@ -0,0 +1,141 @@ +package shachain + +import ( + "bytes" + "crypto/rand" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/stretchr/testify/require" +) + +func randomHash(t *testing.T) *chainhash.Hash { + t.Helper() + + var h chainhash.Hash + _, err := rand.Read(h[:]) + require.NoError(t, err) + + return &h +} + +// TestIndependentStoreAcceptsUnrelatedSecrets checks that the store accepts +// secrets with no shachain relationship, which RevocationStore refuses, and +// that it serves the two most recent ones. +func TestIndependentStoreAcceptsUnrelatedSecrets(t *testing.T) { + t.Parallel() + + // A RevocationStore refuses two random secrets in a row, because the + // second (index 1) must derive the first (index 0). + shaStore := NewRevocationStore() + require.NoError(t, shaStore.AddNextEntry(randomHash(t))) + require.Error(t, shaStore.AddNextEntry(randomHash(t))) + + store := NewIndependentStore() + _, err := store.LookUp(0) + require.Error(t, err) + + var last, prev *chainhash.Hash + for i := uint64(0); i < 100; i++ { + prev = last + last = randomHash(t) + require.NoError(t, store.AddNextEntry(last)) + + got, err := store.LookUp(i) + require.NoError(t, err) + require.Equal(t, *last, *got) + + if i > 0 { + got, err := store.LookUp(i - 1) + require.NoError(t, err) + require.Equal(t, *prev, *got) + } + + if i > 1 { + _, err := store.LookUp(i - 2) + require.ErrorIs(t, err, ErrSecretNotRetained) + } + } + + require.EqualValues(t, 100, store.NumEntries()) + + _, err = store.LookUp(100) + require.Error(t, err) +} + +// TestStoreEncodingRoundTrip checks that NewStoreFromBytes decodes both store +// kinds, reads exactly the bytes of the store, and preserves their contents. +func TestStoreEncodingRoundTrip(t *testing.T) { + t.Parallel() + + trailer := []byte{0xde, 0xad, 0xbe, 0xef} + + // Empty and populated independent stores. + for _, n := range []int{0, 1, 2, 7} { + store := NewIndependentStore() + var last, prev *chainhash.Hash + for i := 0; i < n; i++ { + prev = last + last = randomHash(t) + require.NoError(t, store.AddNextEntry(last)) + } + + var b bytes.Buffer + require.NoError(t, store.Encode(&b)) + b.Write(trailer) + + r := bytes.NewReader(b.Bytes()) + decoded, err := NewStoreFromBytes(r) + require.NoError(t, err) + + ind, ok := decoded.(*IndependentStore) + require.True(t, ok) + require.EqualValues(t, n, ind.NumEntries()) + if n > 0 { + got, err := ind.LookUp(uint64(n - 1)) + require.NoError(t, err) + require.Equal(t, *last, *got) + } + if n > 1 { + got, err := ind.LookUp(uint64(n - 2)) + require.NoError(t, err) + require.Equal(t, *prev, *got) + } + + rest := make([]byte, r.Len()) + _, _ = r.Read(rest) + require.Equal(t, trailer, rest) + } + + // A shachain store still decodes as a RevocationStore. + seed := randomHash(t) + producer := NewRevocationProducer(*seed) + shaStore := NewRevocationStore() + for i := uint64(0); i < 10; i++ { + secret, err := producer.AtIndex(i) + require.NoError(t, err) + require.NoError(t, shaStore.AddNextEntry(secret)) + } + + var b bytes.Buffer + require.NoError(t, shaStore.Encode(&b)) + b.Write(trailer) + + r := bytes.NewReader(b.Bytes()) + decoded, err := NewStoreFromBytes(r) + require.NoError(t, err) + + _, ok := decoded.(*RevocationStore) + require.True(t, ok) + for i := uint64(0); i < 10; i++ { + want, err := producer.AtIndex(i) + require.NoError(t, err) + got, err := decoded.LookUp(i) + require.NoError(t, err) + require.Equal(t, *want, *got) + } + + rest := make([]byte, r.Len()) + _, _ = r.Read(rest) + require.Equal(t, trailer, rest) +} From c0db491da39c278f65a46631743d5a2cff4e9d3a Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:40 +0200 Subject: [PATCH 02/24] channeldb: decode either revocation store kind The channel codec and the migration codecs now decode a revocation store with shachain.NewStoreFromBytes, so they read an IndependentStore as well as a RevocationStore. Legacy stores decode exactly as before. The optional revocation log migration (migration30) reads the revocation state of every channel, so without this it would fail once a channel with an IndependentStore exists. --- channeldb/codec.go | 5 +- .../migration21/current/current_codec.go | 6 ++- .../migration30/independent_secrets_test.go | 51 +++++++++++++++++++ channeldb/migration_01_to_11/codec.go | 6 ++- 4 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 channeldb/migration30/independent_secrets_test.go diff --git a/channeldb/codec.go b/channeldb/codec.go index e5bab3d5f76..157cb12e754 100644 --- a/channeldb/codec.go +++ b/channeldb/codec.go @@ -355,7 +355,10 @@ func ReadElement(r io.Reader, element interface{}) error { *e = producer case *shachain.Store: - store, err := shachain.NewRevocationStoreFromBytes(r) + // Either a shachain RevocationStore or, on channels with + // option_independent_secrets, an IndependentStore. The encoding + // says which. + store, err := shachain.NewStoreFromBytes(r) if err != nil { return err } diff --git a/channeldb/migration21/current/current_codec.go b/channeldb/migration21/current/current_codec.go index d4a28e69b27..025631d8633 100644 --- a/channeldb/migration21/current/current_codec.go +++ b/channeldb/migration21/current/current_codec.go @@ -348,7 +348,11 @@ func ReadElement(r io.Reader, element interface{}) error { *e = producer case *shachain.Store: - store, err := shachain.NewRevocationStoreFromBytes(r) + // NewStoreFromBytes also decodes the IndependentStore of a + // channel with option_independent_secrets. Such channels only + // exist in a fully migrated database, but optional + // migrations such as migration30 still read them. + store, err := shachain.NewStoreFromBytes(r) if err != nil { return err } diff --git a/channeldb/migration30/independent_secrets_test.go b/channeldb/migration30/independent_secrets_test.go new file mode 100644 index 00000000000..6dc3584252b --- /dev/null +++ b/channeldb/migration30/independent_secrets_test.go @@ -0,0 +1,51 @@ +package migration30 + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/channeldb/migtest" + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/shachain" +) + +// TestMigrateRevocationLogIndependentStore checks that the revocation log +// migration, which can also be run later as an optional migration, still +// succeeds when the database holds a channel with option_independent_secrets. +// Such a channel has no legacy revocation logs, but the migration decodes the +// revocation state of every channel, and its store isn't a shachain. +func TestMigrateRevocationLogIndependentStore(t *testing.T) { + legacyChan, independentChan := createTwoChannels() + + store := shachain.NewIndependentStore() + if err := store.AddNextEntry(&chainhash.Hash{1}); err != nil { + t.Fatal(err) + } + if err := store.AddNextEntry(&chainhash.Hash{2}); err != nil { + t.Fatal(err) + } + independentChan.RevocationStore = store + + beforeMigration := func(db kvdb.Backend) error { + if err := createNotStarted(db, legacyChan, true); err != nil { + return err + } + + return setupTestLogs(db, independentChan, nil, nil) + } + + afterMigration := func(db kvdb.Backend) error { + assertOldLogBucketDeleted(t, db, legacyChan) + return nil + } + + migtest.ApplyMigrationWithDB( + t, beforeMigration, afterMigration, + func(db kvdb.Backend) error { + return MigrateRevocationLog( + db, &MigrateRevLogConfigImpl{}, + ) + }, + false, + ) +} diff --git a/channeldb/migration_01_to_11/codec.go b/channeldb/migration_01_to_11/codec.go index ca2f5cff6d4..0cd59d7fe7f 100644 --- a/channeldb/migration_01_to_11/codec.go +++ b/channeldb/migration_01_to_11/codec.go @@ -354,7 +354,11 @@ func ReadElement(r io.Reader, element interface{}) error { *e = producer case *shachain.Store: - store, err := shachain.NewRevocationStoreFromBytes(r) + // NewStoreFromBytes also decodes the IndependentStore of a + // channel with option_independent_secrets. Such channels only + // exist in a fully migrated database, but optional + // migrations such as migration30 still read them. + store, err := shachain.NewStoreFromBytes(r) if err != nil { return err } From d9d173206792625fbf69bad117c86d41bdf68a36 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:41 +0200 Subject: [PATCH 03/24] channeldb: persist independent per-commitment secrets Add IndependentSecretsBit, NewRemoteSecretStore, which picks the store for a channel type, and OpenChannel.UsesIndependentSecrets, which also trusts the store type so the two can't disagree silently. The bit is 1 << 40, far above upstream lnd's allocations, so a rebase onto upstream can't hand it another meaning. On such channels the secret that revokes a remote commitment is written to that commitment's revocation log entry (optional TLV 65537) in the same transaction that moves the commitment into the log. It must be the newest secret in the store; a store out of step with the commitment chain is refused rather than answered with the secret before it. ChanSyncMsg falls back to the revocation log when the in-memory store no longer holds the secret it needs, which a copy of the channel refreshed in two steps can run into. --- channeldb/channel.go | 146 +++++++++++++++++++++- channeldb/independent_secrets_test.go | 172 ++++++++++++++++++++++++++ channeldb/revocation_log.go | 41 ++++++ 3 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 channeldb/independent_secrets_test.go diff --git a/channeldb/channel.go b/channeldb/channel.go index 0df245af1c9..685937c90c6 100644 --- a/channeldb/channel.go +++ b/channeldb/channel.go @@ -496,6 +496,19 @@ const ( // open and fixed for the channel's life: both sides must compute the // same digest or every signature they exchange fails to verify. UnifiedSigsBit ChannelType = 1 << 13 + + // IndependentSecretsBit indicates that option_independent_secrets was + // negotiated for this channel: the remote party's per-commitment + // secrets are not required to come from a shachain, so this node + // stores every one it receives (alongside the revocation log entry of + // the commitment it revokes) instead of compressing them. Negotiated + // at open and fixed for the channel's life. It only changes what this + // node accepts and stores; how this node generates its own secrets is + // unchanged. + // + // The bit is far above upstream lnd's allocations, so rebasing onto + // upstream can't hand the same bit to another meaning. + IndependentSecretsBit ChannelType = 1 << 40 ) // IsSingleFunder returns true if the channel type if one of the known single @@ -578,6 +591,41 @@ func (c ChannelType) HasUnifiedSigs() bool { return c&UnifiedSigsBit == UnifiedSigsBit } +// HasIndependentSecrets returns true if option_independent_secrets was +// negotiated for the channel, so the remote party's per-commitment secrets are +// stored individually rather than in a shachain. +func (c ChannelType) HasIndependentSecrets() bool { + return c&IndependentSecretsBit == IndependentSecretsBit +} + +// NewRemoteSecretStore returns an empty store for the remote party's +// per-commitment secrets that suits the channel type: an IndependentStore when +// option_independent_secrets was negotiated, otherwise a shachain +// RevocationStore. +func NewRemoteSecretStore(chanType ChannelType) shachain.Store { + if chanType.HasIndependentSecrets() { + return shachain.NewIndependentStore() + } + + return shachain.NewRevocationStore() +} + +// UsesIndependentSecrets reports whether the remote party's per-commitment +// secrets are kept as on a channel with option_independent_secrets: each one +// with the revocation log entry of the commitment it revokes, rather than in +// a shachain. The store type is checked as well as the channel type, so that +// a channel whose two disagree still looks for its secrets in the revocation +// log instead of failing to punish a breach. +func (c *OpenChannel) UsesIndependentSecrets() bool { + if c.ChanType.HasIndependentSecrets() { + return true + } + + _, ok := c.RevocationStore.(*shachain.IndependentStore) + + return ok +} + // IsTaprootFinal returns true if the channel is using final/production taproot // scripts and feature bits. func (c ChannelType) IsTaprootFinal() bool { @@ -2138,6 +2186,15 @@ func (c *OpenChannel) ChanSyncMsg() (*lnwire.ChannelReestablish, error) { remoteSecret, err := c.RevocationStore.LookUp( remoteChainTipHeight - 1, ) + + // An IndependentStore only keeps its newest secrets in memory. + // If it has run further ahead of this copy of the remote + // commitment than that, the secret is in the revocation log. + if errors.Is(err, shachain.ErrSecretNotRetained) { + remoteSecret, err = c.loggedCommitSecret( + remoteChainTipHeight - 1, + ) + } if err != nil { return nil, err } @@ -3724,11 +3781,21 @@ func (c *OpenChannel) AdvanceCommitChainTail(fwdPkg *FwdPkg, return err } + // On channels with option_independent_secrets the revocation + // store keeps only the latest secret, which is the one that + // revokes the commitment being logged here, so it's persisted + // with that log entry where a breach retribution can find it. + commitSecret, err := c.revokedCommitSecret() + if err != nil { + return err + } + // With the commitment pointer swapped, we can now add the // revoked (prior) state to the revocation log. - err = putRevocationLog( + err = putRevocationLogWithSecret( logBucket, &c.RemoteCommitment, ourOutputIndex, theirOutputIndex, c.Db.parent.noRevLogAmtData, + commitSecret, ) if err != nil { return err @@ -4619,6 +4686,83 @@ func (c *OpenChannel) LatestCommitments() (*ChannelCommitment, *ChannelCommitmen return &c.LocalCommitment, &c.RemoteCommitment, nil } +// revokedCommitSecret returns the secret that revokes the current remote +// commitment when option_independent_secrets was negotiated, and None +// otherwise. It's called while that commitment is being moved to the +// revocation log, just after the remote party revealed the secret. +// +// NOTE: The channel's mutex must be held. +func (c *OpenChannel) revokedCommitSecret() (fn.Option[[32]byte], error) { + if !c.UsesIndependentSecrets() { + return fn.None[[32]byte](), nil + } + + // The secret must be the newest one in the store. If it isn't, the + // store and the commitment chain are out of step, and persisting both + // would store an inconsistent state, so refuse instead. + height := c.RemoteCommitment.CommitHeight + counted, ok := c.RevocationStore.(interface{ NumEntries() uint64 }) + if ok && counted.NumEntries() != height+1 { + return fn.None[[32]byte](), fmt.Errorf("revocation store "+ + "holds %d secrets, want the newest to revoke "+ + "remote commitment %d", counted.NumEntries(), height) + } + + secret, err := c.RevocationStore.LookUp(height) + if err != nil { + return fn.None[[32]byte](), fmt.Errorf("no per-commitment "+ + "secret for revoked remote commitment %d: %w", height, + err) + } + + return fn.Some([32]byte(*secret)), nil +} + +// loggedCommitSecret reads the per-commitment secret that revoked the remote +// commitment at height from its revocation log entry, where a channel with +// option_independent_secrets keeps it. +func (c *OpenChannel) loggedCommitSecret(height uint64) (*chainhash.Hash, + error) { + + var secret *chainhash.Hash + err := kvdb.View(c.Db.backend, func(tx kvdb.RTx) error { + chanBucket, err := fetchChanBucket( + tx, c.IdentityPub, &c.FundingOutpoint, c.ChainHash, + ) + if err != nil { + return err + } + + rl, _, err := fetchRevocationLogCompatible(chanBucket, height) + if err != nil { + return err + } + if rl == nil { + return nil + } + + rl.CommitSecret.WhenSome( + func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { + h := chainhash.Hash(r.Val) + secret = &h + }, + ) + + return nil + }, func() { + secret = nil + }) + if err != nil { + return nil, err + } + if secret == nil { + return nil, fmt.Errorf("revocation log for remote commitment "+ + "%d has no per-commitment secret", height) + } + + return secret, nil +} + // RemoteRevocationStore returns the most up to date commitment version of the // revocation storage tree for the remote party. This method can be used when // acting on a possible contract breach to ensure, that the caller has the most diff --git a/channeldb/independent_secrets_test.go b/channeldb/independent_secrets_test.go new file mode 100644 index 00000000000..261c415fdc4 --- /dev/null +++ b/channeldb/independent_secrets_test.go @@ -0,0 +1,172 @@ +package channeldb + +import ( + "crypto/rand" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/shachain" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// TestRevocationLogCommitSecret checks that a revocation log entry stores the +// per-commitment secret it's given, and that entries written without one +// read back without one. +func TestRevocationLogCommitSecret(t *testing.T) { + t.Parallel() + + var secret [32]byte + _, err := rand.Read(secret[:]) + require.NoError(t, err) + + fullDB, err := MakeTestDB(t) + require.NoError(t, err) + backend := fullDB.ChannelStateDB().backend + + withSecret := testChannelCommit + withSecret.CommitHeight = 7 + withoutSecret := testChannelCommit + withoutSecret.CommitHeight = 8 + + // The secret is kept whether or not the node stores HTLC amounts in + // its revocation log. + for _, noAmtData := range []bool{false, true} { + var got, gotNone RevocationLog + err = kvdb.Update(backend, func(tx kvdb.RwTx) error { + _, bucket, err := createTestRevocationLogBuckets(tx) + require.NoError(t, err) + + err = putRevocationLogWithSecret( + bucket, &withSecret, 0, 1, noAmtData, + fn.Some(secret), + ) + require.NoError(t, err) + + err = putRevocationLog( + bucket, &withoutSecret, 0, 1, noAmtData, + ) + require.NoError(t, err) + + got, err = fetchRevocationLog( + bucket, withSecret.CommitHeight, + ) + require.NoError(t, err) + + gotNone, err = fetchRevocationLog( + bucket, withoutSecret.CommitHeight, + ) + + return err + }, func() {}) + require.NoError(t, err) + + var stored [32]byte + got.CommitSecret.WhenSome( + func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { + stored = r.Val + }, + ) + require.True(t, got.CommitSecret.IsSome(), "noAmtData=%v", + noAmtData) + require.Equal(t, secret, stored) + + require.True(t, gotNone.CommitSecret.IsNone()) + + err = kvdb.Update(backend, func(tx kvdb.RwTx) error { + return tx.DeleteTopLevelBucket(openChannelBucket) + }, func() {}) + require.NoError(t, err) + } +} + +// TestNewRemoteSecretStore checks the store chosen for each channel type. +func TestNewRemoteSecretStore(t *testing.T) { + t.Parallel() + + chanType := SingleFunderTweaklessBit | AnchorOutputsBit + _, ok := NewRemoteSecretStore(chanType).(*shachain.RevocationStore) + require.True(t, ok) + + chanType |= IndependentSecretsBit + require.True(t, chanType.HasIndependentSecrets()) + _, ok = NewRemoteSecretStore(chanType).(*shachain.IndependentStore) + require.True(t, ok) +} + +// TestUsesIndependentSecrets checks that either the channel type bit or the +// store type marks a channel as keeping its secrets in the revocation log, so +// a channel whose two disagree still finds them there. +func TestUsesIndependentSecrets(t *testing.T) { + t.Parallel() + + base := SingleFunderTweaklessBit | AnchorOutputsBit + + cases := []struct { + name string + bit bool + store shachain.Store + want bool + }{ + {"neither", false, shachain.NewRevocationStore(), false}, + {"both", true, shachain.NewIndependentStore(), true}, + {"bit only", true, shachain.NewRevocationStore(), true}, + {"store only", false, shachain.NewIndependentStore(), true}, + } + for _, c := range cases { + chanType := base + if c.bit { + chanType |= IndependentSecretsBit + } + channel := &OpenChannel{ + ChanType: chanType, + RevocationStore: c.store, + } + require.Equal(t, c.want, channel.UsesIndependentSecrets(), + c.name) + } +} + +// TestRevokedCommitSecretRequiresNewest checks that the secret written to the +// revocation log is the newest one in the store, and that a store whose +// count doesn't match the commitment being revoked is refused rather than +// answered with the secret before it. +func TestRevokedCommitSecretRequiresNewest(t *testing.T) { + t.Parallel() + + store := shachain.NewIndependentStore() + var newest [32]byte + for i := 0; i < 3; i++ { + _, err := rand.Read(newest[:]) + require.NoError(t, err) + h := chainhash.Hash(newest) + require.NoError(t, store.AddNextEntry(&h)) + } + + channel := &OpenChannel{ + ChanType: SingleFunderTweaklessBit | AnchorOutputsBit | + IndependentSecretsBit, + RevocationStore: store, + } + + // The store holds secrets 0..2, so it can revoke commitment 2. + channel.RemoteCommitment.CommitHeight = 2 + secret, err := channel.revokedCommitSecret() + require.NoError(t, err) + require.Equal(t, fn.Some(newest), secret) + + // Commitment 1's secret is in the store too, but it's not the newest: + // the store and the commitment chain are out of step. + channel.RemoteCommitment.CommitHeight = 1 + _, err = channel.revokedCommitSecret() + require.ErrorContains(t, err, "want the newest") + + // Channels without the add-on don't record a secret at all. + channel.ChanType &^= IndependentSecretsBit + channel.RevocationStore = shachain.NewRevocationStore() + secret, err = channel.revokedCommitSecret() + require.NoError(t, err) + require.True(t, secret.IsNone()) +} diff --git a/channeldb/revocation_log.go b/channeldb/revocation_log.go index 5a7f7a76bea..ef68d6953fa 100644 --- a/channeldb/revocation_log.go +++ b/channeldb/revocation_log.go @@ -275,6 +275,16 @@ type RevocationLog struct { // at channel funding time, and after wards is to be considered // immutable. CustomBlob tlv.OptionalRecordT[tlv.TlvType5, tlv.Blob] + + // CommitSecret is the remote party's per-commitment secret that + // revoked this commitment. It's only stored for channels that + // negotiated option_independent_secrets, where the secrets can't be + // re-derived from a shachain; on other channels the shachain + // revocation store serves it instead. + // + // The type is taken from the high range so it can't collide with a + // field upstream lnd adds to the revocation log later. + CommitSecret tlv.OptionalRecordT[tlv.TlvType65537, [32]byte] } // NewRevocationLog creates a new RevocationLog from the given parameters. @@ -322,6 +332,19 @@ func NewRevocationLog(ourOutputIndex uint16, theirOutputIndex uint16, func putRevocationLog(bucket kvdb.RwBucket, commit *ChannelCommitment, ourOutputIndex, theirOutputIndex uint32, noAmtData bool) error { + return putRevocationLogWithSecret( + bucket, commit, ourOutputIndex, theirOutputIndex, noAmtData, + fn.None[[32]byte](), + ) +} + +// putRevocationLogWithSecret is putRevocationLog that also records the +// per-commitment secret that revoked the commitment, when one is given. This +// is used on channels with option_independent_secrets. +func putRevocationLogWithSecret(bucket kvdb.RwBucket, + commit *ChannelCommitment, ourOutputIndex, theirOutputIndex uint32, + noAmtData bool, commitSecret fn.Option[[32]byte]) error { + // Sanity check that the output indexes can be safely converted. if ourOutputIndex > math.MaxUint16 { return ErrOutputIndexTooBig @@ -349,6 +372,12 @@ func putRevocationLog(bucket kvdb.RwBucket, commit *ChannelCommitment, ) }) + commitSecret.WhenSome(func(secret [32]byte) { + rl.CommitSecret = tlv.SomeRecordT( + tlv.NewPrimitiveRecord[tlv.TlvType65537](secret), + ) + }) + if !noAmtData { rl.OurBalance = tlv.SomeRecordT(tlv.NewRecordT[tlv.TlvType3]( tlv.NewBigSizeT(commit.LocalBalance), @@ -431,6 +460,12 @@ func serializeRevocationLog(w io.Writer, rl *RevocationLog) error { records = append(records, r.Record()) }) + rl.CommitSecret.WhenSome( + func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { + records = append(records, r.Record()) + }, + ) + // Create the tlv stream. tlvStream, err := tlv.NewStream(records...) if err != nil { @@ -472,6 +507,7 @@ func deserializeRevocationLog(r io.Reader) (RevocationLog, error) { ourBalance := rl.OurBalance.Zero() theirBalance := rl.TheirBalance.Zero() customBlob := rl.CustomBlob.Zero() + commitSecret := rl.CommitSecret.Zero() // Create the tlv stream. tlvStream, err := tlv.NewStream( @@ -481,6 +517,7 @@ func deserializeRevocationLog(r io.Reader) (RevocationLog, error) { ourBalance.Record(), theirBalance.Record(), customBlob.Record(), + commitSecret.Record(), ) if err != nil { return rl, err @@ -504,6 +541,10 @@ func deserializeRevocationLog(r io.Reader) (RevocationLog, error) { rl.CustomBlob = tlv.SomeRecordT(customBlob) } + if t, ok := parsedTypes[commitSecret.TlvType()]; ok && t == nil { + rl.CommitSecret = tlv.SomeRecordT(commitSecret) + } + // Read the HTLC entries. rl.HTLCEntries, err = deserializeHTLCEntries(r) From f784b5fe9b8160bd0b2e9085560e034b603a6f77 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:41 +0200 Subject: [PATCH 04/24] lnwallet: accept and punish with independent secrets ReceiveRevocation checks that a secret generates the expected point before storing it, since on these channels that is the only check a secret gets, and also requires it to be a valid private key as sent. NewBreachRetribution reads the secret from the revocation log on these channels and from the shachain store otherwise. Reservations take an IndependentSecrets flag that sets the channel type bit and the store. --- lnwallet/channel.go | 49 ++++- lnwallet/independent_secrets_test.go | 306 +++++++++++++++++++++++++++ lnwallet/reservation.go | 4 + lnwallet/test_utils.go | 10 +- lnwallet/wallet.go | 14 +- 5 files changed, 376 insertions(+), 7 deletions(-) create mode 100644 lnwallet/independent_secrets_test.go diff --git a/lnwallet/channel.go b/lnwallet/channel.go index 25aba3b7d93..be31b861416 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2130,6 +2130,35 @@ type BreachRetribution struct { ChanType channeldb.ChannelType } +// revokedCommitSecret returns the remote party's per-commitment secret for the +// revoked commitment at stateNum. On channels with option_independent_secrets +// the secret is stored with the commitment's revocation log entry; otherwise +// it's derived from the shachain revocation store. +func revokedCommitSecret(chanState *channeldb.OpenChannel, + revokedLog *channeldb.RevocationLog, + stateNum uint64) (*chainhash.Hash, error) { + + if revokedLog != nil { + var secret *chainhash.Hash + revokedLog.CommitSecret.WhenSome( + func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { + h := chainhash.Hash(r.Val) + secret = &h + }, + ) + if secret != nil { + return secret, nil + } + } + + if chanState.UsesIndependentSecrets() { + return nil, fmt.Errorf("revocation log for state %d has no "+ + "per-commitment secret", stateNum) + } + + return chanState.RevocationStore.LookUp(stateNum) +} + // NewBreachRetribution creates a new fully populated BreachRetribution for the // passed channel, at a particular revoked state number. If the spend // transaction that the breach retribution should target is known, then it can @@ -2217,7 +2246,9 @@ func newBreachRetribution(chanState *channeldb.OpenChannel, stateNum uint64, // With the state number broadcast known, we can now derive/restore the // proper revocation preimage necessary to sweep the remote party's // output. - revocationPreimage, err := chanState.RevocationStore.LookUp(stateNum) + revocationPreimage, err := revokedCommitSecret( + chanState, revokedLog, stateNum, + ) if err != nil { return nil, err } @@ -6151,13 +6182,27 @@ func (lc *LightningChannel) ReceiveRevocation(revMsg *lnwire.RevokeAndAck) ( // revealed secret to derive a revocation key with our revocation base // point, then it matches the current revocation of the remote party. // We check this before adding the secret to the store, so a revocation - // for the wrong point leaves our state untouched. + // for the wrong point leaves our state untouched. On a channel with + // option_independent_secrets it's the only check a secret gets, since + // the store doesn't relate it to the earlier ones. currentCommitPoint := lc.channelState.RemoteCurrentRevocation derivedCommitPoint := input.ComputeCommitmentPoint(revMsg.Revocation[:]) if !derivedCommitPoint.IsEqual(currentCommitPoint) { return nil, nil, fmt.Errorf("revocation key mismatch") } + // On such a channel, also insist that the secret is a valid private + // key as it was sent, 0 < s < n, rather than one that only matches + // the point once reduced modulo n. + if lc.channelState.UsesIndependentSecrets() { + var s btcec.ModNScalar + overflow := s.SetByteSlice(revMsg.Revocation[:]) + if overflow || s.IsZero() { + return nil, nil, fmt.Errorf("per-commitment secret " + + "is not a valid private key") + } + } + // Ensure that the new pre-image can be placed in preimage store. store := lc.channelState.RevocationStore revocation, err := chainhash.NewHash(revMsg.Revocation[:]) diff --git a/lnwallet/independent_secrets_test.go b/lnwallet/independent_secrets_test.go new file mode 100644 index 00000000000..38e601fd3b6 --- /dev/null +++ b/lnwallet/independent_secrets_test.go @@ -0,0 +1,306 @@ +package lnwallet + +import ( + "crypto/rand" + "io" + "math/big" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/shachain" + "github.com/stretchr/testify/require" +) + +// randomSecretProducer is a shachain.Producer for tests that behaves like a +// peer generating its per-commitment secrets independently, for example +// jointly across k-of-n signers. It returns the secrets of an existing +// producer for the first two indexes, whose points were already exchanged when +// the test channel was created, and fresh random secrets after that. +type randomSecretProducer struct { + base shachain.Producer + secrets map[uint64]chainhash.Hash +} + +func newRandomSecretProducer(base shachain.Producer) *randomSecretProducer { + return &randomSecretProducer{ + base: base, + secrets: make(map[uint64]chainhash.Hash), + } +} + +// AtIndex returns the secret at index i. +func (p *randomSecretProducer) AtIndex(i uint64) (*chainhash.Hash, error) { + if i < 2 { + return p.base.AtIndex(i) + } + + if h, ok := p.secrets[i]; ok { + return &h, nil + } + + var h chainhash.Hash + if _, err := rand.Read(h[:]); err != nil { + return nil, err + } + p.secrets[i] = h + + return &h, nil +} + +// Encode writes the base producer, so the channel state can be persisted. +func (p *randomSecretProducer) Encode(w io.Writer) error { + return p.base.Encode(w) +} + +var _ shachain.Producer = (*randomSecretProducer)(nil) + +// unreducedProducer is like randomSecretProducer, but from index 2 on it +// returns secrets of the form s + n, where n is the order of secp256k1. Such a +// secret isn't a valid private key, yet it gives the same point as s once +// reduced modulo n. +type unreducedProducer struct { + *randomSecretProducer +} + +// AtIndex returns the secret at index i. +func (p *unreducedProducer) AtIndex(i uint64) (*chainhash.Hash, error) { + if i < 2 { + return p.base.AtIndex(i) + } + + if h, ok := p.secrets[i]; ok { + return &h, nil + } + + // A 120-bit s keeps s + n below 2^256. + var small [15]byte + if _, err := rand.Read(small[:]); err != nil { + return nil, err + } + v := new(big.Int).SetBytes(small[:]) + v.Add(v, btcec.S256().N) + + var h chainhash.Hash + v.FillBytes(h[:]) + p.secrets[i] = h + + return &h, nil +} + +// independentSecretsChanType is the commitment type the tests below use, an +// anchors channel, with or without option_independent_secrets. +func independentSecretsChanType(independent bool) channeldb.ChannelType { + chanType := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit + if independent { + chanType |= channeldb.IndependentSecretsBit + } + + return chanType +} + +// sendAndSettle has Alice send Bob an HTLC, locks it in, then has Bob settle +// it and locks the settle in. Each call makes both parties revoke two +// commitments. +func sendAndSettle(t *testing.T, alice, bob *LightningChannel, id int) error { + t.Helper() + + htlc, preimage := createHTLC(id, lnwire.NewMSatFromSatoshis( + btcutil.Amount(10_000+id), + )) + + if _, err := alice.AddHTLC(htlc, nil); err != nil { + return err + } + if _, err := bob.ReceiveHTLC(htlc); err != nil { + return err + } + if err := ForceStateTransition(alice, bob); err != nil { + return err + } + + err := bob.SettleHTLC(preimage, uint64(id), nil, nil, nil) + if err != nil { + return err + } + err = alice.ReceiveHTLCSettle(preimage, uint64(id)) + if err != nil { + return err + } + + return ForceStateTransition(bob, alice) +} + +// TestIndependentSecretsAcceptsUnrelatedSecrets shows that on a channel with +// option_independent_secrets a node accepts per-commitment secrets that aren't +// shachain-derived, persists them, and can still build a breach retribution +// for every revoked state, before and after a restart. +func TestIndependentSecretsAcceptsUnrelatedSecrets(t *testing.T) { + t.Parallel() + + alice, bob, err := CreateTestChannels( + t, independentSecretsChanType(true), + ) + require.NoError(t, err) + + // Alice keeps Bob's secrets in an IndependentStore. + _, ok := alice.channelState.RevocationStore.(*shachain.IndependentStore) + require.True(t, ok, "expected an IndependentStore") + + // From here on Bob's secrets are random. + producer := newRandomSecretProducer(bob.channelState.RevocationProducer) + bob.channelState.RevocationProducer = producer + + const rounds = 6 + for i := 0; i < rounds; i++ { + require.NoError(t, sendAndSettle(t, alice, bob, i)) + } + + // Bob really did reveal random secrets. + require.GreaterOrEqual(t, len(producer.secrets), 2*rounds-2) + + // Every revoked commitment of Bob's can be punished, using the secret + // Bob revealed for it. + assertRetributions := func(alice *LightningChannel) { + t.Helper() + + tip := alice.channelState.RemoteCommitment.CommitHeight + require.Greater(t, tip, uint64(4)) + + for h := uint64(0); h < tip; h++ { + br, err := NewBreachRetribution( + alice.channelState, h, 101, nil, + fn.Some[AuxLeafStore](&MockAuxLeafStore{}), + fn.Some[AuxContractResolver]( + &MockAuxContractResolver{}, + ), + ) + require.NoError(t, err, "state %d", h) + require.Equal(t, h, br.RevokedStateNum) + + secret, err := producer.AtIndex(h) + require.NoError(t, err) + point := input.ComputeCommitmentPoint(secret[:]) + require.True( + t, point.IsEqual(br.KeyRing.CommitPoint), + "state %d: wrong commitment point", h, + ) + } + } + assertRetributions(alice) + + // The secrets live in the database, not in memory: after a restart + // Alice still syncs with Bob and can still punish every old state. + alice, err = restartChannel(alice) + require.NoError(t, err) + + _, ok = alice.channelState.RevocationStore.(*shachain.IndependentStore) + require.True(t, ok, "expected an IndependentStore after restart") + + assertNoChanSyncNeeded(t, alice, bob) + assertRetributions(alice) + + // And the channel keeps working. + require.NoError(t, sendAndSettle(t, alice, bob, rounds)) + assertRetributions(alice) +} + +// TestShachainChannelRejectsUnrelatedSecrets is the control: on a channel +// without option_independent_secrets, the same peer behaviour is refused by +// the shachain revocation store, which is why the channel type is needed. +func TestShachainChannelRejectsUnrelatedSecrets(t *testing.T) { + t.Parallel() + + alice, bob, err := CreateTestChannels( + t, independentSecretsChanType(false), + ) + require.NoError(t, err) + + _, ok := alice.channelState.RevocationStore.(*shachain.RevocationStore) + require.True(t, ok, "expected a shachain RevocationStore") + + bob.channelState.RevocationProducer = newRandomSecretProducer( + bob.channelState.RevocationProducer, + ) + + var lastErr error + for i := 0; i < 6 && lastErr == nil; i++ { + lastErr = sendAndSettle(t, alice, bob, i) + } + require.ErrorContains(t, lastErr, "hash isn't derivable") +} + +// TestIndependentSecretsRejectsUnreducedSecret checks that on a channel with +// option_independent_secrets a secret must be a valid private key as sent, +// even if it gives the expected point once reduced modulo n. +func TestIndependentSecretsRejectsUnreducedSecret(t *testing.T) { + t.Parallel() + + alice, bob, err := CreateTestChannels( + t, independentSecretsChanType(true), + ) + require.NoError(t, err) + + bob.channelState.RevocationProducer = &unreducedProducer{ + newRandomSecretProducer(bob.channelState.RevocationProducer), + } + + var lastErr error + for i := 0; i < 4 && lastErr == nil; i++ { + lastErr = sendAndSettle(t, alice, bob, i) + } + require.ErrorContains(t, lastErr, "not a valid private key") +} + +// TestIndependentSecretsChanSyncMidRevocation checks that a node can still +// build a channel_reestablish after its revocation store has taken a new +// secret but before the remote commitment has been advanced, which is the +// window inside ReceiveRevocation. channel_reestablish then names the secret +// one before the newest. +func TestIndependentSecretsChanSyncMidRevocation(t *testing.T) { + t.Parallel() + + alice, bob, err := CreateTestChannels( + t, independentSecretsChanType(true), + ) + require.NoError(t, err) + + producer := newRandomSecretProducer(bob.channelState.RevocationProducer) + bob.channelState.RevocationProducer = producer + + for i := 0; i < 3; i++ { + require.NoError(t, sendAndSettle(t, alice, bob, i)) + } + + tip := alice.channelState.RemoteCommitment.CommitHeight + want, err := producer.AtIndex(tip - 1) + require.NoError(t, err) + + // The store takes the secret for the current remote commitment, as it + // would first thing on receiving Bob's next revoke_and_ack. + next, err := producer.AtIndex(tip) + require.NoError(t, err) + store := alice.channelState.RevocationStore + require.NoError(t, store.AddNextEntry(next)) + + msg, err := alice.channelState.ChanSyncMsg() + require.NoError(t, err) + require.Equal(t, [32]byte(*want), msg.LastRemoteCommitSecret) + + // If the store runs further ahead than the two secrets it keeps in + // memory, as a copy of the channel refreshed in two steps can see, + // the secret comes from the revocation log instead. + afterNext, err := producer.AtIndex(tip + 1) + require.NoError(t, err) + require.NoError(t, store.AddNextEntry(afterNext)) + + msg, err = alice.channelState.ChanSyncMsg() + require.NoError(t, err) + require.Equal(t, [32]byte(*want), msg.LastRemoteCommitSecret) +} diff --git a/lnwallet/reservation.go b/lnwallet/reservation.go index 1ed2da3acbe..c646cae54c6 100644 --- a/lnwallet/reservation.go +++ b/lnwallet/reservation.go @@ -477,6 +477,10 @@ func NewChannelReservation(capacity, localFundingAmt btcutil.Amount, chanType |= channeldb.UnifiedSigsBit } + if req.IndependentSecrets { + chanType |= channeldb.IndependentSecretsBit + } + taprootOverlay := req.CommitType == CommitmentTypeSimpleTaprootOverlay switch { case taprootOverlay && req.TapscriptRoot.IsNone(): diff --git a/lnwallet/test_utils.go b/lnwallet/test_utils.go index 738558e224f..257067025a6 100644 --- a/lnwallet/test_utils.go +++ b/lnwallet/test_utils.go @@ -308,6 +308,12 @@ func CreateTestChannels(t *testing.T, chanType channeldb.ChannelType, binary.BigEndian.Uint64(chanIDBytes[:]), ) + // Each side stores the other's per-commitment secrets in the store + // the channel type calls for. + remoteSecretStore := func() shachain.Store { + return channeldb.NewRemoteSecretStore(chanType) + } + aliceChannelState := &channeldb.OpenChannel{ LocalChanCfg: aliceCfg, RemoteChanCfg: bobCfg, @@ -319,7 +325,7 @@ func CreateTestChannels(t *testing.T, chanType channeldb.ChannelType, Capacity: channelCapacity, RemoteCurrentRevocation: bobCommitPoint, RevocationProducer: alicePreimageProducer, - RevocationStore: shachain.NewRevocationStore(), + RevocationStore: remoteSecretStore(), LocalCommitment: aliceLocalCommit, RemoteCommitment: aliceRemoteCommit, Db: dbAlice.ChannelStateDB(), @@ -337,7 +343,7 @@ func CreateTestChannels(t *testing.T, chanType channeldb.ChannelType, Capacity: channelCapacity, RemoteCurrentRevocation: aliceCommitPoint, RevocationProducer: bobPreimageProducer, - RevocationStore: shachain.NewRevocationStore(), + RevocationStore: remoteSecretStore(), LocalCommitment: bobLocalCommit, RemoteCommitment: bobRemoteCommit, Db: dbBob.ChannelStateDB(), diff --git a/lnwallet/wallet.go b/lnwallet/wallet.go index 1c5e8d59067..a79c793a9a8 100644 --- a/lnwallet/wallet.go +++ b/lnwallet/wallet.go @@ -31,7 +31,6 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chanfunding" "github.com/lightningnetwork/lnd/lnwallet/chanvalidate" "github.com/lightningnetwork/lnd/lnwire" - "github.com/lightningnetwork/lnd/shachain" "github.com/lightningnetwork/lnd/tlv" ) @@ -239,6 +238,11 @@ type InitFundingReserveMsg struct { // the unified signature hash, binding them to the BLAKE2b chain. UnifiedSigs bool + // IndependentSecrets is true if option_independent_secrets was + // negotiated: the remote party's per-commitment secrets are stored + // individually instead of being required to form a shachain. + IndependentSecrets bool + // Memo is any arbitrary information we wish to store locally about the // channel that will be useful to our future selves. Memo []byte @@ -1972,7 +1976,9 @@ func (l *LightningWallet) handleChanPointReady(req *continueContributionMsg) { // Initialize an empty sha-chain for them, tracking the current pending // revocation hash (we don't yet know the preimage so we can't add it // to the chain). - s := shachain.NewRevocationStore() + s := channeldb.NewRemoteSecretStore( + pendingReservation.partialState.ChanType, + ) pendingReservation.partialState.RevocationStore = s // Store their current commitment point. We'll need this after the @@ -2138,7 +2144,9 @@ func (l *LightningWallet) handleSingleContribution(req *addSingleContributionMsg // Initialize an empty sha-chain for them, tracking the current pending // revocation hash (we don't yet know the preimage so we can't add it // to the chain). - remotePreimageStore := shachain.NewRevocationStore() + remotePreimageStore := channeldb.NewRemoteSecretStore( + chanState.ChanType, + ) chanState.RevocationStore = remotePreimageStore // Now that we've received their first commitment point, we'll store it From d34a1d648513caacd999d29aafbcf545db741bd4 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:41 +0200 Subject: [PATCH 05/24] contractcourt: test breach detection with independent secrets The chain watcher's copy of the channel is loaded before the channel advances, as the chain arbitrator's is, so its in-memory store is stale and the retribution must come from the revocation log. --- contractcourt/independent_secrets_test.go | 175 ++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 contractcourt/independent_secrets_test.go diff --git a/contractcourt/independent_secrets_test.go b/contractcourt/independent_secrets_test.go new file mode 100644 index 00000000000..cdba5e349db --- /dev/null +++ b/contractcourt/independent_secrets_test.go @@ -0,0 +1,175 @@ +package contractcourt + +import ( + "crypto/rand" + "io" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/chainio" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + lnmock "github.com/lightningnetwork/lnd/lntest/mock" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/shachain" + "github.com/stretchr/testify/require" +) + +// randomSecretProducer is a shachain.Producer that behaves like a peer +// generating its per-commitment secrets independently. It returns the base +// producer's secrets for the first two indexes, whose points were exchanged +// when the test channel was created, and random secrets after that. +type randomSecretProducer struct { + base shachain.Producer + secrets map[uint64]chainhash.Hash +} + +// AtIndex returns the secret at index i. +func (p *randomSecretProducer) AtIndex(i uint64) (*chainhash.Hash, error) { + if i < 2 { + return p.base.AtIndex(i) + } + + if h, ok := p.secrets[i]; ok { + return &h, nil + } + + var h chainhash.Hash + if _, err := rand.Read(h[:]); err != nil { + return nil, err + } + p.secrets[i] = h + + return &h, nil +} + +// Encode writes the base producer. +func (p *randomSecretProducer) Encode(w io.Writer) error { + return p.base.Encode(w) +} + +// TestChainWatcherBreachIndependentSecrets checks that the chain watcher +// detects and builds a retribution for the broadcast of a revoked commitment +// on a channel with option_independent_secrets, whose remote secrets have no +// shachain relationship. The watcher's copy of the channel is loaded from the +// database before the channel is updated, as the chain arbitrator's is, so its +// in-memory revocation store is stale and the secret has to come from the +// revocation log. +func TestChainWatcherBreachIndependentSecrets(t *testing.T) { + t.Parallel() + + chanType := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit | + channeldb.IndependentSecretsBit + + aliceChannel, bobChannel, err := lnwallet.CreateTestChannels( + t, chanType, + ) + require.NoError(t, err) + + producer := &randomSecretProducer{ + base: bobChannel.State().RevocationProducer, + secrets: make(map[uint64]chainhash.Hash), + } + bobChannel.State().RevocationProducer = producer + + watcherState, err := aliceChannel.State().Db.FetchChannel( + aliceChannel.State().FundingOutpoint, + ) + require.NoError(t, err) + + // Advance the channel a few states, keeping each of Bob's commitments + // before it's revoked. + var revoked []*wire.MsgTx + for i := 0; i < 5; i++ { + revoked = append( + revoked, bobChannel.State().LocalCommitment.CommitTx, + ) + addFakeHTLC(t, 10_000, uint64(i), aliceChannel, bobChannel) + require.NoError(t, lnwallet.ForceStateTransition( + aliceChannel, bobChannel, + )) + } + + confRegistered := make(chan struct{}, 1) + aliceNotifier := &lnmock.ChainNotifier{ + SpendChan: make(chan *chainntnfs.SpendDetail, 1), + EpochChan: make(chan *chainntnfs.BlockEpoch), + ConfChan: make(chan *chainntnfs.TxConfirmation, 1), + ConfRegistered: confRegistered, + } + retributions := make(chan *lnwallet.BreachRetribution, 1) + aliceChainWatcher, err := newChainWatcher(chainWatcherConfig{ + chanState: watcherState, + notifier: aliceNotifier, + signer: aliceChannel.Signer, + extractStateNumHint: lnwallet.GetStateNumHint, + chanCloseConfs: fn.Some(uint32(1)), + contractBreach: func(r *lnwallet.BreachRetribution) error { + retributions <- r + return nil + }, + auxLeafStore: fn.Some[lnwallet.AuxLeafStore]( + &lnwallet.MockAuxLeafStore{}, + ), + auxResolver: fn.Some[lnwallet.AuxContractResolver]( + &lnwallet.MockAuxContractResolver{}, + ), + }) + require.NoError(t, err) + require.NoError(t, aliceChainWatcher.Start()) + defer aliceChainWatcher.Stop() + + mockBeat := &chainio.MockBlockbeat{} + mockBeat.On("logger").Return(log) + mockBeat.On("Height").Return(int32(1)).Maybe() + mockBeat.On("NotifyBlockProcessed", + nil, aliceChainWatcher.quit).Return().Maybe() + + chanEvents := aliceChainWatcher.SubscribeChannelEvents() + + // Bob broadcasts his revoked commitment for state 2, whose secret was + // random. + const breachedState = 2 + breachTx := revoked[breachedState] + breachTxHash := breachTx.TxHash() + select { + case aliceNotifier.SpendChan <- &chainntnfs.SpendDetail{ + SpenderTxHash: &breachTxHash, + SpendingTx: breachTx, + SpendingHeight: 100, + }: + case <-time.After(time.Second): + t.Fatalf("unable to send spend details") + } + + select { + case aliceChainWatcher.BlockbeatChan <- mockBeat: + case <-time.After(time.Second): + t.Fatalf("unable to send blockbeat") + } + + var retribution *lnwallet.BreachRetribution + select { + case retribution = <-retributions: + case <-time.After(15 * time.Second): + t.Fatalf("breach not detected") + } + require.EqualValues(t, breachedState, retribution.RevokedStateNum) + + // The retribution uses the secret Bob revealed for that state. + secret, err := producer.AtIndex(breachedState) + require.NoError(t, err) + point := input.ComputeCommitmentPoint(secret[:]) + require.True(t, point.IsEqual(retribution.KeyRing.CommitPoint)) + + select { + case <-chanEvents.ContractBreach: + case <-time.After(15 * time.Second): + t.Fatalf("didn't receive contract breach event") + } +} From 1aa3e3f34a7c0082e5c40506bfadc81edc766a84 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:41 +0200 Subject: [PATCH 06/24] lnwire+feature: add option_independent_secrets feature bits Bits 266/267, proposed in a draft bLIP. The add-on is only ever agreed through channel_type, so it depends on explicit channel type negotiation. --- feature/deps.go | 5 +++++ lnwire/features.go | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/feature/deps.go b/feature/deps.go index b473e974a9e..0f0911402d1 100644 --- a/feature/deps.go +++ b/feature/deps.go @@ -101,6 +101,11 @@ var deps = depDesc{ lnwire.UnifiedSigsOptional: { lnwire.Blake2bOptional: {}, }, + // option_independent_secrets only ever applies through channel_type, + // so it depends on explicit channel type negotiation. + lnwire.IndependentSecretsOptional: { + lnwire.ExplicitChannelTypeOptional: {}, + }, } // ValidateDeps asserts that a feature vector sets all features and their diff --git a/lnwire/features.go b/lnwire/features.go index d4b3d967824..b2e045571b2 100644 --- a/lnwire/features.go +++ b/lnwire/features.go @@ -337,6 +337,20 @@ const ( // ordinary channel rather than fail to open one at all. UnifiedSigsOptional FeatureBit = 515 + // IndependentSecretsRequired is a required feature bit that indicates + // the node can negotiate a channel with option_independent_secrets: + // it stores every per-commitment secret it receives instead of + // requiring the remote party to derive them from a shachain, so the + // remote party may generate them by any method, including jointly + // across k-of-n signers. + // + // Proposed as 266/267 in a draft bLIP. + IndependentSecretsRequired FeatureBit = 266 + + // IndependentSecretsOptional is the optional form of + // IndependentSecretsRequired. + IndependentSecretsOptional FeatureBit = 267 + // ExperimentalAccountabilityRequired is a required feature bit that // indicates that the node will relay experimental accountability // signals. @@ -447,6 +461,8 @@ var Features = map[FeatureBit]string{ Blake2bOptional: "blake2b", UnifiedSigsRequired: "unified-sigs", UnifiedSigsOptional: "unified-sigs", + IndependentSecretsRequired: "independent-secrets", + IndependentSecretsOptional: "independent-secrets", ExperimentalAccountabilityRequired: "accountable-x", ExperimentalAccountabilityOptional: "accountable-x", Bolt11BlindedPathsOptional: "bolt-11-blinded-paths", From 989530167471c479ad9142b8d7b85fbda33a3033 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:41 +0200 Subject: [PATCH 07/24] funding: negotiate option_independent_secrets as a channel type add-on Mirrors option_unified_sigs: proposed whenever both peers signal it, stripped before the commitment type is matched, kept on the persisted type, refused explicitly by a peer without it, refused if a peer echoes it unproposed, and allowed on taproot channels. The RPC acceptor strips the required bit like the other add-ons. --- chanacceptor/rpcacceptor.go | 4 + .../rpcacceptor_independent_secrets_test.go | 34 ++ funding/commitment_type_negotiation.go | 57 +++- .../independent_secrets_negotiation_test.go | 311 ++++++++++++++++++ funding/manager.go | 8 + 5 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 chanacceptor/rpcacceptor_independent_secrets_test.go create mode 100644 funding/independent_secrets_negotiation_test.go diff --git a/chanacceptor/rpcacceptor.go b/chanacceptor/rpcacceptor.go index 09163afba95..dee9dee2d9f 100644 --- a/chanacceptor/rpcacceptor.go +++ b/chanacceptor/rpcacceptor.go @@ -457,6 +457,10 @@ func acceptorCommitmentType( shape := channelFeatures.Clone() shape.Unset(lnwire.UnifiedSigsRequired) + // option_independent_secrets changes how per-commitment secrets are + // stored, not the shape of the commitment, so it's set aside too. + shape.Unset(lnwire.IndependentSecretsRequired) + switch { case shape.OnlyContains( lnwire.ZeroConfRequired, diff --git a/chanacceptor/rpcacceptor_independent_secrets_test.go b/chanacceptor/rpcacceptor_independent_secrets_test.go new file mode 100644 index 00000000000..0f7692b68a0 --- /dev/null +++ b/chanacceptor/rpcacceptor_independent_secrets_test.go @@ -0,0 +1,34 @@ +package chanacceptor + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestAcceptorCommitmentTypeIgnoresIndependentSecrets checks that a channel +// carrying option_independent_secrets, alone or with option_unified_sigs, is +// reported by its commitment type, as the same channel without it is. +func TestAcceptorCommitmentTypeIgnoresIndependentSecrets(t *testing.T) { + t.Parallel() + + base := []lnwire.FeatureBit{ + lnwire.StaticRemoteKeyRequired, + lnwire.AnchorsZeroFeeHtlcTxRequired, + } + + for _, extra := range [][]lnwire.FeatureBit{ + {lnwire.IndependentSecretsRequired}, + {lnwire.IndependentSecretsRequired, lnwire.UnifiedSigsRequired}, + } { + bits := append(append([]lnwire.FeatureBit{}, base...), extra...) + vec := lnwire.NewRawFeatureVector(bits...) + require.Equal(t, lnrpc.CommitmentType_ANCHORS, + acceptorCommitmentType(vec), "%v", bits) + + // The caller's vector is not changed. + require.True(t, vec.IsSet(lnwire.IndependentSecretsRequired)) + } +} diff --git a/funding/commitment_type_negotiation.go b/funding/commitment_type_negotiation.go index c159898add9..792e950de7c 100644 --- a/funding/commitment_type_negotiation.go +++ b/funding/commitment_type_negotiation.go @@ -105,6 +105,37 @@ func withUnifiedSigs(chanType lnwire.ChannelType, local, return lnwire.ChannelType(*withBit) } +// withIndependentSecrets returns chanType with option_independent_secrets set, +// when both peers signal it. +// +// Like option_unified_sigs, the bit is not a commitment shape: it changes how +// each side stores the per-commitment secrets it receives, so it applies to +// every commitment type, taproot included. +func withIndependentSecrets(chanType lnwire.ChannelType, local, + remote *lnwire.FeatureVector) lnwire.ChannelType { + + if !hasFeatures(local, remote, lnwire.IndependentSecretsOptional) { + return chanType + } + + features := lnwire.RawFeatureVector(chanType) + withBit := features.Clone() + withBit.Set(lnwire.IndependentSecretsRequired) + + return lnwire.ChannelType(*withBit) +} + +// withAddOns adds to chanType every add-on bit that both peers support and +// that isn't a commitment shape: option_unified_sigs and +// option_independent_secrets. +func withAddOns(chanType lnwire.ChannelType, local, + remote *lnwire.FeatureVector) lnwire.ChannelType { + + return withIndependentSecrets( + withUnifiedSigs(chanType, local, remote), local, remote, + ) +} + // isTaprootType reports whether a channel type is one whose commitment is // signed with MuSig2, which the unified bit cannot yet be combined with: the // simple taproot types and the taproot overlay type that Taproot Assets @@ -116,8 +147,8 @@ func isTaprootType(features lnwire.RawFeatureVector) bool { } // funderChannelType is the channel type a funder proposes when its caller -// named one: the named type, with the unified bit added when both sides -// support it. +// named one: the named type, with the unified and independent-secrets bits +// added when both sides support them. // // Only a funder does this. A fundee answers the type the funder proposed as // proposed, or refuses it, since a funder that is sent back a type it did not @@ -129,7 +160,7 @@ func funderChannelType(desired *lnwire.ChannelType, local, return nil } - augmented := withUnifiedSigs(*desired, local, remote) + augmented := withAddOns(*desired, local, remote) return &augmented } @@ -206,6 +237,22 @@ func explicitNegotiateCommitmentType(channelType lnwire.ChannelType, local, channelFeatures = *stripped } + // option_independent_secrets is orthogonal to the commitment type in + // the same way: it changes how per-commitment secrets are stored, not + // the transactions. Check both sides support it, then take it off. + if channelFeatures.IsSet(lnwire.IndependentSecretsRequired) { + if !hasFeatures( + local, remote, lnwire.IndependentSecretsOptional, + ) { + + return 0, errUnsupportedChannelType + } + + stripped := channelFeatures.Clone() + stripped.Unset(lnwire.IndependentSecretsRequired) + channelFeatures = *stripped + } + switch { // Lease script enforcement + anchors zero fee + static remote key + // zero conf + scid alias features only. @@ -598,7 +645,7 @@ func selectDefaultChannelType(local, // If both peers are signalling support for anchor commitments with // zero-fee HTLC transactions, we'll use this type. if hasFeatures(local, remote, lnwire.AnchorsZeroFeeHtlcTxOptional) { - chanType := withUnifiedSigs( + chanType := withAddOns( lnwire.ChannelType(*lnwire.NewRawFeatureVector( lnwire.AnchorsZeroFeeHtlcTxRequired, lnwire.StaticRemoteKeyRequired, @@ -616,7 +663,7 @@ func selectDefaultChannelType(local, // If both nodes are signaling the proper feature bit for tweakless // commitments, we'll use that. if hasFeatures(local, remote, lnwire.StaticRemoteKeyOptional) { - chanType := withUnifiedSigs( + chanType := withAddOns( lnwire.ChannelType(*lnwire.NewRawFeatureVector( lnwire.StaticRemoteKeyRequired, )), local, remote, diff --git a/funding/independent_secrets_negotiation_test.go b/funding/independent_secrets_negotiation_test.go new file mode 100644 index 00000000000..f453536c3a3 --- /dev/null +++ b/funding/independent_secrets_negotiation_test.go @@ -0,0 +1,311 @@ +package funding + +import ( + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/shachain" + "github.com/stretchr/testify/require" +) + +// TestIndependentSecretsNegotiation covers option_independent_secrets as a +// channel type add-on. Like option_unified_sigs it isn't a commitment shape, +// so it's proposed whenever both peers signal it, taken off before the +// commitment type is decided, and kept on the type that's persisted. +func TestIndependentSecretsNegotiation(t *testing.T) { + t.Parallel() + + features := func(bits ...lnwire.FeatureBit) *lnwire.FeatureVector { + base := []lnwire.FeatureBit{ + lnwire.ExplicitChannelTypeOptional, + lnwire.StaticRemoteKeyOptional, + lnwire.AnchorsZeroFeeHtlcTxOptional, + lnwire.SimpleTaprootChannelsOptionalFinal, + } + + return lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector(append(base, bits...)...), + lnwire.Features, + ) + } + + var ( + both = features( + lnwire.IndependentSecretsOptional, + lnwire.UnifiedSigsOptional, + ) + noIndependent = features(lnwire.UnifiedSigsOptional) + + anchors = lnwire.ChannelType(*lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyRequired, + lnwire.AnchorsZeroFeeHtlcTxRequired, + )) + anchorsIndependent = lnwire.ChannelType( + *lnwire.NewRawFeatureVector( + lnwire.StaticRemoteKeyRequired, + lnwire.AnchorsZeroFeeHtlcTxRequired, + lnwire.IndependentSecretsRequired, + ), + ) + taprootIndependent = lnwire.ChannelType( + *lnwire.NewRawFeatureVector( + lnwire.SimpleTaprootChannelsRequiredFinal, + lnwire.IndependentSecretsRequired, + ), + ) + ) + + isSet := func(chanType *lnwire.ChannelType) bool { + raw := lnwire.RawFeatureVector(*chanType) + return raw.IsSet(lnwire.IndependentSecretsRequired) + } + + t.Run("proposed implicitly when both support it", func(t *testing.T) { + t.Parallel() + + chanType, commitType, err := negotiateCommitmentType( + nil, both, both, + ) + require.NoError(t, err) + require.Equal( + t, lnwallet.CommitmentTypeAnchorsZeroFeeHtlcTx, + commitType, + ) + require.True(t, isSet(chanType)) + + // It rides alongside option_unified_sigs. + raw := lnwire.RawFeatureVector(*chanType) + require.True(t, raw.IsSet(lnwire.UnifiedSigsRequired)) + }) + + t.Run("not proposed to a peer without it", func(t *testing.T) { + t.Parallel() + + chanType, _, err := negotiateCommitmentType( + nil, both, noIndependent, + ) + require.NoError(t, err) + require.False(t, isSet(chanType)) + }) + + t.Run("accepted explicitly when both support it", func(t *testing.T) { + t.Parallel() + + chanType, commitType, err := negotiateCommitmentType( + &anchorsIndependent, both, both, + ) + require.NoError(t, err) + require.Equal( + t, lnwallet.CommitmentTypeAnchorsZeroFeeHtlcTx, + commitType, + ) + require.True(t, isSet(chanType)) + }) + + t.Run("refused explicitly by a peer without it", func(t *testing.T) { + t.Parallel() + + _, _, err := negotiateCommitmentType( + &anchorsIndependent, both, noIndependent, + ) + require.ErrorIs(t, err, errUnsupportedChannelType) + }) + + t.Run("allowed on taproot", func(t *testing.T) { + t.Parallel() + + // Unlike option_unified_sigs, nothing about signing changes, + // so taproot channels can carry it. + chanType, commitType, err := negotiateCommitmentType( + &taprootIndependent, both, both, + ) + require.NoError(t, err) + require.Equal( + t, lnwallet.CommitmentTypeSimpleTaprootFinal, + commitType, + ) + require.True(t, isSet(chanType)) + }) + + t.Run("added to a type the funder names", func(t *testing.T) { + t.Parallel() + + chanType := funderChannelType(&anchors, both, both) + require.True(t, isSet(chanType)) + + chanType = funderChannelType(&anchors, both, noIndependent) + require.False(t, isSet(chanType)) + }) +} + +// TestIndependentSecretsFundingFlow runs the funding flow between two funding +// managers and checks that, when option_independent_secrets is negotiated, +// both sides persist the channel type bit and keep the remote party's secrets +// in an IndependentStore, and that neither does otherwise. +func TestIndependentSecretsFundingFlow(t *testing.T) { + t.Parallel() + + base := []lnwire.FeatureBit{ + lnwire.ExplicitChannelTypeOptional, + lnwire.StaticRemoteKeyOptional, + lnwire.AnchorsZeroFeeHtlcTxOptional, + lnwire.SimpleTaprootChannelsOptionalStaging, + } + with := append( + append([]lnwire.FeatureBit{}, base...), + lnwire.IndependentSecretsOptional, + ) + taproot := lnwire.ChannelType(*lnwire.NewRawFeatureVector( + lnwire.SimpleTaprootChannelsRequiredStaging, + )) + + testCases := []struct { + name string + aliceBits []lnwire.FeatureBit + bobBits []lnwire.FeatureBit + chanType *lnwire.ChannelType + independent bool + }{ + { + name: "both signal it", + aliceBits: with, + bobBits: with, + independent: true, + }, + { + name: "bob doesn't signal it", + aliceBits: with, + bobBits: base, + independent: false, + }, + { + name: "explicit taproot channel", + aliceBits: with, + bobBits: with, + chanType: &taproot, + independent: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + alice, bob := setupFundingManagers(t) + t.Cleanup(func() { + tearDownFundingManagers(t, alice, bob) + }) + + alice.localFeatures = tc.aliceBits + alice.remoteFeatures = tc.bobBits + bob.localFeatures = tc.bobBits + bob.remoteFeatures = tc.aliceBits + + updateChan := make(chan *lnrpc.OpenStatusUpdate) + fundChannel( + t, alice, bob, btcutil.Amount(500000), 0, + false, 0, 0, 1, updateChan, false, + tc.chanType, + ) + + for _, node := range []*testNode{alice, bob} { + db := node.fundingMgr.cfg.Wallet.Cfg.Database + pending, err := db.FetchPendingChannels() + require.NoError(t, err) + require.Len(t, pending, 1) + + channel := pending[0] + store := channel.RevocationStore + _, isIndependent := + store.(*shachain.IndependentStore) + chanType := channel.ChanType + require.Equal( + t, tc.independent, + chanType.HasIndependentSecrets(), + ) + require.Equal(t, tc.independent, isIndependent) + } + }) + } +} + +// TestIndependentSecretsUnproposedEchoRefused checks that a funder that did +// not propose option_independent_secrets refuses an accept_channel whose +// channel_type adds it: the funder would keep requiring shachain secrets while +// the peer believed it could generate them freely. +func TestIndependentSecretsUnproposedEchoRefused(t *testing.T) { + t.Parallel() + + alice, bob := setupFundingManagers(t) + t.Cleanup(func() { + tearDownFundingManagers(t, alice, bob) + }) + + // Alice signals the add-on but Bob doesn't, so Alice proposes a type + // without it. + base := []lnwire.FeatureBit{ + lnwire.ExplicitChannelTypeOptional, + lnwire.StaticRemoteKeyOptional, + lnwire.AnchorsZeroFeeHtlcTxOptional, + } + with := append( + append([]lnwire.FeatureBit{}, base...), + lnwire.IndependentSecretsOptional, + ) + alice.localFeatures, alice.remoteFeatures = with, base + bob.localFeatures, bob.remoteFeatures = base, with + + updateChan := make(chan *lnrpc.OpenStatusUpdate) + errChan := make(chan error, 1) + alice.fundingMgr.InitFundingWorkflow(&InitFundingMsg{ + Peer: bob, + TargetPubkey: bob.privKey.PubKey(), + ChainHash: *fundingNetParams.GenesisHash, + LocalFundingAmt: 500000, + Updates: updateChan, + Err: errChan, + }) + + var openChannel *lnwire.OpenChannel + select { + case msg := <-alice.msgChan: + var ok bool + openChannel, ok = msg.(*lnwire.OpenChannel) + require.True(t, ok, "expected OpenChannel, got %T", msg) + case err := <-errChan: + t.Fatalf("error init funding workflow: %v", err) + case <-time.After(5 * time.Second): + t.Fatalf("alice did not send OpenChannel") + } + require.NotNil(t, openChannel.ChannelType) + proposed := lnwire.RawFeatureVector(*openChannel.ChannelType) + require.False(t, proposed.IsSet(lnwire.IndependentSecretsRequired)) + + bob.fundingMgr.ProcessFundingMsg(openChannel, alice) + accept := assertFundingMsgSent( + t, bob.msgChan, "AcceptChannel", + ).(*lnwire.AcceptChannel) + + // Bob's reply now adds the add-on to the type Alice proposed. + withBit := proposed.Clone() + withBit.Set(lnwire.IndependentSecretsRequired) + chanType := lnwire.ChannelType(*withBit) + accept.ChannelType = &chanType + + alice.fundingMgr.ProcessFundingMsg(accept, bob) + + // Alice fails the flow: Bob gets an error, and the caller gets the + // reason. + assertFundingMsgSent(t, alice.msgChan, "Error") + select { + case err := <-errChan: + require.True(t, strings.Contains( + err.Error(), "channel type mismatch", + ), err.Error()) + case <-time.After(5 * time.Second): + t.Fatalf("funding flow didn't fail") + } +} diff --git a/funding/manager.go b/funding/manager.go index 00cf0593496..2c2f147770e 100644 --- a/funding/manager.go +++ b/funding/manager.go @@ -1700,6 +1700,9 @@ func (f *Manager) fundeeProcessOpenChannel(peer lnpeer.Peer, zeroConf := featureVec.IsSet(lnwire.ZeroConfRequired) scid := featureVec.IsSet(lnwire.ScidAliasRequired) unifiedSigs := featureVec.IsSet(lnwire.UnifiedSigsRequired) + independentSecrets := featureVec.IsSet( + lnwire.IndependentSecretsRequired, + ) // If the zero-conf channel type was negotiated, ensure that the // acceptor allows it. @@ -1794,6 +1797,7 @@ func (f *Manager) fundeeProcessOpenChannel(peer lnpeer.Peer, UnifiedSigs: unifiedSigs, TapscriptRoot: tapscriptRoot, } + req.IndependentSecrets = independentSecrets reservation, err := f.cfg.Wallet.InitChannelReservation(req) if err != nil { @@ -5155,6 +5159,9 @@ func (f *Manager) handleInitFundingMsg(msg *InitFundingMsg) { zeroConf := featureVec.IsSet(lnwire.ZeroConfRequired) scid := featureVec.IsSet(lnwire.ScidAliasRequired) unifiedSigs := featureVec.IsSet(lnwire.UnifiedSigsRequired) + independentSecrets := featureVec.IsSet( + lnwire.IndependentSecretsRequired, + ) // The option-scid-alias channel type for a public channel is disallowed if scid && !msg.Private { @@ -5262,6 +5269,7 @@ func (f *Manager) handleInitFundingMsg(msg *InitFundingMsg) { Memo: msg.Memo, TapscriptRoot: tapscriptRoot, } + req.IndependentSecrets = independentSecrets reservation, err := f.cfg.Wallet.InitChannelReservation(req) if err != nil { From d9f959bc58f02e183c1737674e32d46c184146a8 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 12:48:41 +0200 Subject: [PATCH 08/24] multi: signal option_independent_secrets by default Lightning on BLAKE2b Bitcoin is new, so the optional bit is signalled by default, like the fork's other XBT features, and every new channel with a peer that also signals it uses the add-on. Channels with peers that don't signal it open as before. protocol.no-independent-secrets turns it off. Downgrading to a version without it is not supported once any channel has used it: such a version can't read that channel's stored state, which lnd also keeps after the channel closes. The config help says so. --- feature/default_sets.go | 7 ++++ feature/independent_secrets_test.go | 51 +++++++++++++++++++++++++++++ feature/manager.go | 8 +++++ lncfg/protocol.go | 7 ++++ lncfg/protocol_integration.go | 7 ++++ sample-lnd.conf | 8 +++++ server.go | 3 +- 7 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 feature/independent_secrets_test.go diff --git a/feature/default_sets.go b/feature/default_sets.go index 289266e5e9a..f6143cbf361 100644 --- a/feature/default_sets.go +++ b/feature/default_sets.go @@ -63,6 +63,13 @@ var defaultSetDesc = setDesc{ SetInit: {}, // I SetNodeAnn: {}, // N }, + // Signalled so a peer knows this node can open a channel with + // option_independent_secrets. On by default; the operator can turn it + // off with protocol.no-independent-secrets. + lnwire.IndependentSecretsOptional: { + SetInit: {}, // I + SetNodeAnn: {}, // N + }, lnwire.DataLossProtectRequired: { SetInit: {}, // I SetNodeAnn: {}, // N diff --git a/feature/independent_secrets_test.go b/feature/independent_secrets_test.go new file mode 100644 index 00000000000..3c5dfc28703 --- /dev/null +++ b/feature/independent_secrets_test.go @@ -0,0 +1,51 @@ +package feature + +import ( + "testing" + + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestNoIndependentSecrets checks that option_independent_secrets is +// advertised in init and node_announcement by default, as optional so peers +// without it still open ordinary channels, and not at all when the operator +// turns it off. +func TestNoIndependentSecrets(t *testing.T) { + t.Parallel() + + for _, off := range []bool{false, true} { + m, err := NewManager(Config{NoIndependentSecrets: off}) + require.NoError(t, err) + + for _, set := range []Set{SetInit, SetNodeAnn} { + fv := m.Get(set) + require.Equal(t, !off, + fv.IsSet(lnwire.IndependentSecretsOptional), + "set %v, NoIndependentSecrets=%v", set, off) + require.False(t, + fv.IsSet(lnwire.IndependentSecretsRequired)) + require.NoError(t, ValidateDeps(fv), "set %v", set) + } + } +} + +// TestIndependentSecretsDependsOnChannelType pins the dependency on explicit +// channel type negotiation, the only way the add-on is ever agreed. +func TestIndependentSecretsDependsOnChannelType(t *testing.T) { + t.Parallel() + + vec := func(bits ...lnwire.FeatureBit) *lnwire.FeatureVector { + return lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector(bits...), lnwire.Features, + ) + } + + err := ValidateDeps(vec(lnwire.IndependentSecretsOptional)) + require.ErrorContains(t, err, "missing feature dependency") + + require.NoError(t, ValidateDeps(vec( + lnwire.IndependentSecretsOptional, + lnwire.ExplicitChannelTypeOptional, + ))) +} diff --git a/feature/manager.go b/feature/manager.go index d1b306ae9c4..e3d443cc58c 100644 --- a/feature/manager.go +++ b/feature/manager.go @@ -95,6 +95,10 @@ type Config struct { // channel disagree with the chain about what was signed. Always false // on mainnet, where the opt-in cannot be switched off. NoUnifiedSigs bool + + // NoIndependentSecrets unsets option_independent_secrets, which is + // otherwise signalled by default. + NoIndependentSecrets bool } // Manager is responsible for generating feature vectors for different requested @@ -191,6 +195,10 @@ func newManager(cfg Config, desc setDesc) (*Manager, error) { raw.Unset(lnwire.UnifiedSigsOptional) raw.Unset(lnwire.UnifiedSigsRequired) } + if cfg.NoIndependentSecrets { + raw.Unset(lnwire.IndependentSecretsOptional) + raw.Unset(lnwire.IndependentSecretsRequired) + } if cfg.NoWumbo { raw.Unset(lnwire.WumboChannelsOptional) raw.Unset(lnwire.WumboChannelsRequired) diff --git a/lncfg/protocol.go b/lncfg/protocol.go index 544807f3a3b..9aefc23a2f5 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -31,6 +31,13 @@ type ProtocolOptions struct { // experimental simple taproot chans commitment type. TaprootChans bool `long:"simple-taproot-chans" description:"if set, then lnd will create and accept requests for channels using the simple taproot commitment type"` + // NoIndependentSecrets should be set if the node should not signal + // option_independent_secrets. It's signalled by default, so new + // channels with peers that also signal it store the remote party's + // per-commitment secrets individually instead of requiring them to + // come from a shachain. + NoIndependentSecrets bool `long:"no-independent-secrets" description:"if set, don't signal option_independent_secrets, so new channels keep requiring the remote party's per-commitment secrets to come from a shachain. By default new channels with peers that also signal it store every secret individually, which lets a peer generate them with k-of-n signers. Downgrading to a version of lnd without it is not supported once any channel has used it: such a version can't read that channel's stored state, even after the channel has closed."` + // TaprootOverlayChans should be set if we want to enable support for // the experimental taproot overlay chan type. TaprootOverlayChans bool `long:"simple-taproot-overlay-chans" description:"if set, then lnd will create and accept requests for channels using the taproot overlay commitment type"` diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go index fbfd74e5c40..0f9cac1e800 100644 --- a/lncfg/protocol_integration.go +++ b/lncfg/protocol_integration.go @@ -33,6 +33,13 @@ type ProtocolOptions struct { // experimental simple taproot chans commitment type. TaprootChans bool `long:"simple-taproot-chans" description:"if set, then lnd will create and accept requests for channels using the simple taproot commitment type"` + // NoIndependentSecrets should be set if the node should not signal + // option_independent_secrets. It's signalled by default, so new + // channels with peers that also signal it store the remote party's + // per-commitment secrets individually instead of requiring them to + // come from a shachain. + NoIndependentSecrets bool `long:"no-independent-secrets" description:"if set, don't signal option_independent_secrets, so new channels keep requiring the remote party's per-commitment secrets to come from a shachain. By default new channels with peers that also signal it store every secret individually, which lets a peer generate them with k-of-n signers. Downgrading to a version of lnd without it is not supported once any channel has used it: such a version can't read that channel's stored state, even after the channel has closed."` + // TaprootOverlayChans should be set if we want to enable support for // the experimental taproot overlay chan type. TaprootOverlayChans bool `long:"simple-taproot-overlay-chans" description:"if set, then lnd will create and accept requests for channels using the taproot overlay commitment type"` diff --git a/sample-lnd.conf b/sample-lnd.conf index f15e2acfa6b..57a5df5311d 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1732,6 +1732,14 @@ ; The same applies: no new ones on a node that signs under the unified hash. ; protocol.simple-taproot-overlay-chans=false +; Set to stop signalling option_independent_secrets. By default new channels +; with peers that also signal it store every per-commitment secret the peer +; reveals individually, instead of requiring them to come from a shachain, +; which lets a peer generate them with k-of-n signers. Downgrading to a version +; of lnd without it is not supported once any channel has used it: such a +; version can't read that channel's stored state, even after it has closed. +; protocol.no-independent-secrets=false + ; Set to disable blinded route forwarding. ; protocol.no-route-blinding=false diff --git a/server.go b/server.go index b4bee7c6b72..a94785cc9dc 100644 --- a/server.go +++ b/server.go @@ -791,7 +791,8 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, NoRbfCoopClose: !cfg.ProtocolOptions.RbfCoopClose, Splice: cfg.ProtocolOptions.Splice() || pendingSplices(dbs.ChanStateDB.ChannelStateDB()), - NoUnifiedSigs: !input.UnifiedSigHash(), + NoUnifiedSigs: !input.UnifiedSigHash(), + NoIndependentSecrets: cfg.ProtocolOptions.NoIndependentSecrets, }) if err != nil { return nil, err From 735c1e61ccdb4c0e06e48b70f1651ec51b53e699 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:58 +0200 Subject: [PATCH 09/24] chanbackup: test that backups ignore option_independent_secrets A static channel backup only lets the node ask its peer to force close, after which it never receives a revocation again, so the add-on has nothing to restore. Pin that a channel with it backs up exactly like the same channel without it. --- chanbackup/independent_secrets_test.go | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 chanbackup/independent_secrets_test.go diff --git a/chanbackup/independent_secrets_test.go b/chanbackup/independent_secrets_test.go new file mode 100644 index 00000000000..f2aabcbdda4 --- /dev/null +++ b/chanbackup/independent_secrets_test.go @@ -0,0 +1,58 @@ +package chanbackup + +import ( + "bytes" + "testing" + + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/lnencrypt" + "github.com/stretchr/testify/require" +) + +// TestSingleIgnoresIndependentSecrets checks that a channel with +// option_independent_secrets backs up exactly like the same channel without +// it. A static channel backup only lets the node ask its peer to force close, +// after which it never receives a revocation again, so the add-on has nothing +// to restore and the backup format stays unchanged. +func TestSingleIgnoresIndependentSecrets(t *testing.T) { + t.Parallel() + + keyRing := &lnencrypt.MockKeyRing{} + + channel, err := genRandomOpenChannelShell() + require.NoError(t, err) + + base := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit + + pack := func(chanType channeldb.ChannelType) Single { + channel.ChanType = chanType + single := NewSingle(channel, nil) + + var b bytes.Buffer + require.NoError(t, single.PackToWriter(&b, keyRing)) + + var unpacked Single + require.NoError(t, unpacked.UnpackFromReader( + bytes.NewReader(b.Bytes()), keyRing, + )) + + return unpacked + } + + for _, extra := range []channeldb.ChannelType{ + 0, channeldb.UnifiedSigsBit, + } { + plain := pack(base | extra) + independent := pack( + base | extra | channeldb.IndependentSecretsBit, + ) + + require.Equal(t, plain.Version, independent.Version) + require.Equal(t, plain.UnifiedSigs, independent.UnifiedSigs) + anchors := SingleBackupVersion( + AnchorsZeroFeeHtlcTxCommitVersion, + ) + require.Equal(t, anchors, independent.Version) + } +} From 6e487496d186cea6344f72958a6b035a1957ea04 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:58 +0200 Subject: [PATCH 10/24] wtclient: test the tower's justice transaction with independent secrets Runs the watchtower path end to end on a channel whose peer reveals random secrets: the client builds the backup for a revoked state from the database, as lnd's server does, the tower decrypts it and builds the justice transaction, and every input must validate against the breached commitment. The test fails if the stored secret is off by a single bit. --- .../independent_secrets_internal_test.go | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 watchtower/wtclient/independent_secrets_internal_test.go diff --git a/watchtower/wtclient/independent_secrets_internal_test.go b/watchtower/wtclient/independent_secrets_internal_test.go new file mode 100644 index 00000000000..d5b786c548b --- /dev/null +++ b/watchtower/wtclient/independent_secrets_internal_test.go @@ -0,0 +1,206 @@ +package wtclient + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "io" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/shachain" + "github.com/lightningnetwork/lnd/watchtower/blob" + "github.com/lightningnetwork/lnd/watchtower/lookout" + "github.com/lightningnetwork/lnd/watchtower/wtdb" + "github.com/lightningnetwork/lnd/watchtower/wtpolicy" + "github.com/stretchr/testify/require" +) + +// randomSecretProducer behaves like a peer that generates its per-commitment +// secrets independently: the base producer's secrets for the two indexes +// exchanged at channel creation, random ones after that. +type randomSecretProducer struct { + base shachain.Producer + secrets map[uint64]chainhash.Hash +} + +func (p *randomSecretProducer) AtIndex(i uint64) (*chainhash.Hash, error) { + if i < 2 { + return p.base.AtIndex(i) + } + if h, ok := p.secrets[i]; ok { + return &h, nil + } + + var h chainhash.Hash + if _, err := rand.Read(h[:]); err != nil { + return nil, err + } + p.secrets[i] = h + + return &h, nil +} + +func (p *randomSecretProducer) Encode(w io.Writer) error { + return p.base.Encode(w) +} + +// TestTowerJusticeIndependentSecrets runs the watchtower path end to end on a +// channel with option_independent_secrets whose peer reveals random secrets: +// the client builds the backup for a revoked state from the database, as +// lnd's server does, the tower decrypts it and builds the justice +// transaction, and every input of that transaction must validate against the +// breached commitment. +func TestTowerJusticeIndependentSecrets(t *testing.T) { + t.Parallel() + + chanType := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | channeldb.ZeroHtlcTxFeeBit | + channeldb.IndependentSecretsBit + + alice, bob, err := lnwallet.CreateTestChannels(t, chanType) + require.NoError(t, err) + + bob.State().RevocationProducer = &randomSecretProducer{ + base: bob.State().RevocationProducer, + secrets: make(map[uint64]chainhash.Hash), + } + + // Move some money to Bob, so both sides have an output, and keep each + // of Bob's commitments before it's revoked. + var revoked []*wire.MsgTx + for i := 0; i < 4; i++ { + revoked = append(revoked, bob.State().LocalCommitment.CommitTx) + + preimage := bytes.Repeat([]byte{byte(i + 1)}, 32) + htlc := &lnwire.UpdateAddHTLC{ + ID: uint64(i), + PaymentHash: sha256.Sum256(preimage), + Amount: lnwire.NewMSatFromSatoshis(100_000), + Expiry: 500, + } + _, err := alice.AddHTLC(htlc, nil) + require.NoError(t, err) + _, err = bob.ReceiveHTLC(htlc) + require.NoError(t, err) + require.NoError(t, lnwallet.ForceStateTransition(alice, bob)) + + err = bob.SettleHTLC([32]byte(preimage), uint64(i), nil, nil, + nil) + require.NoError(t, err) + require.NoError(t, alice.ReceiveHTLCSettle( + [32]byte(preimage), uint64(i), + )) + require.NoError(t, lnwallet.ForceStateTransition(bob, alice)) + } + + // Each round revokes two of Bob's commitments, so revoked[i] is his + // state 2*i. State 4 had a random secret and is long revoked. + const breachedState = 4 + breachTx := revoked[breachedState/2] + + // Like lnd's server, build the retribution from the channel as stored. + stored, err := alice.State().Db.FetchChannel( + alice.State().FundingOutpoint, + ) + require.NoError(t, err) + + getBreachInfo := func(lnwire.ChannelID, uint64) ( + *lnwallet.BreachRetribution, channeldb.ChannelType, error) { + + br, err := lnwallet.NewBreachRetribution( + stored, breachedState, 0, nil, + fn.Some[lnwallet.AuxLeafStore]( + &lnwallet.MockAuxLeafStore{}, + ), + fn.Some[lnwallet.AuxContractResolver]( + &lnwallet.MockAuxContractResolver{}, + ), + ) + + return br, stored.ChanType, err + } + br, _, err := getBreachInfo(lnwire.ChannelID{}, breachedState) + require.NoError(t, err) + require.Equal(t, breachTx.TxHash(), br.BreachTxHash) + + // The client binds the backup to a session and encrypts it. + sweepScript := append([]byte{0x00, 0x14}, bytes.Repeat( + []byte{0x42}, 20, + )...) + session := &wtdb.ClientSessionBody{ + Policy: wtpolicy.Policy{ + TxPolicy: wtpolicy.TxPolicy{ + BlobType: blob.TypeAltruistAnchorCommit, + SweepFeeRate: chainfee.SatPerKWeight(2500), + }, + }, + } + + chanID := lnwire.NewChanIDFromOutPoint(stored.FundingOutpoint) + task := newBackupTask(wtdb.BackupID{ + ChanID: chanID, + CommitHeight: breachedState, + }, sweepScript) + require.NoError(t, task.bindSession(session, getBreachInfo)) + + hint, encBlob, err := task.craftSessionPayload(alice.Signer) + require.NoError(t, err) + + // The tower recognises the breach, decrypts the backup and builds the + // justice transaction. + breachTxID := breachTx.TxHash() + require.Equal(t, blob.NewBreachHintFromHash(&breachTxID), hint) + + kit, err := blob.Decrypt( + blob.NewBreachKeyFromHash(&breachTxID), encBlob, + session.Policy.BlobType, + ) + require.NoError(t, err) + + justice := &lookout.JusticeDescriptor{ + BreachedCommitTx: breachTx, + SessionInfo: &wtdb.SessionInfo{ + Policy: session.Policy, + }, + JusticeKit: kit, + } + justiceTx, err := justice.CreateJusticeTxn() + require.NoError(t, err) + + // Every input spends an output of the breached commitment, and does so + // validly: the revocation key behind the to_local signature came from + // the random secret stored in the revocation log. + prevOuts := txscript.NewMultiPrevOutFetcher(nil) + for i, txOut := range breachTx.TxOut { + prevOuts.AddPrevOut(wire.OutPoint{ + Hash: breachTxID, Index: uint32(i), + }, txOut) + } + sigHashes := txscript.NewTxSigHashes(justiceTx, prevOuts) + + require.NotEmpty(t, justiceTx.TxIn) + var swept btcutil.Amount + for i, txIn := range justiceTx.TxIn { + require.Equal(t, breachTxID, txIn.PreviousOutPoint.Hash) + prevOut := prevOuts.FetchPrevOutput(txIn.PreviousOutPoint) + swept += btcutil.Amount(prevOut.Value) + + vm, err := txscript.NewEngine( + prevOut.PkScript, justiceTx, i, + txscript.StandardVerifyFlags, nil, sigHashes, + prevOut.Value, prevOuts, + ) + require.NoError(t, err) + require.NoError(t, vm.Execute(), "input %d", i) + } + require.Positive(t, swept) +} From 362bd3c6e95904bc1b8b661c2c9a28a13fed883d Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Fri, 16 Oct 2026 02:42:38 +0200 Subject: [PATCH 11/24] lnrpc: report option_independent_secrets per channel Add independent_secrets to Channel and PendingChannelsResponse. PendingChannel, and fill it for open, pending open, waiting close and force closing channels, so operators can see which channels store every secret. The field is 2001, away from the fork's own fields, which are numbered on from 1001 (splicing took 1002 to 1006). The change to lightning.pb.go is only what protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the new field; the rest of the file is left as it was. --- lnrpc/lightning.pb.go | 45 ++++++++++++++++++++++++++++-------- lnrpc/lightning.proto | 13 +++++++++++ lnrpc/lightning.swagger.json | 8 +++++++ rpcserver.go | 7 ++++++ 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index 042e35d8678..e8e1c6ff6ad 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -4838,8 +4838,13 @@ type Channel struct { //channel goes by now, which a splice changes; forwarding records keep this //one. Payments naming either reach the channel. OriginalChanId uint64 `protobuf:"varint,1006,opt,name=original_chan_id,json=originalChanId,proto3" json:"original_chan_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the channel uses option_independent_secrets: this node stores + // every per-commitment secret the peer reveals instead of requiring them + // to come from a shachain, which lets the peer generate them with k-of-n + // signers. A build without the option cannot load such a channel. + IndependentSecrets bool `protobuf:"varint,2001,opt,name=independent_secrets,json=independentSecrets,proto3" json:"independent_secrets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Channel) Reset() { @@ -5177,6 +5182,13 @@ func (x *Channel) GetOriginalChanId() uint64 { return 0 } +func (x *Channel) GetIndependentSecrets() bool { + if x != nil { + return x.IndependentSecrets + } + return false +} + // A splice that has started and not yet locked or ended. type PendingSplice struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -19297,8 +19309,14 @@ type PendingChannelsResponse_PendingChannel struct { //The outpoint the channel was opened with, when a splice moved it: //channel_point is then the current funding output. OriginalChannelPoint string `protobuf:"bytes,1002,opt,name=original_channel_point,json=originalChannelPoint,proto3" json:"original_channel_point,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the channel uses option_independent_secrets: this node + // stores every per-commitment secret the peer reveals instead of + // requiring them to come from a shachain. A build without the + // option cannot load such a channel. False for a closing channel + // whose type is not known. + IndependentSecrets bool `protobuf:"varint,2001,opt,name=independent_secrets,json=independentSecrets,proto3" json:"independent_secrets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *PendingChannelsResponse_PendingChannel) Reset() { @@ -19443,6 +19461,13 @@ func (x *PendingChannelsResponse_PendingChannel) GetOriginalChannelPoint() strin return "" } +func (x *PendingChannelsResponse_PendingChannel) GetIndependentSecrets() bool { + if x != nil { + return x.IndependentSecrets + } + return false +} + type PendingChannelsResponse_PendingOpenChannel struct { state protoimpl.MessageState `protogen:"open.v1"` // The pending channel @@ -20187,7 +20212,7 @@ const file_lightning_proto_rawDesc = "" + "\x0edust_limit_sat\x18\x03 \x01(\x04R\fdustLimitSat\x12/\n" + "\x14max_pending_amt_msat\x18\x04 \x01(\x04R\x11maxPendingAmtMsat\x12\"\n" + "\rmin_htlc_msat\x18\x05 \x01(\x04R\vminHtlcMsat\x12,\n" + - "\x12max_accepted_htlcs\x18\x06 \x01(\rR\x10maxAcceptedHtlcs\"\x80\x0e\n" + + "\x12max_accepted_htlcs\x18\x06 \x01(\rR\x10maxAcceptedHtlcs\"\xb2\x0e\n" + "\aChannel\x12\x16\n" + "\x06active\x18\x01 \x01(\bR\x06active\x12#\n" + "\rremote_pubkey\x18\x02 \x01(\tR\fremotePubkey\x12#\n" + @@ -20239,7 +20264,8 @@ const file_lightning_proto_rawDesc = "" + "\vlast_splice\x18\xec\a \x01(\v2\x11.lnrpc.LastSpliceR\n" + "lastSplice\x125\n" + "\x16original_channel_point\x18\xed\a \x01(\tR\x14originalChannelPoint\x12-\n" + - "\x10original_chan_id\x18\xee\a \x01(\x04B\x020\x01R\x0eoriginalChanId\"\xf6\x04\n" + + "\x10original_chan_id\x18\xee\a \x01(\x04B\x020\x01R\x0eoriginalChanId\x120\n" + + "\x13independent_secrets\x18\xd1\x0f \x01(\bR\x12independentSecrets\"\xf6\x04\n" + "\rPendingSplice\x12.\n" + "\tinitiator\x18\x01 \x01(\x0e2\x10.lnrpc.InitiatorR\tinitiator\x12\x14\n" + "\x05state\x18\x02 \x01(\tR\x05state\x12&\n" + @@ -20654,13 +20680,13 @@ const file_lightning_proto_rawDesc = "" + "\x13blocks_til_maturity\x18\x05 \x01(\x05R\x11blocksTilMaturity\x12\x14\n" + "\x05stage\x18\x06 \x01(\rR\x05stage\">\n" + "\x16PendingChannelsRequest\x12$\n" + - "\x0einclude_raw_tx\x18\x01 \x01(\bR\fincludeRawTx\"\xbb\x16\n" + + "\x0einclude_raw_tx\x18\x01 \x01(\bR\fincludeRawTx\"\xed\x16\n" + "\x17PendingChannelsResponse\x12.\n" + "\x13total_limbo_balance\x18\x01 \x01(\x03R\x11totalLimboBalance\x12e\n" + "\x15pending_open_channels\x18\x02 \x03(\v21.lnrpc.PendingChannelsResponse.PendingOpenChannelR\x13pendingOpenChannels\x12j\n" + "\x18pending_closing_channels\x18\x03 \x03(\v2,.lnrpc.PendingChannelsResponse.ClosedChannelB\x02\x18\x01R\x16pendingClosingChannels\x12v\n" + "\x1epending_force_closing_channels\x18\x04 \x03(\v21.lnrpc.PendingChannelsResponse.ForceClosedChannelR\x1bpendingForceClosingChannels\x12h\n" + - "\x16waiting_close_channels\x18\x05 \x03(\v22.lnrpc.PendingChannelsResponse.WaitingCloseChannelR\x14waitingCloseChannels\x1a\xbe\x05\n" + + "\x16waiting_close_channels\x18\x05 \x03(\v22.lnrpc.PendingChannelsResponse.WaitingCloseChannelR\x14waitingCloseChannels\x1a\xf0\x05\n" + "\x0ePendingChannel\x12&\n" + "\x0fremote_node_pub\x18\x01 \x01(\tR\rremoteNodePub\x12#\n" + "\rchannel_point\x18\x02 \x01(\tR\fchannelPoint\x12\x1a\n" + @@ -20678,7 +20704,8 @@ const file_lightning_proto_rawDesc = "" + "\x04memo\x18\r \x01(\tR\x04memo\x12.\n" + "\x13custom_channel_data\x18\" \x01(\fR\x11customChannelData\x12\"\n" + "\funified_sigs\x18\xe9\a \x01(\bR\vunifiedSigs\x125\n" + - "\x16original_channel_point\x18\xea\a \x01(\tR\x14originalChannelPoint\x1a\xe8\x02\n" + + "\x16original_channel_point\x18\xea\a \x01(\tR\x14originalChannelPoint\x120\n" + + "\x13independent_secrets\x18\xd1\x0f \x01(\bR\x12independentSecrets\x1a\xe8\x02\n" + "\x12PendingOpenChannel\x12G\n" + "\achannel\x18\x01 \x01(\v2-.lnrpc.PendingChannelsResponse.PendingChannelR\achannel\x12\x1d\n" + "\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index ed2ce567874..ffdc91cd244 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -1638,6 +1638,12 @@ message Channel { one. Payments naming either reach the channel. */ uint64 original_chan_id = 1006 [jstype = JS_STRING]; + + // Whether the channel uses option_independent_secrets: this node stores + // every per-commitment secret the peer reveals instead of requiring them + // to come from a shachain, which lets the peer generate them with k-of-n + // signers. A build without the option cannot load such a channel. + bool independent_secrets = 2001; } /* @@ -3093,6 +3099,13 @@ message PendingChannelsResponse { channel_point is then the current funding output. */ string original_channel_point = 1002; + + // Whether the channel uses option_independent_secrets: this node + // stores every per-commitment secret the peer reveals instead of + // requiring them to come from a shachain. A build without the + // option cannot load such a channel. False for a closing channel + // whose type is not known. + bool independent_secrets = 2001; } message PendingOpenChannel { diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 9b012544acc..b960b02b91c 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -3352,6 +3352,10 @@ "original_channel_point": { "type": "string", "description": "The outpoint the channel was opened with, when a splice moved it:\nchannel_point is then the current funding output." + }, + "independent_secrets": { + "type": "boolean", + "description": "Whether the channel uses option_independent_secrets: this node\nstores every per-commitment secret the peer reveals instead of\nrequiring them to come from a shachain. A build without the\noption cannot load such a channel. False for a closing channel\nwhose type is not known." } } }, @@ -4143,6 +4147,10 @@ "type": "string", "format": "uint64", "description": "The short channel id the channel was opened with. chan_id is the one the\nchannel goes by now, which a splice changes; forwarding records keep this\none. Payments naming either reach the channel." + }, + "independent_secrets": { + "type": "boolean", + "description": "Whether the channel uses option_independent_secrets: this node stores\nevery per-commitment secret the peer reveals instead of requiring them\nto come from a shachain, which lets the peer generate them with k-of-n\nsigners. A build without the option cannot load such a channel." } } }, diff --git a/rpcserver.go b/rpcserver.go index baf18d89ee9..42d68e88558 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -4214,6 +4214,8 @@ func (r *rpcServer) fetchPendingOpenChannels() (pendingOpenChannels, error) { ConfirmationHeight: pendingChan. ConfirmationHeight, } + result[i].Channel.IndependentSecrets = + pendingChan.ChanType.HasIndependentSecrets() } return result, nil @@ -4281,6 +4283,8 @@ func (r *rpcServer) fetchPendingForceCloseChannels() (pendingForceClose, ) channel.UnifiedSigs = historical.ChanType.HasUnifiedSigs() + channel.IndependentSecrets = + historical.ChanType.HasIndependentSecrets() // Get the number of forwarding packages from the // historical channel. @@ -4540,6 +4544,8 @@ func (r *rpcServer) fetchWaitingCloseChannels( Memo: string(waitingClose.Memo), CustomChannelData: customChanBytes, } + channel.IndependentSecrets = + waitingClose.ChanType.HasIndependentSecrets() var closingTxid, closingTxHex string if closingTx != nil { @@ -5181,6 +5187,7 @@ func createRPCOpenChannel(ctx context.Context, r *rpcServer, LocalChanReserveSat: int64(dbChannel.LocalChanCfg.ChanReserve), RemoteChanReserveSat: int64(dbChannel.RemoteChanCfg.ChanReserve), } + channel.IndependentSecrets = dbChannel.ChanType.HasIndependentSecrets() // Look up our channel peer's node alias if the caller requests it. if peerAliasLookup { From 3bd41082f7e859954f26cb33d2e61da73478795f Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:59 +0200 Subject: [PATCH 12/24] shachain: add IndependentProducer An IndependentProducer derives the secret for index i as an HMAC of i keyed by the root, as option_independent_secrets allows. Like a shachain it needs nothing but its root, which it encodes exactly as a RevocationProducer does, but its secrets can't be compressed, so only a peer that stores every secret accepts them. --- shachain/independent_producer.go | 100 ++++++++++++++++++++++++++ shachain/independent_producer_test.go | 63 ++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 shachain/independent_producer.go create mode 100644 shachain/independent_producer_test.go diff --git a/shachain/independent_producer.go b/shachain/independent_producer.go new file mode 100644 index 00000000000..63b2e247865 --- /dev/null +++ b/shachain/independent_producer.go @@ -0,0 +1,100 @@ +package shachain + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "fmt" + "io" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" +) + +// independentSecretTag separates the secrets of an IndependentProducer from +// any other use of the same root. +const independentSecretTag = "independent-per-commitment-secret" + +// IndependentProducer is a Producer whose secrets have no shachain +// relationship: the secret at index i is an HMAC of i keyed by the root, as +// option_independent_secrets allows ("a key derivation function applied to a +// private seed and the commitment number"). Like a shachain it needs no state +// beyond its root, so a node can always regenerate any secret it has sent the +// point for, but a peer can't compress the secrets, so it only works with a +// peer that stores every secret. +// +// It exists so that a node can exercise the receiving side of its peer, in +// tests and on test networks; a single signer gains nothing from it. +type IndependentProducer struct { + root chainhash.Hash +} + +// A compile time check to ensure IndependentProducer implements the Producer +// interface. +var _ Producer = (*IndependentProducer)(nil) + +// NewIndependentProducer creates an IndependentProducer from a root. +func NewIndependentProducer(root chainhash.Hash) *IndependentProducer { + return &IndependentProducer{root: root} +} + +// NewIndependentProducerFrom creates an IndependentProducer with the same root +// as p, read from p's encoding. Because Encode writes that same root, a +// channel can switch producer kinds without changing what it stores, and +// anything else derived from the encoded root (such as taproot nonces) stays +// the same. +func NewIndependentProducerFrom(p Producer) (*IndependentProducer, error) { + var b bytes.Buffer + if err := p.Encode(&b); err != nil { + return nil, err + } + if b.Len() != chainhash.HashSize { + return nil, fmt.Errorf("producer encoding is %d bytes, want %d", + b.Len(), chainhash.HashSize) + } + + var root chainhash.Hash + copy(root[:], b.Bytes()) + + return NewIndependentProducer(root), nil +} + +// AtIndex returns the secret at index v. It is always a valid secp256k1 +// private key: in the negligible case that the HMAC isn't one, a counter is +// mixed in until it is. +// +// NOTE: This function is part of the Producer interface. +func (p *IndependentProducer) AtIndex(v uint64) (*chainhash.Hash, error) { + // tag || index (8 bytes, big endian) || counter (1 byte). + var msg [len(independentSecretTag) + 8 + 1]byte + n := copy(msg[:], independentSecretTag) + binary.BigEndian.PutUint64(msg[n:], v) + + for counter := 0; counter < 256; counter++ { + msg[n+8] = byte(counter) + + mac := hmac.New(sha256.New, p.root[:]) + mac.Write(msg[:]) + + var secret chainhash.Hash + copy(secret[:], mac.Sum(nil)) + + var s btcec.ModNScalar + if overflow := s.SetByteSlice(secret[:]); !overflow && + !s.IsZero() { + + return &secret, nil + } + } + + return nil, fmt.Errorf("no valid secret for index %d", v) +} + +// Encode writes the root, in the same format as RevocationProducer.Encode. +// +// NOTE: This function is part of the Producer interface. +func (p *IndependentProducer) Encode(w io.Writer) error { + _, err := w.Write(p.root[:]) + return err +} diff --git a/shachain/independent_producer_test.go b/shachain/independent_producer_test.go new file mode 100644 index 00000000000..fa232309f49 --- /dev/null +++ b/shachain/independent_producer_test.go @@ -0,0 +1,63 @@ +package shachain + +import ( + "bytes" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/stretchr/testify/require" +) + +// TestIndependentProducer checks that an IndependentProducer's secrets are +// deterministic, distinct, valid private keys that a shachain store refuses, +// and that it encodes, and can be made from, the same root as a shachain +// producer. +func TestIndependentProducer(t *testing.T) { + t.Parallel() + + root := randomHash(t) + shaProducer := NewRevocationProducer(*root) + + producer, err := NewIndependentProducerFrom(shaProducer) + require.NoError(t, err) + + // Same root, same encoding. + var a, b bytes.Buffer + require.NoError(t, shaProducer.Encode(&a)) + require.NoError(t, producer.Encode(&b)) + require.Equal(t, a.Bytes(), b.Bytes()) + + again := NewIndependentProducer(*root) + seen := make(map[[32]byte]struct{}) + shaStore := NewRevocationStore() + refused := false + for i := uint64(0); i < 64; i++ { + secret, err := producer.AtIndex(i) + require.NoError(t, err) + + // Deterministic. + same, err := again.AtIndex(i) + require.NoError(t, err) + require.Equal(t, *secret, *same) + + // Distinct, and different from the shachain secret. + _, dup := seen[*secret] + require.False(t, dup, "index %d repeats a secret", i) + seen[*secret] = struct{}{} + + shaSecret, err := shaProducer.AtIndex(i) + require.NoError(t, err) + require.NotEqual(t, *shaSecret, *secret) + + // A valid private key as it stands. + var s btcec.ModNScalar + require.False(t, s.SetByteSlice(secret[:])) + require.False(t, s.IsZero()) + + // A shachain store can't take them. + if !refused && shaStore.AddNextEntry(secret) != nil { + refused = true + } + } + require.True(t, refused, "a shachain store accepted every secret") +} From 0aa29d6f007e7572ce405580020d560faee45842 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:59 +0200 Subject: [PATCH 13/24] multi: add a dev option to send independent secrets With --dev.independent-secrets-sender, available in integration builds only, a node generates its own per-commitment secrets with an IndependentProducer on every new channel with option_independent_secrets, so that a peer's support for them can be exercised by a real node. The channel records this in LocalIndependentSecretsBit, a local channel type bit, and the producer is rebuilt from the stored root when the channel is loaded; a type with the bit but without the add-on fails to load. The root is stored as before, and the taproot nonce producer is derived from the new producer exactly as after a restart, so nonces stay consistent. --- channeldb/channel.go | 36 ++++++++ config_builder.go | 4 + .../independent_secrets_negotiation_test.go | 45 ++++++++++ lncfg/dev.go | 6 ++ lncfg/dev_integration.go | 7 ++ lnwallet/config.go | 7 ++ lnwallet/independent_secrets_test.go | 86 +++++++++++++++++++ lnwallet/test_utils.go | 24 +++++- lnwallet/wallet.go | 21 +++++ 9 files changed, 234 insertions(+), 2 deletions(-) diff --git a/channeldb/channel.go b/channeldb/channel.go index 685937c90c6..305569b7aba 100644 --- a/channeldb/channel.go +++ b/channeldb/channel.go @@ -509,6 +509,15 @@ const ( // The bit is far above upstream lnd's allocations, so rebasing onto // upstream can't hand the same bit to another meaning. IndependentSecretsBit ChannelType = 1 << 40 + + // LocalIndependentSecretsBit indicates that this node generates its + // own per-commitment secrets with a shachain.IndependentProducer + // rather than a shachain. It is local, never negotiated, only set + // together with IndependentSecretsBit, and only by the dev option that + // lets a node exercise its peer's support for independent secrets. + // The producer's root is stored the same way either way; this bit + // says how to read it. + LocalIndependentSecretsBit ChannelType = 1 << 41 ) // IsSingleFunder returns true if the channel type if one of the known single @@ -598,6 +607,12 @@ func (c ChannelType) HasIndependentSecrets() bool { return c&IndependentSecretsBit == IndependentSecretsBit } +// HasLocalIndependentSecrets returns true if this node generates its own +// per-commitment secrets with a shachain.IndependentProducer. +func (c ChannelType) HasLocalIndependentSecrets() bool { + return c&LocalIndependentSecretsBit == LocalIndependentSecretsBit +} + // NewRemoteSecretStore returns an empty store for the remote party's // per-commitment secrets that suits the channel type: an IndependentStore when // option_independent_secrets was negotiated, otherwise a shachain @@ -5372,6 +5387,27 @@ func fetchChanRevocationState(chanBucket kvdb.RBucket, channel *OpenChannel) err return err } + // A node that generates its own secrets with an IndependentProducer + // stores the same root a shachain producer would; the channel type + // says which producer to rebuild from it. + if channel.ChanType.HasLocalIndependentSecrets() { + // Such secrets are only accepted by a peer that stores them + // all, so the bit is never set without the add-on. + if !channel.ChanType.HasIndependentSecrets() { + return fmt.Errorf("channel type %v generates "+ + "independent secrets without "+ + "option_independent_secrets", channel.ChanType) + } + + producer, err := shachain.NewIndependentProducerFrom( + channel.RevocationProducer, + ) + if err != nil { + return err + } + channel.RevocationProducer = producer + } + // If there aren't any bytes left in the buffer, then we don't yet have // the next remote revocation, so we can exit early here. if r.Len() == 0 { diff --git a/config_builder.go b/config_builder.go index 04690337cb3..e7fac596842 100644 --- a/config_builder.go +++ b/config_builder.go @@ -803,6 +803,8 @@ func (d *DefaultWalletImpl) BuildChainControl( AuxLeafStore: partialChainControl.Cfg.AuxLeafStore, AuxSigner: partialChainControl.Cfg.AuxSigner, } + lnWalletConfig.IndependentSecretsSender = + d.cfg.Dev.GetIndependentSecretsSender() // The broadcast is already always active for neutrino nodes, so we // don't want to create a rebroadcast loop. @@ -922,6 +924,8 @@ func (d *RPCSignerWalletImpl) BuildChainControl( ChainHash: d.cfg.ActiveNetParams.ChainHash, CoinSelectionStrategy: walletConfig.CoinSelectionStrategy, } + lnWalletConfig.IndependentSecretsSender = + d.cfg.Dev.GetIndependentSecretsSender() // We've created the wallet configuration now, so we can finish // initializing the main chain control. diff --git a/funding/independent_secrets_negotiation_test.go b/funding/independent_secrets_negotiation_test.go index f453536c3a3..fa894f2b0e4 100644 --- a/funding/independent_secrets_negotiation_test.go +++ b/funding/independent_secrets_negotiation_test.go @@ -170,7 +170,37 @@ func TestIndependentSecretsFundingFlow(t *testing.T) { bobBits []lnwire.FeatureBit chanType *lnwire.ChannelType independent bool + + // aliceSender turns on the dev option that makes Alice + // generate her own secrets without a shachain. + aliceSender bool }{ + { + name: "alice sends independent secrets", + aliceBits: with, + bobBits: with, + independent: true, + aliceSender: true, + }, + { + // Taproot nonces derive from the same root, so they + // survive the change of producer. + name: "alice sends independent secrets on taproot", + aliceBits: with, + bobBits: with, + chanType: &taproot, + independent: true, + aliceSender: true, + }, + { + // The dev option only applies where the peer stores + // every secret. + name: "sender option without the add-on", + aliceBits: with, + bobBits: base, + independent: false, + aliceSender: true, + }, { name: "both signal it", aliceBits: with, @@ -204,6 +234,9 @@ func TestIndependentSecretsFundingFlow(t *testing.T) { bob.localFeatures = tc.bobBits bob.remoteFeatures = tc.aliceBits + aliceCfg := &alice.fundingMgr.cfg.Wallet.Cfg + aliceCfg.IndependentSecretsSender = tc.aliceSender + updateChan := make(chan *lnrpc.OpenStatusUpdate) fundChannel( t, alice, bob, btcutil.Amount(500000), 0, @@ -227,6 +260,18 @@ func TestIndependentSecretsFundingFlow(t *testing.T) { chanType.HasIndependentSecrets(), ) require.Equal(t, tc.independent, isIndependent) + + // Only Alice, and only on an independent + // channel, generates her secrets without a + // shachain. + sender := node == alice && tc.aliceSender && + tc.independent + producer := channel.RevocationProducer + _, isProducer := + producer.(*shachain.IndependentProducer) + require.Equal(t, sender, isProducer) + require.Equal(t, sender, + chanType.HasLocalIndependentSecrets()) } }) } diff --git a/lncfg/dev.go b/lncfg/dev.go index 15c9367cbc2..e87b3345a40 100644 --- a/lncfg/dev.go +++ b/lncfg/dev.go @@ -71,3 +71,9 @@ func (d *DevConfig) GetMinFwdHistoryAge() time.Duration { func (d *DevConfig) ChannelCloseConfs() fn.Option[uint32] { return fn.None[uint32]() } + +// GetIndependentSecretsSender returns false for production builds, where this +// node's per-commitment secrets always come from a shachain. +func (d *DevConfig) GetIndependentSecretsSender() bool { + return false +} diff --git a/lncfg/dev_integration.go b/lncfg/dev_integration.go index 3793e7c46a5..971e702d031 100644 --- a/lncfg/dev_integration.go +++ b/lncfg/dev_integration.go @@ -30,6 +30,13 @@ type DevConfig struct { UnsafeConnect bool `long:"unsafeconnect" description:"Allow the rpcserver to connect to a peer even if there's already a connection."` ForceChannelCloseConfs uint32 `long:"force-channel-close-confs" description:"Force a specific number of confirmations for channel closes (dev/test only)"` MinFwdHistoryAge time.Duration `long:"min-fwd-history-age" description:"Minimum age of forwarding events before they can be deleted via DeleteForwardingHistory (dev/test only, default: 1h)"` + IndependentSecretsSender bool `long:"independent-secrets-sender" description:"On new channels with option_independent_secrets, generate this node's own per-commitment secrets without a shachain, to exercise the peer's support for them (dev/test only)"` +} + +// GetIndependentSecretsSender returns the config value for +// `IndependentSecretsSender`. +func (d *DevConfig) GetIndependentSecretsSender() bool { + return d.IndependentSecretsSender } // ChannelReadyWait returns the config value `ProcessChannelReadyWait`. diff --git a/lnwallet/config.go b/lnwallet/config.go index 482ce9620cc..40fa0d14bea 100644 --- a/lnwallet/config.go +++ b/lnwallet/config.go @@ -78,4 +78,11 @@ type Config struct { // AuxSigner is an optional signer that can be used to sign auxiliary // leaves for certain custom channel types. AuxSigner fn.Option[AuxSigner] + + // IndependentSecretsSender makes this node generate its own + // per-commitment secrets with a shachain.IndependentProducer on every + // new channel with option_independent_secrets, so that the peer's + // support for them is exercised. It's set only by a dev option in + // integration builds. + IndependentSecretsSender bool } diff --git a/lnwallet/independent_secrets_test.go b/lnwallet/independent_secrets_test.go index 38e601fd3b6..98bf11590aa 100644 --- a/lnwallet/independent_secrets_test.go +++ b/lnwallet/independent_secrets_test.go @@ -304,3 +304,89 @@ func TestIndependentSecretsChanSyncMidRevocation(t *testing.T) { require.NoError(t, err) require.Equal(t, [32]byte(*want), msg.LastRemoteCommitSecret) } + +// TestIndependentSecretsBothSidesIndependentProducer runs a channel on which +// both sides generate their secrets with an IndependentProducer, as dev nodes +// with the sender option do: every secret is accepted and stored, the +// producer is rebuilt from the database after a restart so the same secrets +// keep coming, and every revoked state stays punishable. +func TestIndependentSecretsBothSidesIndependentProducer(t *testing.T) { + t.Parallel() + + // Taproot channels also derive their musig2 nonces from the stored + // root, which the IndependentProducer keeps. + taproot := channeldb.SingleFunderTweaklessBit | + channeldb.AnchorOutputsBit | channeldb.SimpleTaprootFeatureBit | + channeldb.IndependentSecretsBit + + for name, chanType := range map[string]channeldb.ChannelType{ + "anchors": independentSecretsChanType(true), + "taproot": taproot, + } { + t.Run(name, func(t *testing.T) { + local := channeldb.LocalIndependentSecretsBit + testBothSidesIndependentProducer(t, chanType|local) + }) + } +} + +func testBothSidesIndependentProducer(t *testing.T, + chanType channeldb.ChannelType) { + + alice, bob, err := CreateTestChannels(t, chanType) + require.NoError(t, err) + + for _, c := range []*LightningChannel{alice, bob} { + producer := c.channelState.RevocationProducer + _, ok := producer.(*shachain.IndependentProducer) + require.True(t, ok, "expected an IndependentProducer") + } + + for i := 0; i < 3; i++ { + require.NoError(t, sendAndSettle(t, alice, bob, i)) + } + + // Restart both. Each must rebuild its IndependentProducer from the + // stored root, or it would reveal secrets for points it never sent. + alice, err = restartChannel(alice) + require.NoError(t, err) + bob, err = restartChannel(bob) + require.NoError(t, err) + for _, c := range []*LightningChannel{alice, bob} { + producer := c.channelState.RevocationProducer + _, ok := producer.(*shachain.IndependentProducer) + require.True(t, ok, "expected an IndependentProducer "+ + "after restart") + } + assertNoChanSyncNeeded(t, alice, bob) + + for i := 3; i < 6; i++ { + require.NoError(t, sendAndSettle(t, alice, bob, i)) + } + + // Each side can punish every revoked state of the other. + pairs := [][2]*LightningChannel{{alice, bob}, {bob, alice}} + for _, pair := range pairs { + self, peer := pair[0], pair[1] + tip := self.channelState.RemoteCommitment.CommitHeight + require.Greater(t, tip, uint64(4)) + + for h := uint64(0); h < tip; h++ { + br, err := NewBreachRetribution( + self.channelState, h, 101, nil, + fn.Some[AuxLeafStore](&MockAuxLeafStore{}), + fn.Some[AuxContractResolver]( + &MockAuxContractResolver{}, + ), + ) + require.NoError(t, err, "state %d", h) + + secret, err := peer.channelState.RevocationProducer. + AtIndex(h) + require.NoError(t, err) + point := input.ComputeCommitmentPoint(secret[:]) + require.True(t, point.IsEqual(br.KeyRing.CommitPoint), + "state %d", h) + } + } +} diff --git a/lnwallet/test_utils.go b/lnwallet/test_utils.go index 257067025a6..83583ffebb5 100644 --- a/lnwallet/test_utils.go +++ b/lnwallet/test_utils.go @@ -217,7 +217,19 @@ func CreateTestChannels(t *testing.T, chanType channeldb.ChannelType, if err != nil { return nil, nil, err } - bobPreimageProducer := shachain.NewRevocationProducer(*bobRoot) + var bobPreimageProducer shachain.Producer = shachain. + NewRevocationProducer(*bobRoot) + + // With LocalIndependentSecretsBit, both sides generate their secrets + // without a shachain, as a dev node does. + if chanType.HasLocalIndependentSecrets() { + bobPreimageProducer, err = shachain.NewIndependentProducerFrom( + bobPreimageProducer, + ) + if err != nil { + return nil, nil, err + } + } bobFirstRevoke, err := bobPreimageProducer.AtIndex(0) if err != nil { return nil, nil, err @@ -228,7 +240,15 @@ func CreateTestChannels(t *testing.T, chanType channeldb.ChannelType, if err != nil { return nil, nil, err } - alicePreimageProducer := shachain.NewRevocationProducer(*aliceRoot) + var alicePreimageProducer shachain.Producer = shachain. + NewRevocationProducer(*aliceRoot) + if chanType.HasLocalIndependentSecrets() { + alicePreimageProducer, err = shachain. + NewIndependentProducerFrom(alicePreimageProducer) + if err != nil { + return nil, nil, err + } + } aliceFirstRevoke, err := alicePreimageProducer.AtIndex(0) if err != nil { return nil, nil, err diff --git a/lnwallet/wallet.go b/lnwallet/wallet.go index a79c793a9a8..2099e896616 100644 --- a/lnwallet/wallet.go +++ b/lnwallet/wallet.go @@ -31,6 +31,7 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chanfunding" "github.com/lightningnetwork/lnd/lnwallet/chanvalidate" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/shachain" "github.com/lightningnetwork/lnd/tlv" ) @@ -1464,6 +1465,26 @@ func (l *LightningWallet) initOurContribution(reservation *ChannelReservation, return err } + // A dev build can generate this node's secrets without a shachain on + // channels whose peer stores them all. The producer keeps the same + // root, and the taproot nonce producer is derived from the new + // producer exactly as it will be after a restart. + chanType := reservation.partialState.ChanType + if l.Cfg.IndependentSecretsSender && chanType.HasIndependentSecrets() { + producer, err = shachain.NewIndependentProducerFrom(producer) + if err != nil { + return err + } + taprootNonceProducer, err = channeldb.DeriveMusig2Shachain( + producer, + ) + if err != nil { + return err + } + reservation.partialState.ChanType |= + channeldb.LocalIndependentSecretsBit + } + firstPreimage, err := producer.AtIndex(0) if err != nil { return err From f6cec6a5175fea6101bba33a9319c539b7e91dc4 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:59 +0200 Subject: [PATCH 14/24] htlcswitch: run the retransmission tests with independent secrets Every TestChannelRetransmission case now also runs on channels with option_independent_secrets on which both sides send secrets without a shachain: links exchange real messages, are restarted from the database, reestablish and retransmit revoke_and_ack. --- htlcswitch/link_test.go | 21 +++++++++++++++- htlcswitch/test_utils.go | 54 ++++++++++++++++++++++++++++++++-------- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/htlcswitch/link_test.go b/htlcswitch/link_test.go index 02dab1edd1a..be537fea8fb 100644 --- a/htlcswitch/link_test.go +++ b/htlcswitch/link_test.go @@ -3981,9 +3981,12 @@ func TestChannelRetransmission(t *testing.T) { }, }, } - paymentWithRestart := func(t *testing.T, messages []expectedMessage) { + paymentWithRestart := func(t *testing.T, messages []expectedMessage, + extraChanType ...channeldb.ChannelType) { + channels, restoreChannelsFromDb, err := createClusterChannels( t, btcutil.SatoshiPerBitcoin*5, btcutil.SatoshiPerBitcoin*5, + extraChanType..., ) if err != nil { t.Fatalf("unable to create channel: %v", err) @@ -4119,11 +4122,27 @@ func TestChannelRetransmission(t *testing.T) { } } + // Every case also runs on channels with option_independent_secrets + // on which both sides generate their secrets without a shachain: the + // reestablish, the retransmitted revoke_and_ack and the restart from + // the database must all work with secrets that are stored one by one. + independent := channeldb.IndependentSecretsBit | + channeldb.LocalIndependentSecretsBit for _, test := range retransmissionTests { passed := t.Run(test.name, func(t *testing.T) { paymentWithRestart(t, test.messages) }) + if !passed { + break + } + passed = t.Run("independent secrets/"+test.name, + func(t *testing.T) { + paymentWithRestart( + t, test.messages, independent, + ) + }, + ) if !passed { break } diff --git a/htlcswitch/test_utils.go b/htlcswitch/test_utils.go index 3bcb3a074e8..fc6111572a0 100644 --- a/htlcswitch/test_utils.go +++ b/htlcswitch/test_utils.go @@ -131,11 +131,21 @@ type testLightningChannel struct { // representations. // // TODO(roasbeef): need to factor out, similar func re-used in many parts of codebase +// +// Any extraChanType bits are added to the channel type of both sides. With +// LocalIndependentSecretsBit, both sides generate their per-commitment +// secrets with a shachain.IndependentProducer. func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, aliceAmount, bobAmount, aliceReserve, bobReserve btcutil.Amount, - chanID lnwire.ShortChannelID) (*testLightningChannel, + chanID lnwire.ShortChannelID, + extraChanType ...channeldb.ChannelType) (*testLightningChannel, *testLightningChannel, error) { + chanType := channeldb.SingleFunderTweaklessBit + for _, extra := range extraChanType { + chanType |= extra + } + aliceKeyPriv, aliceKeyPub := btcec.PrivKeyFromBytes(alicePrivKey) bobKeyPriv, bobKeyPub := btcec.PrivKeyFromBytes(bobPrivKey) @@ -224,7 +234,16 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, if err != nil { return nil, nil, err } - bobPreimageProducer := shachain.NewRevocationProducer(*bobRoot) + var bobPreimageProducer shachain.Producer = shachain. + NewRevocationProducer(*bobRoot) + if chanType.HasLocalIndependentSecrets() { + bobPreimageProducer, err = shachain.NewIndependentProducerFrom( + bobPreimageProducer, + ) + if err != nil { + return nil, nil, err + } + } bobFirstRevoke, err := bobPreimageProducer.AtIndex(0) if err != nil { return nil, nil, err @@ -235,7 +254,15 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, if err != nil { return nil, nil, err } - alicePreimageProducer := shachain.NewRevocationProducer(*aliceRoot) + var alicePreimageProducer shachain.Producer = shachain. + NewRevocationProducer(*aliceRoot) + if chanType.HasLocalIndependentSecrets() { + alicePreimageProducer, err = shachain. + NewIndependentProducerFrom(alicePreimageProducer) + if err != nil { + return nil, nil, err + } + } aliceFirstRevoke, err := alicePreimageProducer.AtIndex(0) if err != nil { return nil, nil, err @@ -244,8 +271,7 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, aliceCommitTx, bobCommitTx, err := lnwallet.CreateCommitmentTxns( aliceAmount, bobAmount, &aliceCfg, &bobCfg, aliceCommitPoint, - bobCommitPoint, *fundingTxIn, channeldb.SingleFunderTweaklessBit, - isAliceInitiator, 0, + bobCommitPoint, *fundingTxIn, chanType, isAliceInitiator, 0, ) if err != nil { return nil, nil, err @@ -291,17 +317,20 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, CommitSig: bytes.Repeat([]byte{1}, 71), } + aliceSecretStore := channeldb.NewRemoteSecretStore(chanType) + bobSecretStore := channeldb.NewRemoteSecretStore(chanType) + aliceChannelState := &channeldb.OpenChannel{ LocalChanCfg: aliceCfg, RemoteChanCfg: bobCfg, IdentityPub: aliceKeyPub, FundingOutpoint: *prevOut, - ChanType: channeldb.SingleFunderTweaklessBit, + ChanType: chanType, IsInitiator: isAliceInitiator, Capacity: channelCapacity, RemoteCurrentRevocation: bobCommitPoint, RevocationProducer: alicePreimageProducer, - RevocationStore: shachain.NewRevocationStore(), + RevocationStore: aliceSecretStore, LocalCommitment: aliceCommit, RemoteCommitment: aliceCommit, ShortChannelID: chanID, @@ -315,12 +344,12 @@ func createTestChannel(t *testing.T, alicePrivKey, bobPrivKey []byte, RemoteChanCfg: aliceCfg, IdentityPub: bobKeyPub, FundingOutpoint: *prevOut, - ChanType: channeldb.SingleFunderTweaklessBit, + ChanType: chanType, IsInitiator: !isAliceInitiator, Capacity: channelCapacity, RemoteCurrentRevocation: aliceCommitPoint, RevocationProducer: bobPreimageProducer, - RevocationStore: shachain.NewRevocationStore(), + RevocationStore: bobSecretStore, LocalCommitment: bobCommit, RemoteCommitment: bobCommit, ShortChannelID: chanID, @@ -871,14 +900,16 @@ type clusterChannels struct { // createClusterChannels creates lightning channels which are needed for // network cluster to be initialized. -func createClusterChannels(t *testing.T, aliceToBob, bobToCarol btcutil.Amount) ( - *clusterChannels, func() (*clusterChannels, error), error) { +func createClusterChannels(t *testing.T, aliceToBob, bobToCarol btcutil.Amount, + extraChanType ...channeldb.ChannelType) (*clusterChannels, + func() (*clusterChannels, error), error) { _, _, firstChanID, secondChanID := genIDs() // Create lightning channels between Alice<->Bob and Bob<->Carol aliceChannel, firstBobChannel, err := createTestChannel(t, alicePrivKey, bobPrivKey, aliceToBob, aliceToBob, 0, 0, firstChanID, + extraChanType..., ) if err != nil { return nil, nil, fmt.Errorf("unable to create "+ @@ -887,6 +918,7 @@ func createClusterChannels(t *testing.T, aliceToBob, bobToCarol btcutil.Amount) secondBobChannel, carolChannel, err := createTestChannel(t, bobPrivKey, carolPrivKey, bobToCarol, bobToCarol, 0, 0, secondChanID, + extraChanType..., ) if err != nil { return nil, nil, fmt.Errorf("unable to create "+ From 7f6460067f72d720683eba0533162bf270bd24db Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:59 +0200 Subject: [PATCH 15/24] itest: add an independent secrets test Two nodes, one sending independent secrets with the dev option, open a channel with option_independent_secrets by default, pay across restarts, and the sender then broadcasts a revoked state, which the other node sweeps. The fork's harness can't start lnd yet (it needs a BLAKE2b Bitcoin Knots backend), so this runs once it can. --- itest/list_on_test.go | 4 + itest/lnd_independent_secrets_test.go | 130 ++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 itest/lnd_independent_secrets_test.go diff --git a/itest/list_on_test.go b/itest/list_on_test.go index ae2ae75f03e..3cf24748cc1 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -335,6 +335,10 @@ var allTestCases = []*lntest.TestCase{ Name: "revoked uncooperative close retribution remote hodl", TestFunc: testRevokedCloseRetributionRemoteHodl, }, + { + Name: "independent secrets", + TestFunc: testIndependentSecrets, + }, { Name: "multi-hop send to route", TestFunc: testMultiHopSendToRoute, diff --git a/itest/lnd_independent_secrets_test.go b/itest/lnd_independent_secrets_test.go new file mode 100644 index 00000000000..ae42fb3cc50 --- /dev/null +++ b/itest/lnd_independent_secrets_test.go @@ -0,0 +1,130 @@ +package itest + +import ( + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntest/node" + "github.com/stretchr/testify/require" +) + +// testIndependentSecrets runs a channel with option_independent_secrets +// between two real nodes, one of which reveals per-commitment secrets that +// have no shachain relationship, and then has that node broadcast a revoked +// state. +// +// Bob runs with --dev.independent-secrets-sender, so on such a channel he +// generates his secrets with a KDF instead of a shachain. Carol runs the +// defaults. The test checks that: +// - both nodes signal the add-on by default and negotiate it, and report it +// over RPC; +// - payments flow while Carol accepts and stores Bob's secrets, also across +// restarts of both nodes; +// - when Bob broadcasts a revoked commitment, Carol sweeps it with the +// secret she stored for that state. +func testIndependentSecrets(ht *lntest.HarnessTest) { + const ( + chanAmt = funding.MaxBtcFundingAmount + paymentAmt = 10_000 + numInvoices = 6 + ) + + commitType := lnrpc.CommitmentType_ANCHORS + nodeArgs := lntest.NodeArgsForCommitType(commitType) + + // Carol is the honest party. --nolisten and a long backoff keep Bob + // from reconnecting to her before his breach, which would trigger data + // loss protection instead. + carol := ht.NewNode("Carol", append( + nodeArgs, "--hodl.exit-settle", "--nolisten", "--minbackoff=1h", + )) + bob := ht.NewNode("Bob", append( + nodeArgs, "--dev.independent-secrets-sender", + )) + ht.ConnectNodes(carol, bob) + ht.FundCoins(btcutil.SatoshiPerBitcoin, carol) + + chanPoint := ht.OpenChannel(carol, bob, lntest.OpenChannelParams{ + CommitmentType: commitType, + Amt: chanAmt, + }) + + // Neither node was told to: the add-on is on by default. + assertIndependentSecrets := func(hn *node.HarnessNode) { + ch := ht.QueryChannelByChanPoint(hn, chanPoint) + require.True(ht, ch.IndependentSecrets, + "%s: channel without option_independent_secrets", + hn.Name()) + } + assertIndependentSecrets(carol) + assertIndependentSecrets(bob) + + bobPayReqs, _, _ := ht.CreatePayReqs(bob, paymentAmt, numInvoices) + + // Each payment has Bob reveal secrets for two of his commitments. + ht.CompletePaymentRequests(carol, bobPayReqs[:numInvoices/2]) + bobChan := ht.AssertChannelLocalBalance( + bob, chanPoint, 3*paymentAmt, + ) + + // Both nodes come back with the channel intact: Carol still has every + // secret, and Bob regenerates his from the stored root. + ht.RestartNode(carol) + ht.RestartNode(bob) + ht.EnsureConnected(carol, bob) + ht.AssertChannelActive(carol, chanPoint) + assertIndependentSecrets(carol) + assertIndependentSecrets(bob) + + // Keep Bob's current state, to broadcast once it has been revoked. + bobStateNum := ht.QueryChannelByChanPoint(bob, chanPoint).NumUpdates + require.Equal(ht, bobChan.NumUpdates, bobStateNum) + ht.BackupDB(bob) + ht.EnsureConnected(carol, bob) + ht.AssertChannelExists(carol, chanPoint) + + ht.CompletePaymentRequests(carol, bobPayReqs[numInvoices/2:]) + ht.AssertChannelLocalBalance(bob, chanPoint, numInvoices*paymentAmt) + + // Bob travels back to the revoked state and force closes with it. + ht.RestartNodeAndRestoreDB(bob) + ht.AssertChannelCommitHeight(bob, chanPoint, int(bobStateNum)) + + _, closeUpd := ht.CloseChannelAssertPending(bob, chanPoint, true) + pending := closeUpd.GetClosePending() + require.NotNil(ht, pending) + breachTXID, err := chainhash.NewHash(pending.Txid) + require.NoError(ht, err) + + // Carol must keep what she needs for retribution across a restart. + ht.RestartNode(carol) + + block := ht.MineBlocksAndAssertNumTxes(1, 1)[0] + ht.AssertTxInBlock(block, *breachTXID) + + // On an anchor channel the first two outputs are anchors, so Bob's + // to_remote output is the third. Carol's justice transaction spends it + // together with Bob's revoked to_local output, which she can only do + // with the secret Bob revealed for this state. + toRemote := wire.OutPoint{Hash: *breachTXID, Index: 2} + justiceTx := ht.AssertOutpointInMempool(toRemote) + for _, txIn := range justiceTx.TxIn { + require.Equal(ht, *breachTXID, txIn.PreviousOutPoint.Hash, + "justice tx not spending the breach transaction") + } + require.GreaterOrEqual(ht, len(justiceTx.TxIn), 2, + "justice tx should sweep both of Bob's outputs") + + ht.RestartNode(carol) + ht.AssertNumPendingSweeps(carol, 1) + + block = ht.MineBlocksAndAssertNumTxes(1, 1)[0] + ht.AssertTxInBlock(block, justiceTx.TxHash()) + ht.AssertNodeNumChannels(carol, 0) + + ht.MineBlocks(defaultCSV - 2) + ht.AssertNumPendingForceClose(bob, 0) +} From f12ba93bd22b6a7573382136ff18e5c214c9bfa0 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Mon, 5 Oct 2026 15:39:59 +0200 Subject: [PATCH 16/24] docs: describe option_independent_secrets What the add-on does, why, how to turn it off, the downgrade caveat, the RPC field and the dev option. --- docs/independent-secrets.md | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/independent-secrets.md diff --git a/docs/independent-secrets.md b/docs/independent-secrets.md new file mode 100644 index 00000000000..2200fbd65af --- /dev/null +++ b/docs/independent-secrets.md @@ -0,0 +1,84 @@ +# Independent per-commitment secrets + +Lightning Fork supports `option_independent_secrets` (feature bits 266/267, +proposed in a draft bLIP). +On a channel with it, this node accepts per-commitment secrets from its peer +that do not come from a shachain, and stores every one of them. + +## Why + +Each time a channel updates, each side reveals a secret that cancels its old +state. If it later broadcasts that old state anyway, the other side uses the +secret to take the whole channel. + +BOLT 3 requires those secrets to come from one seed (a shachain), so the +receiver can store them in 49 entries. A node whose keys are held k-of-n then +has to give that seed to every signer, and any one signer can pass it to the +channel peer, which can then steal the channel the moment the node broadcasts +its current state. Secrets that need not come from one seed can be generated +jointly by the signers, so no single signer ever holds them. + +This node's part is to accept such secrets. It does not change how this node +generates its own: they still come from its shachain. + +## What it does + +- **Signalled by default.** The optional bit 267 is set in `init` and + `node_announcement`. A new channel uses the add-on when both peers signal it + and agree on it in `channel_type`. With a peer that doesn't signal it, the + channel opens the normal way. +- **Storage.** Each secret is stored with the revocation log entry of the + commitment it revokes, written in the same database transaction. That's 38 + bytes per revoked state (a 32-byte secret in a TLV record), next to data the + node keeps for every revoked state anyway. +- **Checks.** A secret must generate the point of the commitment it revokes and + be a valid private key. It doesn't have to be related to earlier secrets. +- **Breaches.** When the chain watcher sees a revoked state on chain, it builds + the breach retribution from that state's revocation log entry, secret + included; the watchtower client does the same for its backups. +- **Static channel backups** are unchanged. They never carried revocation + state, so a node restored from one can't punish a breach, with or without + the add-on. +- **Existing channels** keep their type and behave exactly as before. + +`lncli listchannels` and `lncli pendingchannels` report the add-on per channel +as `independent_secrets`. + +## Turning it off + +``` +[protocol] +protocol.no-independent-secrets=true +``` + +New channels then keep requiring shachain secrets. Channels already opened with +the add-on keep it. + +## Downgrading + +Downgrading to a version of Lightning Fork without this change is not +supported once any channel has used the add-on. Such a version can't read the +channel's stored state, and that stays true after the channel closes, because +lnd keeps a copy of a closed channel's state. While the channel is open or +still being resolved, the older version refuses to start; afterwards, its +`closedchannels` and `pendingchannels` calls fail. + +## Testing against it + +Integration builds (`-tags integration`) have a dev option, +`--dev.independent-secrets-sender`, that makes this node generate its own +secrets on new channels with the add-on using a key derivation function +instead of a shachain: + +``` +secret(i) = HMAC-SHA256(root, "independent-per-commitment-secret" || + i as 8 bytes big-endian || counter as 1 byte) +``` + +The counter starts at 0 and only goes up in the negligible case that the +result is not a valid private key. That exercises the peer's support for +independent secrets with a real node. The secrets are regenerated from the +root lnd already stores; the channel records that it uses them in a local +channel type bit, so it keeps using them for its whole life, even if the +option is later turned off. It is not available in release builds: a single +signer gains nothing from it. From a4a09cf3b3d36c036801274fa4684a04c4048f57 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Fri, 16 Oct 2026 02:53:06 +0200 Subject: [PATCH 17/24] channeldb, lnwallet: keep the secret for splices' revocation logs While a splice is pending, the peer has a commitment on each funding output at the same number, and its revoked ones on a splice's funding go into that splice's own revocation log. A breach on that funding, or a watchtower backup of it, is built from the splice's log entry. Those entries were written without the per-commitment secret, so on a channel with option_independent_secrets the commitment couldn't be punished. One secret revokes the commitment with that number on every funding output. AdvanceCommitChainTail now passes it to applySpliceWrites, which stores it with each splice's entry at that number. As a fallback for an entry without it, revokedCommitSecret takes the secret from the channel's own log at the same number, which is written in the same transaction. The new tests run the splice breach and watchtower tests on a channel with the add-on, where the peer reveals random secrets. They fail without this change. --- channeldb/channel.go | 11 +- channeldb/splice_records.go | 18 +- docs/independent-secrets.md | 3 + lnwallet/channel.go | 44 +++- lnwallet/independent_secrets_splice_test.go | 217 ++++++++++++++++++++ 5 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 lnwallet/independent_secrets_splice_test.go diff --git a/channeldb/channel.go b/channeldb/channel.go index 305569b7aba..c17364baf1e 100644 --- a/channeldb/channel.go +++ b/channeldb/channel.go @@ -2771,7 +2771,10 @@ func (c *OpenChannel) UpdateCommitment(newCommitment *ChannelCommitment, return fmt.Errorf("unable to store chan "+ "revocations: %v", err) } - err = applySpliceWrites(chanBucket, splices, false, false) + err = applySpliceWrites( + chanBucket, splices, false, false, + fn.None[[32]byte](), 0, + ) if err != nil { return err } @@ -3564,7 +3567,10 @@ func (c *OpenChannel) AppendRemoteCommitChain(diff *CommitDiff, return err } - return applySpliceWrites(chanBucket, splices, false, false) + return applySpliceWrites( + chanBucket, splices, false, false, + fn.None[[32]byte](), 0, + ) }, func() {}) } @@ -3817,6 +3823,7 @@ func (c *OpenChannel) AdvanceCommitChainTail(fwdPkg *FwdPkg, } err = applySpliceWrites( chanBucket, splices, true, c.Db.parent.noRevLogAmtData, + commitSecret, c.RemoteCommitment.CommitHeight, ) if err != nil { return err diff --git a/channeldb/splice_records.go b/channeldb/splice_records.go index da1c34e806e..b1435505b34 100644 --- a/channeldb/splice_records.go +++ b/channeldb/splice_records.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/fn/v2" graphdb "github.com/lightningnetwork/lnd/graph/db" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/kvdb" @@ -1137,8 +1138,15 @@ func (c *OpenChannel) SpliceRevocationsAt(height uint64) ([]chainhash.Hash, // pending splices: our new commitments, the peer's new ones with our // signatures, or, with revoke, the peer's revoked ones moved into their // revocation logs. +// +// On a channel with option_independent_secrets, commitSecret is the peer's +// per-commitment secret for the commitment number secretHeight. One secret +// revokes the commitment with that number on every funding output, so it is +// stored with each splice's log entry at that number too: a breach on a +// splice's funding is looked up in the splice's log. func applySpliceWrites(chanBucket kvdb.RwBucket, writes []SpliceCommitWrite, - revoke bool, noAmtData bool) error { + revoke bool, noAmtData bool, commitSecret fn.Option[[32]byte], + secretHeight uint64) error { for _, w := range writes { r, err := fetchSpliceRecord(chanBucket, w.Txid) @@ -1173,9 +1181,13 @@ func applySpliceWrites(chanBucket kvdb.RwBucket, writes []SpliceCommitWrite, if err != nil { return err } - err = putRevocationLog( + secret := fn.None[[32]byte]() + if r.RemoteTail.CommitHeight == secretHeight { + secret = commitSecret + } + err = putRevocationLogWithSecret( log, r.RemoteTail, w.OurOutputIndex, - w.TheirOutputIndex, noAmtData, + w.TheirOutputIndex, noAmtData, secret, ) if err != nil { return err diff --git a/docs/independent-secrets.md b/docs/independent-secrets.md index 2200fbd65af..54771532e4a 100644 --- a/docs/independent-secrets.md +++ b/docs/independent-secrets.md @@ -36,6 +36,9 @@ generates its own: they still come from its shachain. - **Breaches.** When the chain watcher sees a revoked state on chain, it builds the breach retribution from that state's revocation log entry, secret included; the watchtower client does the same for its backups. +- **Splices.** While a splice is pending, the peer has a commitment on each + funding output at the same number, and one secret revokes all of them. The + secret is stored with each of their log entries, the splice's own included. - **Static channel backups** are unchanged. They never carried revocation state, so a node restored from one can't punish a breach, with or without the add-on. diff --git a/lnwallet/channel.go b/lnwallet/channel.go index be31b861416..a862ffa31f1 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2130,6 +2130,24 @@ type BreachRetribution struct { ChanType channeldb.ChannelType } +// logCommitSecret returns the per-commitment secret stored with a revocation +// log entry, if any. +func logCommitSecret(revokedLog *channeldb.RevocationLog) *chainhash.Hash { + if revokedLog == nil { + return nil + } + + var secret *chainhash.Hash + revokedLog.CommitSecret.WhenSome( + func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { + h := chainhash.Hash(r.Val) + secret = &h + }, + ) + + return secret +} + // revokedCommitSecret returns the remote party's per-commitment secret for the // revoked commitment at stateNum. On channels with option_independent_secrets // the secret is stored with the commitment's revocation log entry; otherwise @@ -2138,20 +2156,24 @@ func revokedCommitSecret(chanState *channeldb.OpenChannel, revokedLog *channeldb.RevocationLog, stateNum uint64) (*chainhash.Hash, error) { - if revokedLog != nil { - var secret *chainhash.Hash - revokedLog.CommitSecret.WhenSome( - func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { - h := chainhash.Hash(r.Val) - secret = &h - }, - ) - if secret != nil { - return secret, nil - } + if secret := logCommitSecret(revokedLog); secret != nil { + return secret, nil } if chanState.UsesIndependentSecrets() { + // The entry may be in a splice's log, kept for a commitment on + // another funding output, and written without the secret (by + // a version that didn't store it there). The channel's own log + // has an entry at every revoked number, written in the same + // transaction, and one secret revokes the commitment with that + // number on every funding output. + own, _, err := chanState.FindPreviousState(stateNum) + if err == nil { + if secret := logCommitSecret(own); secret != nil { + return secret, nil + } + } + return nil, fmt.Errorf("revocation log for state %d has no "+ "per-commitment secret", stateNum) } diff --git a/lnwallet/independent_secrets_splice_test.go b/lnwallet/independent_secrets_splice_test.go new file mode 100644 index 00000000000..cfbb1b22eb4 --- /dev/null +++ b/lnwallet/independent_secrets_splice_test.go @@ -0,0 +1,217 @@ +package lnwallet + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/shachain" + "github.com/lightningnetwork/lnd/tlv" + "github.com/stretchr/testify/require" +) + +// independentSplicedPair is splicedPair on a channel with +// option_independent_secrets, where Bob reveals random secrets: Alice can +// only punish him with the secrets she stored. +func independentSplicedPair(t *testing.T, aliceDelta, + bobDelta btcutil.Amount) (*LightningChannel, *LightningChannel, + chainhash.Hash, *randomSecretProducer) { + + t.Helper() + + alice, bob, err := CreateTestChannels( + t, spliceChanTypes["anchors"]|channeldb.IndependentSecretsBit, + ) + require.NoError(t, err) + + _, ok := alice.channelState.RevocationStore.(*shachain.IndependentStore) + require.True(t, ok, "expected an IndependentStore") + + producer := newRandomSecretProducer(bob.channelState.RevocationProducer) + bob.channelState.RevocationProducer = producer + + withHTLCs(t, alice, bob) + + keys := newSpliceKeys(t, alice, bob) + txid := chainhash.Hash{0x5c} + addSplice(t, alice, bob, keys, txid, aliceDelta, bobDelta) + exchangeSpliceCommitments(t, alice, bob, txid) + + return alice, bob, txid, producer +} + +// requireLogSecret checks that a revocation log entry carries the secret Bob +// revealed for its commitment number. +func requireLogSecret(t *testing.T, rl *channeldb.RevocationLog, + producer *randomSecretProducer, height uint64) { + + t.Helper() + + want, err := producer.AtIndex(height) + require.NoError(t, err) + + var got [32]byte + rl.CommitSecret.WhenSome( + func(r tlv.RecordT[tlv.TlvType65537, [32]byte]) { + got = r.Val + }, + ) + require.True(t, rl.CommitSecret.IsSome(), "height %d", height) + require.Equal(t, [32]byte(*want), got, "height %d", height) +} + +// TestIndependentSecretsSpliceBreach is TestSpliceBreachAfterLock on a +// channel with option_independent_secrets: every revoked commitment of +// Bob's can be punished, on the splice's funding while it was pending, on +// the old funding at the lock, and on the new one after it, including after +// a restart. While a splice is pending, Alice logs Bob's revoked commitments +// on its funding in the splice's own log, which must carry the secret too. +func TestIndependentSecretsSpliceBreach(t *testing.T) { + t.Parallel() + + alice, bob, txid, producer := independentSplicedPair( + t, 100_000_000, 0, + ) + + type revoked struct { + height uint64 + tx *wire.MsgTx + bob lnwire.MilliSatoshi + alice lnwire.MilliSatoshi + } + capture := func(c *commitment) revoked { + return revoked{c.height, c.txn, c.ourBalance, c.theirBalance} + } + + // Pending: Bob's commitment at his current height, on both fundings. + p, err := bob.pendingFunding(txid) + require.NoError(t, err) + h := bob.commitChains.Local.tail().height + pendingOld := capture(bob.commitChains.Local.tail()) + pendingNew := capture(p.local[h]) + + htlc, _ := createHTLC(3, lnwire.NewMSatFromSatoshis(400_000)) + addAndReceiveHTLC(t, alice, bob, htlc, nil) + forceBatchTransition(t, alice, bob) + + // Bob's revoked commitment on the splice's funding is in the splice's + // log, with the random secret he revealed for it. + rl, err := alice.channelState.FetchSpliceRevocationLog(txid, h) + require.NoError(t, err) + requireLogSecret(t, rl, producer, h) + + // Bob's commitment on the old funding when the splice locks. + atLock := capture(bob.commitChains.Local.tail()) + scid := lnwire.ShortChannelID{BlockHeight: 900_300} + require.NoError(t, alice.LockPendingFunding(txid, scid)) + require.NoError(t, bob.LockPendingFunding(txid, scid)) + + // After the lock, on the new funding. + afterLock := capture(bob.commitChains.Local.tail()) + require.NoError(t, ForceStateTransition(alice, bob)) + require.NoError(t, ForceStateTransition(alice, bob)) + + all := []revoked{pendingOld, pendingNew, atLock, afterLock} + for _, r := range all { + requireBreachFor(t, alice, r.height, r.tx, r.bob, r.alice) + } + + // And after a restart, from the database alone. + alice, err = restartChannel(alice) + require.NoError(t, err) + for _, r := range all { + requireBreachFor(t, alice, r.height, r.tx, r.bob, r.alice) + } +} + +// TestIndependentSecretsSpliceTower is TestSpliceTowerRetributions on a +// channel with option_independent_secrets: a watchtower backup can be built +// for every commitment of Bob's at a revoked height, the one in the +// channel's own log and one per splice log holding the height, each with the +// secret Bob revealed. +func TestIndependentSecretsSpliceTower(t *testing.T) { + t.Parallel() + + alice, bob, txid, producer := independentSplicedPair( + t, 100_000_000, 0, + ) + + h := bob.commitChains.Local.tail().height + + htlc, _ := createHTLC(3, lnwire.NewMSatFromSatoshis(400_000)) + addAndReceiveHTLC(t, alice, bob, htlc, nil) + forceBatchTransition(t, alice, bob) + + atLockHeight := bob.commitChains.Local.tail().height + scid := lnwire.ShortChannelID{BlockHeight: 900_300} + require.NoError(t, alice.LockPendingFunding(txid, scid)) + require.NoError(t, bob.LockPendingFunding(txid, scid)) + require.NoError(t, ForceStateTransition(alice, bob)) + require.NoError(t, ForceStateTransition(alice, bob)) + + for _, height := range []uint64{h, atLockHeight} { + secret, err := producer.AtIndex(height) + require.NoError(t, err) + point := input.ComputeCommitmentPoint(secret[:]) + + splices, err := alice.channelState.SpliceRevocationsAt(height) + require.NoError(t, err) + require.NotEmpty(t, splices, "height %d", height) + + for _, splice := range append( + []chainhash.Hash{{}}, splices..., + ) { + br, err := NewTowerBreachRetribution( + alice.channelState, height, splice, + fn.None[AuxLeafStore](), + fn.None[AuxContractResolver](), + ) + require.NoError(t, err, "height %d splice %v", + height, splice) + require.True(t, point.IsEqual(br.KeyRing.CommitPoint), + "height %d splice %v: wrong commitment point", + height, splice) + } + } +} + +// TestIndependentSecretsLogWithoutSecret checks the fallback for a revocation +// log entry without the secret, such as a splice's entry written by a +// version that didn't store it there: the secret is taken from the channel's +// own log at the same commitment number, since one secret revokes the +// commitment with that number on every funding output. +func TestIndependentSecretsLogWithoutSecret(t *testing.T) { + t.Parallel() + + alice, bob, err := CreateTestChannels( + t, independentSecretsChanType(true), + ) + require.NoError(t, err) + + producer := newRandomSecretProducer(bob.channelState.RevocationProducer) + bob.channelState.RevocationProducer = producer + for i := 0; i < 2; i++ { + require.NoError(t, sendAndSettle(t, alice, bob, i)) + } + + const height = 2 + want, err := producer.AtIndex(height) + require.NoError(t, err) + + got, err := revokedCommitSecret( + alice.channelState, &channeldb.RevocationLog{}, height, + ) + require.NoError(t, err) + require.Equal(t, *want, *got) + + // A number that was never revoked has no secret anywhere. + _, err = revokedCommitSecret( + alice.channelState, &channeldb.RevocationLog{}, 1000, + ) + require.ErrorContains(t, err, "no per-commitment secret") +} From 9b85c75f1f3c7ca745de0b66ff98d8a3a089b1f1 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Sat, 17 Oct 2026 16:37:06 +0200 Subject: [PATCH 18/24] channeldb: mark the database once it stores an independent secrets channel An older version can't read the revocation state of a channel with option_independent_secrets. It used to fail to decode the channel while it was open or being resolved, and once it had closed, start and fail its closedchannels and pendingchannels calls. Database version 39 now marks a database that has stored such a channel: syncNewChannel moves the database there, in the same transaction, when it saves a channel with the add-on, new or restored from a backup. So an older version refuses to start with the usual "Refusing to revert" error. Like the splicing mark (38), it changes no data. It is never run as a migration, so a node that never has such a channel stays at 37 or 38 and can still go back to an older release. A database at 39 opens with splicing on or off, and OpenableDBVersions lists it for copying tools. --- channeldb/channel.go | 10 ++ channeldb/db.go | 50 +++++-- channeldb/db_splice_versions_test.go | 11 +- channeldb/independent_secrets_version_test.go | 122 ++++++++++++++++++ channeldb/migration39/migration.go | 22 ++++ channeldb/splicing_version_test.go | 5 +- docs/independent-secrets.md | 17 ++- 7 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 channeldb/independent_secrets_version_test.go create mode 100644 channeldb/migration39/migration.go diff --git a/channeldb/channel.go b/channeldb/channel.go index c17364baf1e..9dbc1b375d1 100644 --- a/channeldb/channel.go +++ b/channeldb/channel.go @@ -2684,6 +2684,16 @@ func syncNewChannel(tx kvdb.RwTx, c *OpenChannel, addrs []net.Addr) error { return err } + // An older version can't read the revocation state of a channel with + // option_independent_secrets: from now on it must refuse the database + // rather than fail when it reaches the channel. This covers channels + // restored from a backup too. + if c.ChanType.HasIndependentSecrets() { + if err := markIndependentSecrets(tx); err != nil { + return err + } + } + nodeInfoBucket, err := tx.CreateTopLevelBucket(nodeInfoBucket) if err != nil { return err diff --git a/channeldb/db.go b/channeldb/db.go index 462c7b33188..4f7caeb526d 100644 --- a/channeldb/db.go +++ b/channeldb/db.go @@ -34,6 +34,7 @@ import ( "github.com/lightningnetwork/lnd/channeldb/migration36" "github.com/lightningnetwork/lnd/channeldb/migration37" "github.com/lightningnetwork/lnd/channeldb/migration38" + "github.com/lightningnetwork/lnd/channeldb/migration39" "github.com/lightningnetwork/lnd/channeldb/migration_01_to_11" "github.com/lightningnetwork/lnd/clock" graphdb "github.com/lightningnetwork/lnd/graph/db" @@ -349,6 +350,15 @@ var ( number: splicingDBVersion, migration: migration38.MarkSplicing, }, + { + // Changes no data, and is never run as a migration: + // the database moves here when it first stores a + // channel with option_independent_secrets, so that an + // older binary, which can't read that channel's + // revocation state, refuses it at startup. + number: independentSecretsDBVersion, + migration: migration39.MarkIndependentSecrets, + }, } // optionalVersions stores all optional migrations that are applied @@ -529,7 +539,7 @@ func (d *DB) Wipe() error { return err } - return initChannelDB(d.Backend, getLatestDBVersion(dbVersions)) + return initChannelDB(d.Backend, getLatestDBVersion(targetVersions(true))) } // initChannelDB creates and initializes a fresh version of channeldb. In the @@ -2157,13 +2167,15 @@ func LatestDBVersion() uint32 { } // OpenableDBVersions returns the channel database versions this build opens -// as they are: the latest, and the one before the splicing mark, at which a -// node that never ran with splicing stays. A tool that copies a database -// for this build (lndinit's SQLite conversion) accepts either. +// as they are: the one before the splicing mark, at which a node that never +// ran with splicing stays, the splicing mark, and the mark of a database +// with a channel with option_independent_secrets. A tool that copies a +// database for this build (lndinit's SQLite conversion) accepts any of them. func OpenableDBVersions() []uint32 { return []uint32{ getLatestDBVersion(targetVersions(false)), getLatestDBVersion(targetVersions(true)), + independentSecretsDBVersion, } } @@ -2187,14 +2199,36 @@ func markSplicing(tx kvdb.RwTx) error { return putMeta(meta, tx) } -// targetVersions returns the migrations to apply: all of them with splicing -// on, all but the splicing mark without. +// independentSecretsDBVersion is the database version that marks a node +// that has stored a channel with option_independent_secrets. +const independentSecretsDBVersion = 39 + +// markIndependentSecrets moves the database to the independent secrets +// version, in tx, if it is below it. +func markIndependentSecrets(tx kvdb.RwTx) error { + meta := &Meta{} + if err := FetchMeta(meta, tx); err != nil { + return err + } + if meta.DbVersionNumber >= independentSecretsDBVersion { + return nil + } + meta.DbVersionNumber = independentSecretsDBVersion + + return putMeta(meta, tx) +} + +// targetVersions returns the migrations to apply: all but the independent +// secrets mark with splicing on, and also without the splicing mark when +// it's off. The independent secrets mark is never run as a migration: +// markIndependentSecrets applies it with the first such channel. func targetVersions(splicing bool) []mandatoryVersion { + versions := dbVersions[:len(dbVersions)-1] if splicing { - return dbVersions + return versions } - return dbVersions[:len(dbVersions)-1] + return versions[:len(versions)-1] } func getLatestDBVersion(versions []mandatoryVersion) uint32 { diff --git a/channeldb/db_splice_versions_test.go b/channeldb/db_splice_versions_test.go index d8a7574111c..d79e8b8d15f 100644 --- a/channeldb/db_splice_versions_test.go +++ b/channeldb/db_splice_versions_test.go @@ -7,12 +7,15 @@ import ( ) // TestOpenableDBVersions checks the versions a copying tool must accept: -// the latest, which carries the splicing mark, and the one before it. +// the one before the splicing mark, the splicing mark, and the latest, which +// marks a database with a channel with option_independent_secrets. func TestOpenableDBVersions(t *testing.T) { t.Parallel() versions := OpenableDBVersions() - require.Equal(t, []uint32{splicingDBVersion - 1, splicingDBVersion}, - versions) - require.Equal(t, LatestDBVersion(), versions[1]) + require.Equal(t, []uint32{ + splicingDBVersion - 1, splicingDBVersion, + independentSecretsDBVersion, + }, versions) + require.Equal(t, LatestDBVersion(), versions[2]) } diff --git a/channeldb/independent_secrets_version_test.go b/channeldb/independent_secrets_version_test.go new file mode 100644 index 00000000000..39c046315ee --- /dev/null +++ b/channeldb/independent_secrets_version_test.go @@ -0,0 +1,122 @@ +package channeldb + +import ( + "testing" + + "github.com/lightningnetwork/lnd/kvdb" + "github.com/lightningnetwork/lnd/shachain" + "github.com/stretchr/testify/require" +) + +// dbVersion returns the database's version. +func dbVersion(t *testing.T, db *DB) uint32 { + t.Helper() + + meta, err := db.FetchMeta() + require.NoError(t, err) + + return meta.DbVersionNumber +} + +// TestIndependentSecretsDBVersion checks that the database moves to the +// independent secrets version when it first stores a channel with +// option_independent_secrets, new or restored, and only then; that a marked +// database still opens, with splicing on or off; and that a newer one is +// refused. An older release refuses a marked database by its own check: the +// version is past the newest one it knows. +func TestIndependentSecretsDBVersion(t *testing.T) { + t.Parallel() + + require.Equal(t, uint32(independentSecretsDBVersion), LatestDBVersion()) + require.Equal(t, uint32(splicingDBVersion), + getLatestDBVersion(targetVersions(true))) + + newChannel := func(t *testing.T, cdb *ChannelStateDB, + independent bool) { + + t.Helper() + + channel := createTestChannelState(t, cdb) + if independent { + channel.ChanType |= IndependentSecretsBit + channel.RevocationStore = shachain.NewIndependentStore() + } + require.NoError(t, channel.SyncPending(defaultAddr, 100)) + } + + t.Run("a shachain channel leaves the version", func(t *testing.T) { + t.Parallel() + + db, err := MakeTestDB(t) + require.NoError(t, err) + before := dbVersion(t, db) + require.Less(t, before, uint32(independentSecretsDBVersion)) + + newChannel(t, db.ChannelStateDB(), false) + require.Equal(t, before, dbVersion(t, db)) + }) + + t.Run("an independent secrets channel marks", func(t *testing.T) { + t.Parallel() + + db, err := MakeTestDB(t) + require.NoError(t, err) + + newChannel(t, db.ChannelStateDB(), true) + require.Equal(t, uint32(independentSecretsDBVersion), + dbVersion(t, db)) + + // A second one leaves it there. + newChannel(t, db.ChannelStateDB(), true) + require.Equal(t, uint32(independentSecretsDBVersion), + dbVersion(t, db)) + }) + + t.Run("a restored channel marks", func(t *testing.T) { + t.Parallel() + + db, err := MakeTestDB(t) + require.NoError(t, err) + + shell, err := genRandomChannelShell() + require.NoError(t, err) + shell.Chan.ChanType |= IndependentSecretsBit + require.NoError(t, db.ChannelStateDB().RestoreChannelShells( + shell, + )) + require.Equal(t, uint32(independentSecretsDBVersion), + dbVersion(t, db)) + }) + + t.Run("marked opens, newer refused", func(t *testing.T) { + t.Parallel() + + for _, splicing := range []bool{false, true} { + backend, cleanup, err := kvdb.GetTestBackend( + t.TempDir(), "cdb", + ) + require.NoError(t, err) + t.Cleanup(cleanup) + + db, err := CreateWithBackend( + backend, OptionSplicing(splicing), + ) + require.NoError(t, err) + newChannel(t, db.ChannelStateDB(), true) + + // Opening again, with splicing either way, keeps the + // mark and runs no migration. + for _, again := range []bool{false, true} { + v, err := openVersionTestDB(t, backend, again) + require.NoError(t, err) + require.Equal(t, + uint32(independentSecretsDBVersion), v) + } + + setVersionTestDB(t, backend, + independentSecretsDBVersion+1) + _, err = openVersionTestDB(t, backend, splicing) + require.ErrorIs(t, err, ErrDBReversion) + } + }) +} diff --git a/channeldb/migration39/migration.go b/channeldb/migration39/migration.go new file mode 100644 index 00000000000..dd71cedaec7 --- /dev/null +++ b/channeldb/migration39/migration.go @@ -0,0 +1,22 @@ +// Package migration39 marks a database that holds a channel with +// option_independent_secrets. +package migration39 + +import ( + "github.com/lightningnetwork/lnd/kvdb" +) + +// MarkIndependentSecrets changes nothing in the data, and is never run: the +// database moves to this version when it first stores a channel with +// option_independent_secrets, in the same transaction. Its number is the +// point: an older binary refuses a database at a version it doesn't know, with +// a clear error. Without the bump, an older binary would fail to decode such a +// channel's revocation state while the channel is open or being resolved, and +// once it had closed, start and fail its closedchannels and pendingchannels +// calls. +// +// A node that never has such a channel stays at the version before, and can +// still go back to an older release. +func MarkIndependentSecrets(_ kvdb.RwTx) error { + return nil +} diff --git a/channeldb/splicing_version_test.go b/channeldb/splicing_version_test.go index 9081556e745..a87ee34a70a 100644 --- a/channeldb/splicing_version_test.go +++ b/channeldb/splicing_version_test.go @@ -42,7 +42,8 @@ func setVersionTestDB(t *testing.T, backend kvdb.Backend, version uint32) { func TestSplicingDBVersion(t *testing.T) { t.Parallel() - require.Equal(t, uint32(splicingDBVersion), LatestDBVersion()) + require.Equal(t, uint32(splicingDBVersion), + getLatestDBVersion(targetVersions(true))) before := uint32(splicingDBVersion - 1) newBackend := func(t *testing.T) kvdb.Backend { @@ -106,7 +107,7 @@ func TestSplicingDBVersion(t *testing.T) { _, err := openVersionTestDB(t, backend, splicing) require.NoError(t, err) - setVersionTestDB(t, backend, splicingDBVersion+1) + setVersionTestDB(t, backend, LatestDBVersion()+1) _, err = openVersionTestDB(t, backend, splicing) require.ErrorIs(t, err, ErrDBReversion) } diff --git a/docs/independent-secrets.md b/docs/independent-secrets.md index 54771532e4a..565af67d717 100644 --- a/docs/independent-secrets.md +++ b/docs/independent-secrets.md @@ -60,11 +60,18 @@ the add-on keep it. ## Downgrading Downgrading to a version of Lightning Fork without this change is not -supported once any channel has used the add-on. Such a version can't read the -channel's stored state, and that stays true after the channel closes, because -lnd keeps a copy of a closed channel's state. While the channel is open or -still being resolved, the older version refuses to start; afterwards, its -`closedchannels` and `pendingchannels` calls fail. +supported once any channel has used the add-on, because such a version can't +read the channel's stored state. lnd keeps a copy of a closed channel's state, +so that stays true after the channel closes. + +To make that fail clearly, the database moves to version 39 when it first +stores a channel with the add-on, whether new or restored from a backup. An +older version then refuses to start with a clear error ("Refusing to revert +from db_version=39"). Without that, it would fail to decode the channel while +the channel is open or still being resolved, and once it has closed it would +start and fail its `closedchannels` and `pendingchannels` calls. A node that +never had such a channel stays at the version before, and can still go back +to an older release. ## Testing against it From 49c0aa3e20b5ce0031436656b74f6994c9a0812e Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Sun, 18 Oct 2026 10:51:35 +0200 Subject: [PATCH 19/24] channeldb: pick the database version marks by number targetVersions left out the independent secrets mark (39) and, with splicing off, the splicing mark (38) by their position at the end of dbVersions. The next migration appended there would have been taken for a mark and never run. Leave them out by number instead, and test that a migration appended after them still runs. Also correct two comments and the docs: a static backup doesn't carry the add-on's bit, so it's a channel being opened that marks the database, including the pending channel a fundee saves before any funding transaction exists. With the option on by default, a peer can therefore mark the database by starting an open; the docs now say so, and how to keep the option to downgrade. --- channeldb/channel.go | 6 ++-- channeldb/db.go | 25 +++++++++++++--- channeldb/db_splice_versions_test.go | 30 +++++++++++++++++++ channeldb/independent_secrets_version_test.go | 3 +- docs/independent-secrets.md | 10 +++++-- 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/channeldb/channel.go b/channeldb/channel.go index 9dbc1b375d1..6324b84196c 100644 --- a/channeldb/channel.go +++ b/channeldb/channel.go @@ -2686,8 +2686,10 @@ func syncNewChannel(tx kvdb.RwTx, c *OpenChannel, addrs []net.Addr) error { // An older version can't read the revocation state of a channel with // option_independent_secrets: from now on it must refuse the database - // rather than fail when it reaches the channel. This covers channels - // restored from a backup too. + // rather than fail when it reaches the channel. A static backup doesn't + // carry the bit, so this is a channel being opened, including the + // pending channel the fundee saves on funding_created, before any + // funding transaction exists. if c.ChanType.HasIndependentSecrets() { if err := markIndependentSecrets(tx); err != nil { return err diff --git a/channeldb/db.go b/channeldb/db.go index 4f7caeb526d..fc953496215 100644 --- a/channeldb/db.go +++ b/channeldb/db.go @@ -2223,12 +2223,29 @@ func markIndependentSecrets(tx kvdb.RwTx) error { // it's off. The independent secrets mark is never run as a migration: // markIndependentSecrets applies it with the first such channel. func targetVersions(splicing bool) []mandatoryVersion { - versions := dbVersions[:len(dbVersions)-1] - if splicing { - return versions + return filterTargetVersions(dbVersions, splicing) +} + +// filterTargetVersions leaves out of versions the marks targetVersions +// doesn't run, by number rather than by position, so that a migration +// appended after them still runs. +func filterTargetVersions(versions []mandatoryVersion, + splicing bool) []mandatoryVersion { + + targets := make([]mandatoryVersion, 0, len(versions)) + for _, v := range versions { + switch { + case v.number == independentSecretsDBVersion: + continue + + case v.number == splicingDBVersion && !splicing: + continue + } + + targets = append(targets, v) } - return versions[:len(versions)-1] + return targets } func getLatestDBVersion(versions []mandatoryVersion) uint32 { diff --git a/channeldb/db_splice_versions_test.go b/channeldb/db_splice_versions_test.go index d79e8b8d15f..c7eb23e589e 100644 --- a/channeldb/db_splice_versions_test.go +++ b/channeldb/db_splice_versions_test.go @@ -19,3 +19,33 @@ func TestOpenableDBVersions(t *testing.T) { }, versions) require.Equal(t, LatestDBVersion(), versions[2]) } + +// TestTargetVersions checks which versions are run as migrations: every one +// but the independent secrets mark, and without the splicing mark when +// splicing is off. The marks are picked by number, so a migration appended +// after them still runs. +func TestTargetVersions(t *testing.T) { + t.Parallel() + + numbers := func(versions []mandatoryVersion) []uint32 { + var n []uint32 + for _, v := range versions { + n = append(n, v.number) + } + + return n + } + + all := numbers(dbVersions) + require.Equal(t, uint32(independentSecretsDBVersion), all[len(all)-1]) + require.Equal(t, all[:len(all)-1], numbers(targetVersions(true))) + require.Equal(t, all[:len(all)-2], numbers(targetVersions(false))) + + later := append(append([]mandatoryVersion{}, dbVersions...), + mandatoryVersion{number: independentSecretsDBVersion + 1}) + next := uint32(independentSecretsDBVersion + 1) + require.Equal(t, append(append([]uint32{}, all[:len(all)-1]...), next), + numbers(filterTargetVersions(later, true))) + require.Equal(t, append(append([]uint32{}, all[:len(all)-2]...), next), + numbers(filterTargetVersions(later, false))) +} diff --git a/channeldb/independent_secrets_version_test.go b/channeldb/independent_secrets_version_test.go index 39c046315ee..f3e9aa1366b 100644 --- a/channeldb/independent_secrets_version_test.go +++ b/channeldb/independent_secrets_version_test.go @@ -20,7 +20,8 @@ func dbVersion(t *testing.T, db *DB) uint32 { // TestIndependentSecretsDBVersion checks that the database moves to the // independent secrets version when it first stores a channel with -// option_independent_secrets, new or restored, and only then; that a marked +// option_independent_secrets, new or with the restored status, and only +// then; that a marked // database still opens, with splicing on or off; and that a newer one is // refused. An older release refuses a marked database by its own check: the // version is past the newest one it knows. diff --git a/docs/independent-secrets.md b/docs/independent-secrets.md index 565af67d717..d5ffb8494bc 100644 --- a/docs/independent-secrets.md +++ b/docs/independent-secrets.md @@ -65,13 +65,17 @@ read the channel's stored state. lnd keeps a copy of a closed channel's state, so that stays true after the channel closes. To make that fail clearly, the database moves to version 39 when it first -stores a channel with the add-on, whether new or restored from a backup. An +stores a channel with the add-on. That includes the pending channel saved +when a peer starts an open with us, before any funding transaction exists, so +with the option on by default, a peer that offers it can mark the database +just by starting an open, even one it never completes. An older version then refuses to start with a clear error ("Refusing to revert from db_version=39"). Without that, it would fail to decode the channel while the channel is open or still being resolved, and once it has closed it would start and fail its `closedchannels` and `pendingchannels` calls. A node that -never had such a channel stays at the version before, and can still go back -to an older release. +never had such a channel, even a pending one, stays at the version before, and +can still go back to an older release. To keep that option, turn the add-on +off with `protocol.no-independent-secrets`. ## Testing against it From 7dbdb32c8ae04db46da8f6f539b490526e1cc09f Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Wed, 21 Oct 2026 03:15:12 +0200 Subject: [PATCH 20/24] multi: turn option_independent_secrets off by default Once a channel with the add-on is stored, the database moves to version 39, which an older release refuses to open. With the add-on on by default, two upgraded nodes would use it on every channel they open, so nearly every node would be marked right after upgrading and couldn't roll back if something else in the release went wrong. So it now ships off, and protocol.independent-secrets turns it on, replacing protocol.no-independent-secrets. A release that can read version 39 can then go out first; turning it on by default in a later release leaves a rollback landing on a release that already reads it. dev.independent-secrets-sender now needs protocol.independent-secrets, since its secrets are only accepted on channels with the add-on. The itest turns the option on for both nodes. The feature bit comment and docs now point to lightning-blake2b/bolts#17 instead of a draft bLIP. --- config.go | 10 +++++++ config_independent_secrets_test.go | 43 +++++++++++++++++++++++++++ docs/independent-secrets.md | 31 +++++++++---------- feature/default_sets.go | 4 +-- feature/independent_secrets_test.go | 8 ++--- feature/manager.go | 4 +-- itest/lnd_independent_secrets_test.go | 18 +++++++---- lncfg/protocol.go | 6 ++-- lncfg/protocol_integration.go | 6 ++-- lnwire/features.go | 2 +- sample-lnd.conf | 14 ++++----- server.go | 2 +- 12 files changed, 104 insertions(+), 44 deletions(-) create mode 100644 config_independent_secrets_test.go diff --git a/config.go b/config.go index 9e2c3cdbcef..86062788f27 100644 --- a/config.go +++ b/config.go @@ -1235,6 +1235,16 @@ func ValidateConfig(cfg Config, interceptor signal.Interceptor, fileParser, "can't be set with protocol.no-quiescence") } + // The dev sender's secrets are only accepted on channels with + // option_independent_secrets, which this node only opens or accepts + // when it signals the feature. + if cfg.Dev != nil && cfg.Dev.GetIndependentSecretsSender() && + !cfg.ProtocolOptions.IndependentSecrets { + + return nil, mkErr("dev.independent-secrets-sender needs " + + "protocol.independent-secrets") + } + // The hold on a peer's spliced-in coins, and the deepest lock, wait // for this many confirmations; the notifier counts no further. if cfg.ProtocolOptions.SplicePeerInputConfs > chainntnfs.MaxNumConfs { diff --git a/config_independent_secrets_test.go b/config_independent_secrets_test.go new file mode 100644 index 00000000000..1a4be55c325 --- /dev/null +++ b/config_independent_secrets_test.go @@ -0,0 +1,43 @@ +//go:build integration + +package lnd + +import ( + "testing" + + "github.com/jessevdk/go-flags" + "github.com/lightningnetwork/lnd/lncfg" + "github.com/lightningnetwork/lnd/signal" + "github.com/stretchr/testify/require" +) + +// TestDevIndependentSecretsSenderNeedsProtocolOption checks that the dev +// sender is refused unless protocol.independent-secrets is set: its secrets +// are only accepted on channels with the add-on, which the node only +// negotiates when it signals the feature. +func TestDevIndependentSecretsSenderNeedsProtocolOption(t *testing.T) { + const msg = "needs protocol.independent-secrets" + + validate := func(t *testing.T, on bool) error { + t.Helper() + cfg := DefaultConfig() + cfg.LndDir = t.TempDir() + cfg.Bitcoin.RegTest = true + cfg.Bitcoin.Node = "nochainbackend" + cfg.Dev = &lncfg.DevConfig{IndependentSecretsSender: true} + cfg.ProtocolOptions.IndependentSecrets = on + fileParser := flags.NewParser(&cfg, flags.Default) + flagParser := flags.NewParser(&cfg, flags.Default) + _, err := ValidateConfig( + cfg, signal.Interceptor{}, fileParser, flagParser, + ) + return err + } + + err := validate(t, false) + require.ErrorContains(t, err, msg) + + if err := validate(t, true); err != nil { + require.NotContains(t, err.Error(), msg) + } +} diff --git a/docs/independent-secrets.md b/docs/independent-secrets.md index d5ffb8494bc..de500755e0c 100644 --- a/docs/independent-secrets.md +++ b/docs/independent-secrets.md @@ -1,7 +1,8 @@ # Independent per-commitment secrets Lightning Fork supports `option_independent_secrets` (feature bits 266/267, -proposed in a draft bLIP). +specified in the fork's BOLTs: +[lightning-blake2b/bolts#17](https://github.com/lightning-blake2b/bolts/pull/17)). On a channel with it, this node accepts per-commitment secrets from its peer that do not come from a shachain, and stores every one of them. @@ -23,10 +24,10 @@ generates its own: they still come from its shachain. ## What it does -- **Signalled by default.** The optional bit 267 is set in `init` and - `node_announcement`. A new channel uses the add-on when both peers signal it - and agree on it in `channel_type`. With a peer that doesn't signal it, the - channel opens the normal way. +- **Off by default.** With `protocol.independent-secrets` set, the optional + bit 267 is set in `init` and `node_announcement`. A new channel uses the + add-on when both peers signal it and agree on it in `channel_type`. With a + peer that doesn't signal it, the channel opens the normal way. - **Storage.** Each secret is stored with the revocation log entry of the commitment it revokes, written in the same database transaction. That's 38 bytes per revoked state (a 32-byte secret in a TLV record), next to data the @@ -47,15 +48,15 @@ generates its own: they still come from its shachain. `lncli listchannels` and `lncli pendingchannels` report the add-on per channel as `independent_secrets`. -## Turning it off +## Turning it on ``` [protocol] -protocol.no-independent-secrets=true +protocol.independent-secrets=true ``` -New channels then keep requiring shachain secrets. Channels already opened with -the add-on keep it. +Turning it off again only affects new channels: those already opened with the +add-on keep it. ## Downgrading @@ -67,20 +68,20 @@ so that stays true after the channel closes. To make that fail clearly, the database moves to version 39 when it first stores a channel with the add-on. That includes the pending channel saved when a peer starts an open with us, before any funding transaction exists, so -with the option on by default, a peer that offers it can mark the database -just by starting an open, even one it never completes. An -older version then refuses to start with a clear error ("Refusing to revert +with the option on, a peer that offers it can mark the database just by +starting an open, even one it never completes. An older version then refuses to start with a clear error ("Refusing to revert from db_version=39"). Without that, it would fail to decode the channel while the channel is open or still being resolved, and once it has closed it would start and fail its `closedchannels` and `pendingchannels` calls. A node that never had such a channel, even a pending one, stays at the version before, and -can still go back to an older release. To keep that option, turn the add-on -off with `protocol.no-independent-secrets`. +can still go back to an older release. That's why the add-on is off by +default for now: a node that leaves it off can't be marked. ## Testing against it Integration builds (`-tags integration`) have a dev option, -`--dev.independent-secrets-sender`, that makes this node generate its own +`--dev.independent-secrets-sender` (which needs `protocol.independent-secrets`), +that makes this node generate its own secrets on new channels with the add-on using a key derivation function instead of a shachain: diff --git a/feature/default_sets.go b/feature/default_sets.go index f6143cbf361..85f1799b463 100644 --- a/feature/default_sets.go +++ b/feature/default_sets.go @@ -64,8 +64,8 @@ var defaultSetDesc = setDesc{ SetNodeAnn: {}, // N }, // Signalled so a peer knows this node can open a channel with - // option_independent_secrets. On by default; the operator can turn it - // off with protocol.no-independent-secrets. + // option_independent_secrets. Off by default: the feature manager + // unsets it unless the operator sets protocol.independent-secrets. lnwire.IndependentSecretsOptional: { SetInit: {}, // I SetNodeAnn: {}, // N diff --git a/feature/independent_secrets_test.go b/feature/independent_secrets_test.go index 3c5dfc28703..d72697d2a01 100644 --- a/feature/independent_secrets_test.go +++ b/feature/independent_secrets_test.go @@ -7,10 +7,10 @@ import ( "github.com/stretchr/testify/require" ) -// TestNoIndependentSecrets checks that option_independent_secrets is -// advertised in init and node_announcement by default, as optional so peers -// without it still open ordinary channels, and not at all when the operator -// turns it off. +// TestNoIndependentSecrets checks that the feature manager advertises +// option_independent_secrets in init and node_announcement unless told not +// to (lnd tells it not to unless protocol.independent-secrets is set), and +// only as optional, so peers without it still open ordinary channels. func TestNoIndependentSecrets(t *testing.T) { t.Parallel() diff --git a/feature/manager.go b/feature/manager.go index e3d443cc58c..a0cad070d54 100644 --- a/feature/manager.go +++ b/feature/manager.go @@ -96,8 +96,8 @@ type Config struct { // on mainnet, where the opt-in cannot be switched off. NoUnifiedSigs bool - // NoIndependentSecrets unsets option_independent_secrets, which is - // otherwise signalled by default. + // NoIndependentSecrets unsets option_independent_secrets. lnd sets it + // unless the operator turns on protocol.independent-secrets. NoIndependentSecrets bool } diff --git a/itest/lnd_independent_secrets_test.go b/itest/lnd_independent_secrets_test.go index ae42fb3cc50..aa13b590516 100644 --- a/itest/lnd_independent_secrets_test.go +++ b/itest/lnd_independent_secrets_test.go @@ -17,10 +17,10 @@ import ( // state. // // Bob runs with --dev.independent-secrets-sender, so on such a channel he -// generates his secrets with a KDF instead of a shachain. Carol runs the -// defaults. The test checks that: -// - both nodes signal the add-on by default and negotiate it, and report it -// over RPC; +// generates his secrets with a KDF instead of a shachain. Both run with +// --protocol.independent-secrets, since the add-on is off by default. The test +// checks that: +// - both nodes negotiate the add-on and report it over RPC; // - payments flow while Carol accepts and stores Bob's secrets, also across // restarts of both nodes; // - when Bob broadcasts a revoked commitment, Carol sweeps it with the @@ -33,7 +33,13 @@ func testIndependentSecrets(ht *lntest.HarnessTest) { ) commitType := lnrpc.CommitmentType_ANCHORS - nodeArgs := lntest.NodeArgsForCommitType(commitType) + nodeArgs := append( + lntest.NodeArgsForCommitType(commitType), + "--protocol.independent-secrets", + ) + + // Clip the capacity, so the two appends below don't share an array. + nodeArgs = nodeArgs[:len(nodeArgs):len(nodeArgs)] // Carol is the honest party. --nolisten and a long backoff keep Bob // from reconnecting to her before his breach, which would trigger data @@ -52,7 +58,7 @@ func testIndependentSecrets(ht *lntest.HarnessTest) { Amt: chanAmt, }) - // Neither node was told to: the add-on is on by default. + // Both nodes signal the add-on, so the channel has it. assertIndependentSecrets := func(hn *node.HarnessNode) { ch := ht.QueryChannelByChanPoint(hn, chanPoint) require.True(ht, ch.IndependentSecrets, diff --git a/lncfg/protocol.go b/lncfg/protocol.go index 9aefc23a2f5..c2b1093fed4 100644 --- a/lncfg/protocol.go +++ b/lncfg/protocol.go @@ -31,12 +31,12 @@ type ProtocolOptions struct { // experimental simple taproot chans commitment type. TaprootChans bool `long:"simple-taproot-chans" description:"if set, then lnd will create and accept requests for channels using the simple taproot commitment type"` - // NoIndependentSecrets should be set if the node should not signal - // option_independent_secrets. It's signalled by default, so new + // IndependentSecrets should be set if the node should signal + // option_independent_secrets. It's off by default. When set, new // channels with peers that also signal it store the remote party's // per-commitment secrets individually instead of requiring them to // come from a shachain. - NoIndependentSecrets bool `long:"no-independent-secrets" description:"if set, don't signal option_independent_secrets, so new channels keep requiring the remote party's per-commitment secrets to come from a shachain. By default new channels with peers that also signal it store every secret individually, which lets a peer generate them with k-of-n signers. Downgrading to a version of lnd without it is not supported once any channel has used it: such a version can't read that channel's stored state, even after the channel has closed."` + IndependentSecrets bool `long:"independent-secrets" description:"if set, signal option_independent_secrets, so new channels with peers that also signal it store every per-commitment secret the peer reveals individually, instead of requiring them to come from a shachain. This lets a peer generate its secrets with k-of-n signers. Once any channel has used it, the database moves to version 39 and lnd can't be downgraded to a version that doesn't read it."` // TaprootOverlayChans should be set if we want to enable support for // the experimental taproot overlay chan type. diff --git a/lncfg/protocol_integration.go b/lncfg/protocol_integration.go index 0f9cac1e800..3f93b0f0345 100644 --- a/lncfg/protocol_integration.go +++ b/lncfg/protocol_integration.go @@ -33,12 +33,12 @@ type ProtocolOptions struct { // experimental simple taproot chans commitment type. TaprootChans bool `long:"simple-taproot-chans" description:"if set, then lnd will create and accept requests for channels using the simple taproot commitment type"` - // NoIndependentSecrets should be set if the node should not signal - // option_independent_secrets. It's signalled by default, so new + // IndependentSecrets should be set if the node should signal + // option_independent_secrets. It's off by default. When set, new // channels with peers that also signal it store the remote party's // per-commitment secrets individually instead of requiring them to // come from a shachain. - NoIndependentSecrets bool `long:"no-independent-secrets" description:"if set, don't signal option_independent_secrets, so new channels keep requiring the remote party's per-commitment secrets to come from a shachain. By default new channels with peers that also signal it store every secret individually, which lets a peer generate them with k-of-n signers. Downgrading to a version of lnd without it is not supported once any channel has used it: such a version can't read that channel's stored state, even after the channel has closed."` + IndependentSecrets bool `long:"independent-secrets" description:"if set, signal option_independent_secrets, so new channels with peers that also signal it store every per-commitment secret the peer reveals individually, instead of requiring them to come from a shachain. This lets a peer generate its secrets with k-of-n signers. Once any channel has used it, the database moves to version 39 and lnd can't be downgraded to a version that doesn't read it."` // TaprootOverlayChans should be set if we want to enable support for // the experimental taproot overlay chan type. diff --git a/lnwire/features.go b/lnwire/features.go index b2e045571b2..5a21b205b66 100644 --- a/lnwire/features.go +++ b/lnwire/features.go @@ -344,7 +344,7 @@ const ( // remote party may generate them by any method, including jointly // across k-of-n signers. // - // Proposed as 266/267 in a draft bLIP. + // Specified as 266/267 in the fork's BOLTs (lightning-blake2b/bolts#17). IndependentSecretsRequired FeatureBit = 266 // IndependentSecretsOptional is the optional form of diff --git a/sample-lnd.conf b/sample-lnd.conf index 57a5df5311d..9bef851f53d 100644 --- a/sample-lnd.conf +++ b/sample-lnd.conf @@ -1732,13 +1732,13 @@ ; The same applies: no new ones on a node that signs under the unified hash. ; protocol.simple-taproot-overlay-chans=false -; Set to stop signalling option_independent_secrets. By default new channels -; with peers that also signal it store every per-commitment secret the peer -; reveals individually, instead of requiring them to come from a shachain, -; which lets a peer generate them with k-of-n signers. Downgrading to a version -; of lnd without it is not supported once any channel has used it: such a -; version can't read that channel's stored state, even after it has closed. -; protocol.no-independent-secrets=false +; Set to signal option_independent_secrets. New channels with peers that also +; signal it then store every per-commitment secret the peer reveals +; individually, instead of requiring them to come from a shachain, which lets a +; peer generate them with k-of-n signers. Once any channel has used it, the +; database moves to version 39, and lnd can't be downgraded to a version that +; doesn't read it. +; protocol.independent-secrets=false ; Set to disable blinded route forwarding. ; protocol.no-route-blinding=false diff --git a/server.go b/server.go index a94785cc9dc..8fc063edd0e 100644 --- a/server.go +++ b/server.go @@ -792,7 +792,7 @@ func newServer(ctx context.Context, cfg *Config, listenAddrs []net.Addr, Splice: cfg.ProtocolOptions.Splice() || pendingSplices(dbs.ChanStateDB.ChannelStateDB()), NoUnifiedSigs: !input.UnifiedSigHash(), - NoIndependentSecrets: cfg.ProtocolOptions.NoIndependentSecrets, + NoIndependentSecrets: !cfg.ProtocolOptions.IndependentSecrets, }) if err != nil { return nil, err From 679e8c0483a8fe38ba8a74169ec4fd2def0755aa Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Wed, 21 Oct 2026 03:15:12 +0200 Subject: [PATCH 21/24] chanacceptor, lnrpc: report option_independent_secrets to the acceptor The channel acceptor's commitment_type sets the add-on's bit aside, since it doesn't change the commitment's shape, so an acceptor couldn't tell whether accepting a channel meant storing every secret the initiator reveals. ChannelAcceptRequest gains wants_independent_secrets (field 2001), set when the proposed channel type carries the bit. The lightning.pb.go change is only what protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the new field. --- chanacceptor/rpcacceptor.go | 18 ++++- .../rpcacceptor_independent_secrets_test.go | 68 +++++++++++++++++++ lnrpc/lightning.pb.go | 21 ++++-- lnrpc/lightning.proto | 6 ++ lnrpc/lightning.swagger.json | 4 ++ 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/chanacceptor/rpcacceptor.go b/chanacceptor/rpcacceptor.go index dee9dee2d9f..af6232ef4ad 100644 --- a/chanacceptor/rpcacceptor.go +++ b/chanacceptor/rpcacceptor.go @@ -262,9 +262,10 @@ func (r *RPCAcceptor) sendAcceptRequests(errChan chan error, // counterpart. Also determine whether the zero-conf or // scid-alias channel types are set. var ( - commitmentType lnrpc.CommitmentType - wantsZeroConf bool - wantsScidAlias bool + commitmentType lnrpc.CommitmentType + wantsZeroConf bool + wantsScidAlias bool + wantsIndependentSecrets bool ) if req.OpenChanMsg.ChannelType != nil { @@ -289,6 +290,15 @@ func (r *RPCAcceptor) sendAcceptRequests(errChan chan error, wantsScidAlias = true } + + // acceptorCommitmentType sets this bit aside, + // so it's reported on its own. + if channelFeatures.IsSet( + lnwire.IndependentSecretsRequired, + ) { + + wantsIndependentSecrets = true + } } acceptRequests[pendingChanID] = newRequest @@ -311,6 +321,8 @@ func (r *RPCAcceptor) sendAcceptRequests(errChan chan error, CommitmentType: commitmentType, WantsZeroConf: wantsZeroConf, WantsScidAlias: wantsScidAlias, + + WantsIndependentSecrets: wantsIndependentSecrets, } if err := r.send(chanAcceptReq); err != nil { diff --git a/chanacceptor/rpcacceptor_independent_secrets_test.go b/chanacceptor/rpcacceptor_independent_secrets_test.go index 0f7692b68a0..d4a75f2259d 100644 --- a/chanacceptor/rpcacceptor_independent_secrets_test.go +++ b/chanacceptor/rpcacceptor_independent_secrets_test.go @@ -3,6 +3,8 @@ package chanacceptor import ( "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" @@ -32,3 +34,69 @@ func TestAcceptorCommitmentTypeIgnoresIndependentSecrets(t *testing.T) { require.True(t, vec.IsSet(lnwire.IndependentSecretsRequired)) } } + +// TestAcceptorReportsIndependentSecrets checks that the request sent to the +// channel acceptor says whether the proposed channel type carries +// option_independent_secrets, which commitment_type leaves out. +func TestAcceptorReportsIndependentSecrets(t *testing.T) { + t.Parallel() + + node := btcec.NewPublicKey( + new(btcec.FieldVal).SetInt(1), new(btcec.FieldVal).SetInt(1), + ) + + for _, withBit := range []bool{false, true} { + bits := []lnwire.FeatureBit{ + lnwire.StaticRemoteKeyRequired, + lnwire.AnchorsZeroFeeHtlcTxRequired, + } + if withBit { + bits = append(bits, lnwire.IndependentSecretsRequired) + } + chanType := lnwire.ChannelType(*lnwire.NewRawFeatureVector( + bits..., + )) + + // The acceptor sends each request once; the mock remote answers + // the pending channel it names, and the test reads what was sent. + sent := make(chan *lnrpc.ChannelAcceptRequest, 1) + pending := make(chan []byte, 1) + quit := make(chan struct{}) + acceptor := NewRPCAcceptor( + func() (*lnrpc.ChannelAcceptResponse, error) { + select { + case id := <-pending: + return &lnrpc.ChannelAcceptResponse{ + PendingChanId: id, + }, nil + case <-quit: + return nil, errShuttingDown + } + }, + func(req *lnrpc.ChannelAcceptRequest) error { + sent <- req + pending <- req.PendingChanId + return nil + }, + testTimeout*5, &chaincfg.RegressionNetParams, quit, + ) + errChan := make(chan error, 1) + go func() { errChan <- acceptor.Run() }() + + acceptor.Accept(&ChannelAcceptRequest{ + Node: node, + OpenChanMsg: &lnwire.OpenChannel{ + PendingChannelID: [32]byte{1}, + ChannelType: &chanType, + }, + }) + + req := <-sent + require.Equal(t, withBit, req.WantsIndependentSecrets) + require.Equal(t, lnrpc.CommitmentType_ANCHORS, + req.CommitmentType) + + close(quit) + require.ErrorIs(t, <-errChan, errShuttingDown) + } +} diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index e8e1c6ff6ad..b94633c1844 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -2749,8 +2749,13 @@ type ChannelAcceptRequest struct { // Whether the initiator wants to use the scid-alias channel type. This is // separate from the feature bit. WantsScidAlias bool `protobuf:"varint,16,opt,name=wants_scid_alias,json=wantsScidAlias,proto3" json:"wants_scid_alias,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the initiator proposes option_independent_secrets in the + // channel type. On such a channel this node stores every per-commitment + // secret the initiator reveals instead of requiring them to come from a + // shachain. commitment_type doesn't show it. + WantsIndependentSecrets bool `protobuf:"varint,2001,opt,name=wants_independent_secrets,json=wantsIndependentSecrets,proto3" json:"wants_independent_secrets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChannelAcceptRequest) Reset() { @@ -2895,6 +2900,13 @@ func (x *ChannelAcceptRequest) GetWantsScidAlias() bool { return false } +func (x *ChannelAcceptRequest) GetWantsIndependentSecrets() bool { + if x != nil { + return x.WantsIndependentSecrets + } + return false +} + type ChannelAcceptResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Whether or not the client accepts the channel. @@ -20060,7 +20072,7 @@ const file_lightning_proto_rawDesc = "" + "\n" + "fixed_msat\x18\x03 \x01(\x03H\x00R\tfixedMsat\x12\x1a\n" + "\apercent\x18\x02 \x01(\x03H\x00R\apercentB\a\n" + - "\x05limit\"\xec\x04\n" + + "\x05limit\"\xa9\x05\n" + "\x14ChannelAcceptRequest\x12\x1f\n" + "\vnode_pubkey\x18\x01 \x01(\fR\n" + "nodePubkey\x12\x1d\n" + @@ -20083,7 +20095,8 @@ const file_lightning_proto_rawDesc = "" + "\rchannel_flags\x18\r \x01(\rR\fchannelFlags\x12>\n" + "\x0fcommitment_type\x18\x0e \x01(\x0e2\x15.lnrpc.CommitmentTypeR\x0ecommitmentType\x12&\n" + "\x0fwants_zero_conf\x18\x0f \x01(\bR\rwantsZeroConf\x12(\n" + - "\x10wants_scid_alias\x18\x10 \x01(\bR\x0ewantsScidAlias\"\x90\x03\n" + + "\x10wants_scid_alias\x18\x10 \x01(\bR\x0ewantsScidAlias\x12;\n" + + "\x19wants_independent_secrets\x18\xd1\x0f \x01(\bR\x17wantsIndependentSecrets\"\x90\x03\n" + "\x15ChannelAcceptResponse\x12\x16\n" + "\x06accept\x18\x01 \x01(\bR\x06accept\x12&\n" + "\x0fpending_chan_id\x18\x02 \x01(\fR\rpendingChanId\x12\x14\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index ffdc91cd244..7eb58648845 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -928,6 +928,12 @@ message ChannelAcceptRequest { // Whether the initiator wants to use the scid-alias channel type. This is // separate from the feature bit. bool wants_scid_alias = 16; + + // Whether the initiator proposes option_independent_secrets in the + // channel type. On such a channel this node stores every per-commitment + // secret the initiator reveals instead of requiring them to come from a + // shachain. commitment_type doesn't show it. + bool wants_independent_secrets = 2001; } message ChannelAcceptResponse { diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index b960b02b91c..381b7480fa5 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -4233,6 +4233,10 @@ "wants_scid_alias": { "type": "boolean", "description": "Whether the initiator wants to use the scid-alias channel type. This is\nseparate from the feature bit." + }, + "wants_independent_secrets": { + "type": "boolean", + "description": "Whether the initiator proposes option_independent_secrets in the\nchannel type. On such a channel this node stores every per-commitment\nsecret the initiator reveals instead of requiring them to come from a\nshachain. commitment_type doesn't show it." } } }, From bda87f7e0f81f9e8652e54619de4ce7c0c17c40b Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Wed, 21 Oct 2026 11:22:02 +0200 Subject: [PATCH 22/24] feature, peer, funding: keep the add-on's bits to their own option protocol.custom-init and protocol.custom-nodeann could set either bit of option_independent_secrets: 267 turned the add-on on with protocol.independent-secrets off, so the database could move to version 39, and 266 is not valid in init at all. The feature manager now refuses custom bits that lnd defines, as updates at runtime already do. A peer that set 266 in init counted as supporting the add-on, since HasFeature(267) is true when 266 is set, and a node with the option off no longer disconnected it, since lnd now knows the bit. Such a peer is now refused in init, and negotiation checks bit 267 itself. --- feature/independent_secrets_test.go | 32 +++++++++++++++++ feature/manager.go | 13 +++++++ funding/commitment_type_negotiation.go | 16 ++++++--- peer/brontide.go | 10 ++++++ peer/independent_secrets_init_test.go | 50 ++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 peer/independent_secrets_init_test.go diff --git a/feature/independent_secrets_test.go b/feature/independent_secrets_test.go index d72697d2a01..d57ea91133d 100644 --- a/feature/independent_secrets_test.go +++ b/feature/independent_secrets_test.go @@ -49,3 +49,35 @@ func TestIndependentSecretsDependsOnChannelType(t *testing.T) { lnwire.ExplicitChannelTypeOptional, ))) } + +// TestCustomFeaturesRefuseKnownBits checks that custom-init and +// custom-nodeann can't set a bit lnd defines, such as either bit of +// option_independent_secrets: the odd one would turn the add-on on with its +// option off, and the even one isn't valid outside channel_type. +func TestCustomFeaturesRefuseKnownBits(t *testing.T) { + t.Parallel() + + for _, set := range []Set{SetInit, SetNodeAnn} { + for _, bit := range []lnwire.FeatureBit{ + lnwire.IndependentSecretsOptional, + lnwire.IndependentSecretsRequired, + } { + _, err := NewManager(Config{ + NoIndependentSecrets: true, + CustomFeatures: map[Set][]lnwire.FeatureBit{ + set: {bit}, + }, + }) + require.ErrorIs(t, err, lnwire.ErrFeatureStandard, + "set %v, bit %v", set, bit) + } + + // An unknown bit is still accepted. + _, err := NewManager(Config{ + CustomFeatures: map[Set][]lnwire.FeatureBit{ + set: {lnwire.FeatureBit(333)}, + }, + }) + require.NoError(t, err, "set %v", set) + } +} diff --git a/feature/manager.go b/feature/manager.go index a0cad070d54..5dd794c7e6f 100644 --- a/feature/manager.go +++ b/feature/manager.go @@ -271,6 +271,19 @@ func newManager(cfg Config, desc setDesc) (*Manager, error) { "already set", custom) } + // A bit lnd defines is set by its own option, if at + // all, not as a custom bit: otherwise custom-init could + // turn on a feature its option leaves off, such as + // option_independent_secrets, which moves the database + // to a version older releases can't open. The same rule + // applies to updates at runtime (ValidateUpdate). + if name, known := lnwire.Features[custom]; known { + return nil, fmt.Errorf("feature bit: %v (%v) "+ + "is defined by lnd and can't be set as "+ + "a custom feature: %w", custom, name, + lnwire.ErrFeatureStandard) + } + if err := raw.SafeSet(custom); err != nil { return nil, fmt.Errorf("%w: could not set "+ "feature: %d", err, custom) diff --git a/funding/commitment_type_negotiation.go b/funding/commitment_type_negotiation.go index 792e950de7c..4ef47068d1b 100644 --- a/funding/commitment_type_negotiation.go +++ b/funding/commitment_type_negotiation.go @@ -114,7 +114,7 @@ func withUnifiedSigs(chanType lnwire.ChannelType, local, func withIndependentSecrets(chanType lnwire.ChannelType, local, remote *lnwire.FeatureVector) lnwire.ChannelType { - if !hasFeatures(local, remote, lnwire.IndependentSecretsOptional) { + if !bothSignalIndependentSecrets(local, remote) { return chanType } @@ -125,6 +125,15 @@ func withIndependentSecrets(chanType lnwire.ChannelType, local, return lnwire.ChannelType(*withBit) } +// bothSignalIndependentSecrets reports whether both peers set the optional bit +// of option_independent_secrets. It checks bit 267 itself rather than +// HasFeature, which also counts the even bit: bit 266 is only valid in +// channel_type, so setting it in init doesn't signal support. +func bothSignalIndependentSecrets(local, remote *lnwire.FeatureVector) bool { + return local.IsSet(lnwire.IndependentSecretsOptional) && + remote.IsSet(lnwire.IndependentSecretsOptional) +} + // withAddOns adds to chanType every add-on bit that both peers support and // that isn't a commitment shape: option_unified_sigs and // option_independent_secrets. @@ -241,10 +250,7 @@ func explicitNegotiateCommitmentType(channelType lnwire.ChannelType, local, // the same way: it changes how per-commitment secrets are stored, not // the transactions. Check both sides support it, then take it off. if channelFeatures.IsSet(lnwire.IndependentSecretsRequired) { - if !hasFeatures( - local, remote, lnwire.IndependentSecretsOptional, - ) { - + if !bothSignalIndependentSecrets(local, remote) { return 0, errUnsupportedChannelType } diff --git a/peer/brontide.go b/peer/brontide.go index 8a0cabf82c1..3b65779c042 100644 --- a/peer/brontide.go +++ b/peer/brontide.go @@ -5134,6 +5134,16 @@ func (p *Brontide) handleInitMsg(msg *lnwire.Init) error { return fmt.Errorf("peer requires splicing, which is turned off") } + // The even bit of option_independent_secrets is only valid in + // channel_type, and a node must not set it in init (BOLT 3). lnd knows + // the bit, so the check for unknown required bits lets it through; a + // peer that sets it anyway is refused, whether or not this node offers + // the add-on. + if p.remoteFeatures.IsSet(lnwire.IndependentSecretsRequired) { + return fmt.Errorf("peer sets option_independent_secrets' " + + "even bit in init, where it isn't valid") + } + // Refuse a peer that serves another chain, or that will not say which // chain it serves when we insist on knowing. err = checkPeerNetworks( diff --git a/peer/independent_secrets_init_test.go b/peer/independent_secrets_init_test.go new file mode 100644 index 00000000000..520f6060eac --- /dev/null +++ b/peer/independent_secrets_init_test.go @@ -0,0 +1,50 @@ +package peer + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/stretchr/testify/require" +) + +// TestInitRefusesIndependentSecretsEvenBit checks that a peer setting the +// even bit of option_independent_secrets in init, where it isn't valid, is +// refused whether or not this node offers the add-on, and that the optional +// bit is accepted. +func TestInitRefusesIndependentSecretsEvenBit(t *testing.T) { + t.Parallel() + + init := func(bit lnwire.FeatureBit) *lnwire.Init { + return &lnwire.Init{ + Networks: []chainhash.Hash{{}}, + GlobalFeatures: lnwire.NewRawFeatureVector(), + Features: lnwire.NewRawFeatureVector( + lnwire.DataLossProtectRequired, + lnwire.ExplicitChannelTypeOptional, bit, + ), + } + } + + for _, offered := range []bool{false, true} { + local := lnwire.NewRawFeatureVector( + lnwire.DataLossProtectRequired, + lnwire.ExplicitChannelTypeOptional, + ) + if offered { + local.Set(lnwire.IndependentSecretsOptional) + } + + p := createTestPeer(t).peer + p.cfg.Features = lnwire.NewFeatureVector(local, lnwire.Features) + require.ErrorContains(t, + p.handleInitMsg(init(lnwire.IndependentSecretsRequired)), + "even bit", "offered=%v", offered) + + p = createTestPeer(t).peer + p.cfg.Features = lnwire.NewFeatureVector(local, lnwire.Features) + require.NoError(t, + p.handleInitMsg(init(lnwire.IndependentSecretsOptional)), + "offered=%v", offered) + } +} From baa36d4c3715f8bff2fb5763edd36172d109f7c6 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Wed, 21 Oct 2026 11:38:32 +0200 Subject: [PATCH 23/24] multi: harden marking and secret lookup, report closed channels From the maintainer's second pass: - syncNewChannel marks the database when UsesIndependentSecrets() is true, not on the channel-type bit alone, so a channel whose store and type disagree still marks it. Not reachable today; a test covers it. - revokedCommitSecret says plainly that a revoked state has no secret and can't be punished, and passes on the error when the channel's own log entry can't be read. - TestIndependentSecretsSpliceBreach also checks that each retribution's commitment point is the point of the secret Bob revealed, which the balances alone wouldn't catch. - closedchannels reports independent_secrets (field 2001 on ChannelCloseSummary), since a closed channel with the add-on still keeps the database at version 39. The lightning.pb.go change is only what protoc 3.21.12 and protoc-gen-go v1.36.11 generate for the new field. - The syncNewChannel comment no longer says a restored channel can't carry the bit. --- channeldb/channel.go | 13 ++++++---- channeldb/independent_secrets_version_test.go | 15 ++++++++++++ lnrpc/lightning.pb.go | 20 ++++++++++++---- lnrpc/lightning.proto | 5 ++++ lnrpc/lightning.swagger.json | 4 ++++ lnwallet/channel.go | 18 +++++++++----- lnwallet/independent_secrets_splice_test.go | 24 +++++++++++++++++++ rpcserver.go | 3 +++ 8 files changed, 87 insertions(+), 15 deletions(-) diff --git a/channeldb/channel.go b/channeldb/channel.go index 6324b84196c..1cdaeae2cfa 100644 --- a/channeldb/channel.go +++ b/channeldb/channel.go @@ -2686,11 +2686,14 @@ func syncNewChannel(tx kvdb.RwTx, c *OpenChannel, addrs []net.Addr) error { // An older version can't read the revocation state of a channel with // option_independent_secrets: from now on it must refuse the database - // rather than fail when it reaches the channel. A static backup doesn't - // carry the bit, so this is a channel being opened, including the - // pending channel the fundee saves on funding_created, before any - // funding transaction exists. - if c.ChanType.HasIndependentSecrets() { + // rather than fail when it reaches the channel. Every channel saved + // here with the add-on marks it: one being opened, including the + // pending channel the fundee saves on funding_created before any + // funding transaction exists, and one restored with a type that + // carries the bit (a static backup doesn't record it today, but the + // marking doesn't rely on that). UsesIndependentSecrets also counts + // the store type, in case the two ever disagree. + if c.UsesIndependentSecrets() { if err := markIndependentSecrets(tx); err != nil { return err } diff --git a/channeldb/independent_secrets_version_test.go b/channeldb/independent_secrets_version_test.go index f3e9aa1366b..9fb00117e62 100644 --- a/channeldb/independent_secrets_version_test.go +++ b/channeldb/independent_secrets_version_test.go @@ -73,6 +73,21 @@ func TestIndependentSecretsDBVersion(t *testing.T) { dbVersion(t, db)) }) + t.Run("an independent store marks without the bit", func(t *testing.T) { + t.Parallel() + + db, err := MakeTestDB(t) + require.NoError(t, err) + + // Not reachable today: the store is created from the channel + // type. If the two ever disagree, the store still marks. + channel := createTestChannelState(t, db.ChannelStateDB()) + channel.RevocationStore = shachain.NewIndependentStore() + require.NoError(t, channel.SyncPending(defaultAddr, 100)) + require.Equal(t, uint32(independentSecretsDBVersion), + dbVersion(t, db)) + }) + t.Run("a restored channel marks", func(t *testing.T) { t.Parallel() diff --git a/lnrpc/lightning.pb.go b/lnrpc/lightning.pb.go index b94633c1844..ed7910651fc 100644 --- a/lnrpc/lightning.pb.go +++ b/lnrpc/lightning.pb.go @@ -6322,8 +6322,12 @@ type ChannelCloseSummary struct { //The outpoint the channel was opened with, when a splice moved it: //channel_point is then the funding output that was closed. OriginalChannelPoint string `protobuf:"bytes,1002,opt,name=original_channel_point,json=originalChannelPoint,proto3" json:"original_channel_point,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Whether the channel used option_independent_secrets. A closed channel + // with it still keeps its stored state, so a build without the option + // can't open this database. False when the channel's history isn't kept. + IndependentSecrets bool `protobuf:"varint,2001,opt,name=independent_secrets,json=independentSecrets,proto3" json:"independent_secrets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ChannelCloseSummary) Reset() { @@ -6475,6 +6479,13 @@ func (x *ChannelCloseSummary) GetOriginalChannelPoint() string { return "" } +func (x *ChannelCloseSummary) GetIndependentSecrets() bool { + if x != nil { + return x.IndependentSecrets + } + return false +} + type Resolution struct { state protoimpl.MessageState `protogen:"open.v1"` // The type of output we are resolving. @@ -20376,7 +20387,7 @@ const file_lightning_proto_rawDesc = "" + "\x12ListAliasesRequest\"E\n" + "\x13ListAliasesResponse\x12.\n" + "\n" + - "alias_maps\x18\x01 \x03(\v2\x0f.lnrpc.AliasMapR\taliasMaps\"\x9d\a\n" + + "alias_maps\x18\x01 \x03(\v2\x0f.lnrpc.AliasMapR\taliasMaps\"\xcf\a\n" + "\x13ChannelCloseSummary\x12#\n" + "\rchannel_point\x18\x01 \x01(\tR\fchannelPoint\x12\x1b\n" + "\achan_id\x18\x02 \x01(\x04B\x020\x01R\x06chanId\x12\x1d\n" + @@ -20398,7 +20409,8 @@ const file_lightning_proto_rawDesc = "" + "aliasScids\x12;\n" + "\x18zero_conf_confirmed_scid\x18\x0f \x01(\x04B\x020\x01R\x15zeroConfConfirmedScid\x12.\n" + "\x13custom_channel_data\x18\x10 \x01(\fR\x11customChannelData\x125\n" + - "\x16original_channel_point\x18\xea\a \x01(\tR\x14originalChannelPoint\"\x8a\x01\n" + + "\x16original_channel_point\x18\xea\a \x01(\tR\x14originalChannelPoint\x120\n" + + "\x13independent_secrets\x18\xd1\x0f \x01(\bR\x12independentSecrets\"\x8a\x01\n" + "\vClosureType\x12\x15\n" + "\x11COOPERATIVE_CLOSE\x10\x00\x12\x15\n" + "\x11LOCAL_FORCE_CLOSE\x10\x01\x12\x16\n" + diff --git a/lnrpc/lightning.proto b/lnrpc/lightning.proto index 7eb58648845..a529d74da56 100644 --- a/lnrpc/lightning.proto +++ b/lnrpc/lightning.proto @@ -2022,6 +2022,11 @@ message ChannelCloseSummary { channel_point is then the funding output that was closed. */ string original_channel_point = 1002; + + // Whether the channel used option_independent_secrets. A closed channel + // with it still keeps its stored state, so a build without the option + // can't open this database. False when the channel's history isn't kept. + bool independent_secrets = 2001; } enum ResolutionType { diff --git a/lnrpc/lightning.swagger.json b/lnrpc/lightning.swagger.json index 381b7480fa5..850ac9cf187 100644 --- a/lnrpc/lightning.swagger.json +++ b/lnrpc/lightning.swagger.json @@ -4487,6 +4487,10 @@ "original_channel_point": { "type": "string", "description": "The outpoint the channel was opened with, when a splice moved it:\nchannel_point is then the funding output that was closed." + }, + "independent_secrets": { + "type": "boolean", + "description": "Whether the channel used option_independent_secrets. A closed channel\nwith it still keeps its stored state, so a build without the option\ncan't open this database. False when the channel's history isn't kept." } } }, diff --git a/lnwallet/channel.go b/lnwallet/channel.go index a862ffa31f1..c09631eacac 100644 --- a/lnwallet/channel.go +++ b/lnwallet/channel.go @@ -2168,14 +2168,20 @@ func revokedCommitSecret(chanState *channeldb.OpenChannel, // transaction, and one secret revokes the commitment with that // number on every funding output. own, _, err := chanState.FindPreviousState(stateNum) - if err == nil { - if secret := logCommitSecret(own); secret != nil { - return secret, nil - } + if err != nil { + return nil, fmt.Errorf("no per-commitment secret for "+ + "revoked state %d: its revocation log entry "+ + "has none, and the channel's own entry can't "+ + "be read: %w", stateNum, err) + } + if secret := logCommitSecret(own); secret != nil { + return secret, nil } - return nil, fmt.Errorf("revocation log for state %d has no "+ - "per-commitment secret", stateNum) + return nil, fmt.Errorf("no per-commitment secret for revoked "+ + "state %d: neither its revocation log entry nor the "+ + "channel's own has one, so the breach can't be "+ + "punished", stateNum) } return chanState.RevocationStore.LookUp(stateNum) diff --git a/lnwallet/independent_secrets_splice_test.go b/lnwallet/independent_secrets_splice_test.go index cfbb1b22eb4..1cb93ede6ba 100644 --- a/lnwallet/independent_secrets_splice_test.go +++ b/lnwallet/independent_secrets_splice_test.go @@ -119,6 +119,7 @@ func TestIndependentSecretsSpliceBreach(t *testing.T) { all := []revoked{pendingOld, pendingNew, atLock, afterLock} for _, r := range all { requireBreachFor(t, alice, r.height, r.tx, r.bob, r.alice) + requireBreachPoint(t, alice, producer, r.height, r.tx) } // And after a restart, from the database alone. @@ -126,9 +127,32 @@ func TestIndependentSecretsSpliceBreach(t *testing.T) { require.NoError(t, err) for _, r := range all { requireBreachFor(t, alice, r.height, r.tx, r.bob, r.alice) + requireBreachPoint(t, alice, producer, r.height, r.tx) } } +// requireBreachPoint checks that the retribution for Bob's revoked +// commitment at height uses the point of the secret he revealed for it, so +// the keys it signs with are the revocation keys of that commitment. The +// balances requireBreachFor checks wouldn't catch a wrong secret. +func requireBreachPoint(t *testing.T, alice *LightningChannel, + producer *randomSecretProducer, height uint64, tx *wire.MsgTx) { + + t.Helper() + + br, err := NewBreachRetribution( + alice.channelState, height, 1, tx, + fn.None[AuxLeafStore](), fn.None[AuxContractResolver](), + ) + require.NoError(t, err) + + secret, err := producer.AtIndex(height) + require.NoError(t, err) + point := input.ComputeCommitmentPoint(secret[:]) + require.True(t, point.IsEqual(br.KeyRing.CommitPoint), + "height %d", height) +} + // TestIndependentSecretsSpliceTower is TestSpliceTowerRetributions on a // channel with option_independent_secrets: a watchtower backup can be built // for every commitment of Bob's at a revoked height, the one in the diff --git a/rpcserver.go b/rpcserver.go index 42d68e88558..9f9279ee9b4 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -5443,6 +5443,9 @@ func (r *rpcServer) createRPCClosedChannel( case channeldb.ErrChannelNotFound: case nil: + channel.IndependentSecrets = + histChan.ChanType.HasIndependentSecrets() + if histChan.IsZeroConf() && histChan.ZeroConfConfirmed() { // If the channel was zero-conf, it may have confirmed. // Populate the confirmed SCID if so. From 9b0d0b365a2a8dbd48109e2f34bd523d2fe63ee7 Mon Sep 17 00:00:00 2001 From: Dave Lund Date: Wed, 21 Oct 2026 11:38:32 +0200 Subject: [PATCH 24/24] docs, lnwire, itest: review nits - docs: the channel acceptor reports wants_independent_secrets, and closedchannels reports the add-on; a dev-sender node restored from a static backup gets a shachain producer back, so it can't sweep its own to_local with an external tool; rewrap a paragraph. - lnwire: IndependentSecretsRequired is only valid in channel_type. - itest: output 2 of the breach is Bob's revoked to_local, and the justice transaction's other input is Carol's to_remote; drop --hodl.exit-settle, which had no effect on Carol. --- docs/independent-secrets.md | 20 +++++++++++++++----- itest/lnd_independent_secrets_test.go | 17 +++++++++-------- lnwire/features.go | 3 +++ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/independent-secrets.md b/docs/independent-secrets.md index de500755e0c..7eda54c8ed3 100644 --- a/docs/independent-secrets.md +++ b/docs/independent-secrets.md @@ -1,7 +1,7 @@ # Independent per-commitment secrets Lightning Fork supports `option_independent_secrets` (feature bits 266/267, -specified in the fork's BOLTs: +specified in the fork's BOLTs, [lightning-blake2b/bolts#17](https://github.com/lightning-blake2b/bolts/pull/17)). On a channel with it, this node accepts per-commitment secrets from its peer that do not come from a shachain, and stores every one of them. @@ -45,8 +45,11 @@ generates its own: they still come from its shachain. the add-on. - **Existing channels** keep their type and behave exactly as before. -`lncli listchannels` and `lncli pendingchannels` report the add-on per channel -as `independent_secrets`. +`lncli listchannels`, `lncli pendingchannels` and `lncli closedchannels` +report the add-on per channel as `independent_secrets`. The channel acceptor +RPC gets `wants_independent_secrets` for a proposed channel, so an acceptor +can see the add-on and refuse it per channel; its `commitment_type` leaves the +bit out, as it does for `option_unified_sigs`. ## Turning it on @@ -69,8 +72,9 @@ To make that fail clearly, the database moves to version 39 when it first stores a channel with the add-on. That includes the pending channel saved when a peer starts an open with us, before any funding transaction exists, so with the option on, a peer that offers it can mark the database just by -starting an open, even one it never completes. An older version then refuses to start with a clear error ("Refusing to revert -from db_version=39"). Without that, it would fail to decode the channel while +starting an open, even one it never completes. An older version then refuses +to start with a clear error ("Refusing to revert from db_version=39"). +Without that, it would fail to decode the channel while the channel is open or still being resolved, and once it has closed it would start and fail its `closedchannels` and `pendingchannels` calls. A node that never had such a channel, even a pending one, stays at the version before, and @@ -97,3 +101,9 @@ root lnd already stores; the channel records that it uses them in a local channel type bit, so it keeps using them for its whole life, even if the option is later turned off. It is not available in release builds: a single signer gains nothing from it. + +A node restored from a static channel backup gets a shachain producer back, +since the backup doesn't record that the dev sender was used. Such a node +can't regenerate the secrets it revealed on the restored channel, so it can't +sweep its own `to_local` output with an external tool after a force close. +That only matters for testing: release builds don't have the dev sender. diff --git a/itest/lnd_independent_secrets_test.go b/itest/lnd_independent_secrets_test.go index aa13b590516..1a90a8a7f22 100644 --- a/itest/lnd_independent_secrets_test.go +++ b/itest/lnd_independent_secrets_test.go @@ -45,7 +45,7 @@ func testIndependentSecrets(ht *lntest.HarnessTest) { // from reconnecting to her before his breach, which would trigger data // loss protection instead. carol := ht.NewNode("Carol", append( - nodeArgs, "--hodl.exit-settle", "--nolisten", "--minbackoff=1h", + nodeArgs, "--nolisten", "--minbackoff=1h", )) bob := ht.NewNode("Bob", append( nodeArgs, "--dev.independent-secrets-sender", @@ -111,18 +111,19 @@ func testIndependentSecrets(ht *lntest.HarnessTest) { block := ht.MineBlocksAndAssertNumTxes(1, 1)[0] ht.AssertTxInBlock(block, *breachTXID) - // On an anchor channel the first two outputs are anchors, so Bob's - // to_remote output is the third. Carol's justice transaction spends it - // together with Bob's revoked to_local output, which she can only do - // with the secret Bob revealed for this state. - toRemote := wire.OutPoint{Hash: *breachTXID, Index: 2} - justiceTx := ht.AssertOutpointInMempool(toRemote) + // Outputs are sorted by amount, so on this anchor channel the first + // two are the anchors and the third is Bob's revoked to_local, his + // smaller balance. Carol's justice transaction spends it, which she + // can only do with the secret Bob revealed for this state, together + // with her own to_remote output. + bobToLocal := wire.OutPoint{Hash: *breachTXID, Index: 2} + justiceTx := ht.AssertOutpointInMempool(bobToLocal) for _, txIn := range justiceTx.TxIn { require.Equal(ht, *breachTXID, txIn.PreviousOutPoint.Hash, "justice tx not spending the breach transaction") } require.GreaterOrEqual(ht, len(justiceTx.TxIn), 2, - "justice tx should sweep both of Bob's outputs") + "justice tx should sweep Bob's to_local and Carol's to_remote") ht.RestartNode(carol) ht.AssertNumPendingSweeps(carol, 1) diff --git a/lnwire/features.go b/lnwire/features.go index 5a21b205b66..55e24ed7c3b 100644 --- a/lnwire/features.go +++ b/lnwire/features.go @@ -344,6 +344,9 @@ const ( // remote party may generate them by any method, including jointly // across k-of-n signers. // + // This even bit is only valid in channel_type: a node must not set it + // in init or node_announcement, where only the optional bit is used. + // // Specified as 266/267 in the fork's BOLTs (lightning-blake2b/bolts#17). IndependentSecretsRequired FeatureBit = 266