What changed, and why it matters
This commit adds a read-only accessor that returns a private copy of a Lightning channel's internal state. It is a defensive code change: by making a deep copy before handing data out, it prevents callers from accidentally or intentionally modifying sensitive internal state. There is no direct security vulnerability in the patch itself; it is a hardening measure.
No immediate action required. Treat as routine hardening. Review future RPCs that consume this accessor to ensure they do not leak sensitive channel state to unauthorized callers.
Security signals we found
Defensive deep-copy of sensitive channel state before external exposure
Use of read lock (RLock) while accessing shared mutable state
New public accessor exposes previously internal channeldb.OpenChannel data
No input validation, parsing, or cryptographic operations added
Evidence from the diff
The patch introduces ChannelCommitment.copy() and OpenChannel.Copy() deep-copy methods in channeldb, plus a LightningChannel.ChannelState() getter in lnwallet. The getter takes an RLock on the internal OpenChannel, clones it (including nested transaction, signatures, HTLCs, shutdown scripts, and TLV blobs), and returns the clone. This isolates internal state from callers and is intended to support future RPC exposure of the full channel state.
Changed components
channeldb/channel.golnwallet/channel.goInspect captured patch +106 / −0
diff --git a/channeldb/channel.go b/channeldb/channel.go
index d576475..e6b2bc4 100644
--- a/channeldb/channel.go
+++ b/channeldb/channel.go
@@ -746,6 +746,33 @@ func (c *ChannelCommitment) extractTlvData() commitTlvData {
return auxData
}
+// copy returns a deep copy of the channel commitment.
+func (c *ChannelCommitment) copy() ChannelCommitment {
+ c2 := *c
+ if c.CommitTx != nil {
+ c2.CommitTx = c.CommitTx.Copy()
+ }
+ if len(c.CommitSig) > 0 {
+ c2.CommitSig = make([]byte, len(c.CommitSig))
+ copy(c2.CommitSig, c.CommitSig)
+ }
+
+ c.CustomBlob.WhenSome(func(blob tlv.Blob) {
+ blobCopy := make([]byte, len(blob))
+ copy(blobCopy, blob)
+ c2.CustomBlob = fn.Some(blobCopy)
+ })
+
+ if len(c.Htlcs) > 0 {
+ c2.Htlcs = make([]HTLC, len(c.Htlcs))
+ for i, h := range c.Htlcs {
+ c2.Htlcs[i] = h.Copy()
+ }
+ }
+
+ return c2
+}
+
// ChannelStatus is a bit vector used to indicate whether an OpenChannel is in
// the default usable state, or a state where it shouldn't be used.
type ChannelStatus uint64
@@ -4066,6 +4093,78 @@ func (c *OpenChannel) Snapshot() *ChannelSnapshot {
return snapshot
}
+// Copy returns a deep copy of the channel state.
+func (c *OpenChannel) Copy() *OpenChannel {
+ c.RLock()
+ defer c.RUnlock()
+
+ clone := &OpenChannel{
+ ChanType: c.ChanType,
+ ChainHash: c.ChainHash,
+ FundingOutpoint: c.FundingOutpoint,
+ ShortChannelID: c.ShortChannelID,
+ IsPending: c.IsPending,
+ IsInitiator: c.IsInitiator,
+ chanStatus: c.chanStatus,
+ FundingBroadcastHeight: c.FundingBroadcastHeight,
+ ConfirmationHeight: c.ConfirmationHeight,
+ NumConfsRequired: c.NumConfsRequired,
+ ChannelFlags: c.ChannelFlags,
+ IdentityPub: c.IdentityPub,
+ Capacity: c.Capacity,
+ TotalMSatSent: c.TotalMSatSent,
+ TotalMSatReceived: c.TotalMSatReceived,
+ InitialLocalBalance: c.InitialLocalBalance,
+ InitialRemoteBalance: c.InitialRemoteBalance,
+ LocalChanCfg: c.LocalChanCfg,
+ RemoteChanCfg: c.RemoteChanCfg,
+ LocalCommitment: c.LocalCommitment.copy(),
+ RemoteCommitment: c.RemoteCommitment.copy(),
+ RemoteCurrentRevocation: c.RemoteCurrentRevocation,
+ RemoteNextRevocation: c.RemoteNextRevocation,
+ RevocationProducer: c.RevocationProducer,
+ RevocationStore: c.RevocationStore,
+ Packager: c.Packager,
+ ThawHeight: c.ThawHeight,
+ LastWasRevoke: c.LastWasRevoke,
+ RevocationKeyLocator: c.RevocationKeyLocator,
+ confirmedScid: c.confirmedScid,
+ TapscriptRoot: c.TapscriptRoot,
+ }
+
+ if c.FundingTxn != nil {
+ clone.FundingTxn = c.FundingTxn.Copy()
+ }
+
+ if len(c.LocalShutdownScript) > 0 {
+ clone.LocalShutdownScript = make(
+ lnwire.DeliveryAddress,
+ len(c.LocalShutdownScript),
+ )
+ copy(clone.LocalShutdownScript, c.LocalShutdownScript)
+ }
+ if len(c.RemoteShutdownScript) > 0 {
+ clone.RemoteShutdownScript = make(
+ lnwire.DeliveryAddress,
+ len(c.RemoteShutdownScript),
+ )
+ copy(clone.RemoteShutdownScript, c.RemoteShutdownScript)
+ }
+
+ if len(c.Memo) > 0 {
+ clone.Memo = make([]byte, len(c.Memo))
+ copy(clone.Memo, c.Memo)
+ }
+
+ c.CustomBlob.WhenSome(func(blob tlv.Blob) {
+ blobCopy := make([]byte, len(blob))
+ copy(blobCopy, blob)
+ clone.CustomBlob = fn.Some(blobCopy)
+ })
+
+ return clone
+}
+
// LatestCommitments returns the two latest commitments for both the local and
// remote party. These commitments are read from disk to ensure that only the
// latest fully committed state is returned. The first commitment returned is
diff --git a/lnwallet/channel.go b/lnwallet/channel.go
index 484a019..fb1e3ac 100644
--- a/lnwallet/channel.go
+++ b/lnwallet/channel.go
@@ -6576,6 +6576,13 @@ func (lc *LightningChannel) ChannelPoint() wire.OutPoint {
return lc.channelState.FundingOutpoint
}
+// ChannelState returns a copy of the internal channeldb.OpenChannel state
+// struct. Modifications to the returned struct will not be reflected within
+// the LightningChannel.
+func (lc *LightningChannel) ChannelState() *channeldb.OpenChannel {
+ return lc.channelState.Copy()
+}
+
// ChannelID returns the ChannelID of this LightningChannel. This is the same
// ChannelID that is used in update messages for this channel.
func (lc *LightningChannel) ChannelID() lnwire.ChannelID {
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.