mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
The channel-restriction rule was previously fragile because it failed to initialize when a restricted channel was closed while the session was inactive. This often caused unnecessary session invalidation and blocked users from managing their nodes. This change makes the rule resilient by allowing it to start even if some channels in the deny-list are missing from the node's current active set. To maintain high performance, this implements a negative cache that tracks unknown channel IDs, shielding LND from redundant RPC calls during request evaluation. Only having a negative cache without invalidation can be a security problem. Someone could apply a rule with a future guessed channel id such that the channel restriction populates the checkedIDs map with it. After the channel was opened, we'd then allow making actions on the channel because we don't know about the channel's id in the getChannelID check. To ensure security isn't compromised by the cache, this adds a self-healing retry mechanism. If the firewall encounters an unknown channel outpoint while it still has unmapped restricted IDs, it clears the negative cache and forces a single retry in the next RPC call. This ensures that any newly opened restricted channels are correctly identified and blocked without adding latency to the common path. Note: This approach deliberately accepts potential cache thrashing in the edge case where a user repeatedly requests an unknown channel point while a permanently missing ID exists in the deny list. This trade-off is accepted to prioritize security (fail close) over performance in this specific invalid state.
486 lines
13 KiB
Go
486 lines
13 KiB
Go
package rules
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math/rand"
|
|
"testing"
|
|
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/lightninglabs/lightning-terminal/firewalldb"
|
|
"github.com/lightninglabs/lightning-terminal/session"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestChannelRestrictCheckRequest ensures that the ChannelRestrictEnforcer
|
|
// correctly accepts or denys a request.
|
|
func TestChannelRestrictCheckRequest(t *testing.T) {
|
|
txid1, index1, err := newTXID()
|
|
require.NoError(t, err)
|
|
|
|
txid2, index2, err := newTXID()
|
|
require.NoError(t, err)
|
|
|
|
txid3, index3, err := newTXID()
|
|
require.NoError(t, err)
|
|
|
|
chanPointStr1 := fmt.Sprintf("%s:%d", hex.EncodeToString(txid1), index1)
|
|
chanPointStr2 := fmt.Sprintf("%s:%d", hex.EncodeToString(txid2), index2)
|
|
chanPointStr3 := fmt.Sprintf("%s:%d", hex.EncodeToString(txid3), index3)
|
|
|
|
chanID1, _ := firewalldb.NewPseudoUint64()
|
|
chanID2, _ := firewalldb.NewPseudoUint64()
|
|
chanID3, _ := firewalldb.NewPseudoUint64()
|
|
|
|
ctx := context.Background()
|
|
mgr := NewChannelRestrictMgr()
|
|
cfg := &mockLndClient{}
|
|
cfg.On(
|
|
"ListChannels", mock.Anything, mock.Anything, mock.Anything,
|
|
mock.Anything,
|
|
).Return([]lndclient.ChannelInfo{
|
|
{
|
|
ChannelID: chanID1,
|
|
ChannelPoint: chanPointStr1,
|
|
},
|
|
{
|
|
ChannelID: chanID2,
|
|
ChannelPoint: chanPointStr2,
|
|
},
|
|
{
|
|
ChannelID: chanID3,
|
|
ChannelPoint: chanPointStr3,
|
|
},
|
|
}, nil)
|
|
|
|
enf, err := mgr.NewEnforcer(ctx, cfg, &ChannelRestrict{
|
|
DenyList: []uint64{
|
|
chanID1, chanID2,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// A request for an irrelevant URI should be allowed.
|
|
_, err = enf.HandleRequest(ctx, "random-URI", nil)
|
|
require.NoError(t, err)
|
|
|
|
// If there is a channel restriction list, then no global policy updates
|
|
// are allowed.
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_Global{Global: true},
|
|
},
|
|
)
|
|
require.ErrorContainsf(t, err, "cant apply call to global scope when "+
|
|
"using a channel restriction list", "")
|
|
|
|
// Test that an action on channel point 1 in the string form is
|
|
// disallowed.
|
|
chanPoint1 := &lnrpc.ChannelPoint{
|
|
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
|
|
FundingTxidStr: hex.EncodeToString(txid1),
|
|
},
|
|
OutputIndex: index1,
|
|
}
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint1,
|
|
},
|
|
},
|
|
)
|
|
require.ErrorContainsf(t, err, "illegal action on channel in channel "+
|
|
"restriction list", "")
|
|
|
|
// Test that an action on channel point 2 in the byte form is
|
|
// disallowed.
|
|
h, err := chainhash.NewHashFromStr(hex.EncodeToString(txid2))
|
|
require.NoError(t, err)
|
|
|
|
chanPoint2 := &lnrpc.ChannelPoint{
|
|
FundingTxid: &lnrpc.ChannelPoint_FundingTxidBytes{
|
|
FundingTxidBytes: h[:],
|
|
},
|
|
OutputIndex: index2,
|
|
}
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint2,
|
|
},
|
|
},
|
|
)
|
|
require.ErrorContainsf(t, err, "illegal action on channel in channel "+
|
|
"restriction list", "")
|
|
|
|
// Test that an action on a channel not in the deny-list is allowed.
|
|
chanPoint3 := &lnrpc.ChannelPoint{
|
|
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
|
|
FundingTxidStr: hex.EncodeToString(txid3),
|
|
},
|
|
OutputIndex: index3,
|
|
}
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint3,
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func newTXID() ([]byte, uint32, error) {
|
|
var b [32]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return b[:], rand.Uint32(), nil
|
|
}
|
|
|
|
type mockLndClient struct {
|
|
lndclient.LightningClient
|
|
Config
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockLndClient) GetLndClient() lndclient.LightningClient {
|
|
return m
|
|
}
|
|
|
|
func (m *mockLndClient) ListChannels(ctx context.Context, public, active bool,
|
|
opts ...lndclient.ListChannelsOption) ([]lndclient.ChannelInfo, error) {
|
|
|
|
args := m.Called(ctx, public, active, opts)
|
|
|
|
if args.Get(0) != nil {
|
|
return args.Get(0).([]lndclient.ChannelInfo), args.Error(1)
|
|
}
|
|
|
|
return nil, args.Error(1)
|
|
}
|
|
|
|
// TestChannelRestrictRealToPseudo tests that the ChannelRestrict's RealToPseudo
|
|
// method correctly determines which real strings to generate pseudo pairs for
|
|
// based on the privacy map db passed to it.
|
|
func TestChannelRestrictRealToPseudo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
chanID1 := firewalldb.Uint64ToStr(1)
|
|
chanID2 := firewalldb.Uint64ToStr(2)
|
|
chanID3 := firewalldb.Uint64ToStr(3)
|
|
chanID2Obfuscated := firewalldb.Uint64ToStr(200)
|
|
|
|
tests := []struct {
|
|
name string
|
|
privacyFlags session.PrivacyFlags
|
|
dbPreLoad map[string]string
|
|
expectNewPairs map[string]bool
|
|
}{
|
|
{
|
|
// If there is no preloaded DB, then we expect all the
|
|
// values in the deny list to be returned from the
|
|
// RealToPseudo method.
|
|
name: "no pre loaded db",
|
|
expectNewPairs: map[string]bool{
|
|
chanID1: true,
|
|
chanID2: true,
|
|
chanID3: true,
|
|
},
|
|
},
|
|
{
|
|
// If the DB is preloaded with an entry for "channel 2"
|
|
// then we don't expect that entry to be returned in the
|
|
// set of new pairs.
|
|
name: "partially pre-loaded DB",
|
|
dbPreLoad: map[string]string{
|
|
chanID2: chanID2Obfuscated,
|
|
},
|
|
expectNewPairs: map[string]bool{
|
|
chanID1: true,
|
|
chanID3: true,
|
|
},
|
|
},
|
|
{
|
|
name: "turned off mapping",
|
|
privacyFlags: session.PrivacyFlags{
|
|
session.ClearChanIDs,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Construct the ChannelRestrict deny list. Note that we repeat one of
|
|
// the entries here in order to ensure that the RealToPseudo method is
|
|
// forced to look up any real-to-pseudo pairs that it already
|
|
// generated.
|
|
cr := &ChannelRestrict{
|
|
DenyList: []uint64{
|
|
1,
|
|
2,
|
|
3,
|
|
3,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
privMapPairDB := firewalldb.NewPrivacyMapPairs(
|
|
test.dbPreLoad,
|
|
)
|
|
|
|
// Iterate over the preload key value pairs and load
|
|
// them into the DB.
|
|
expectedDenyList := make(map[uint64]bool)
|
|
for _, p := range test.dbPreLoad {
|
|
// Add the pseudo value to the expected deny
|
|
// list.
|
|
pInt, err := firewalldb.StrToUint64(p)
|
|
require.NoError(t, err)
|
|
|
|
expectedDenyList[pInt] = true
|
|
}
|
|
|
|
// Call the RealToPseudo method on the ChannelRestrict
|
|
// rule. This will return the rule value in its pseudo
|
|
// form along with any new privacy map pairs that should
|
|
// be added to the DB.
|
|
v, newPairs, err := cr.RealToPseudo(
|
|
ctx, privMapPairDB, test.privacyFlags,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, newPairs, len(test.expectNewPairs))
|
|
|
|
// We add each new pair to the expected deny list too.
|
|
for r, p := range newPairs {
|
|
require.True(t, test.expectNewPairs[r])
|
|
|
|
pInt, err := firewalldb.StrToUint64(p)
|
|
require.NoError(t, err)
|
|
|
|
expectedDenyList[pInt] = true
|
|
}
|
|
|
|
denyList, ok := v.(*ChannelRestrict)
|
|
require.True(t, ok)
|
|
|
|
// We expect the original deny list if we switch off
|
|
// privacy mapping.
|
|
if test.privacyFlags.Contains(
|
|
session.ClearChanIDs) {
|
|
|
|
for _, p := range cr.DenyList {
|
|
expectedDenyList[p] = true
|
|
}
|
|
}
|
|
|
|
// Assert that the resulting deny list is the same
|
|
// length as the un-obfuscated one.
|
|
require.Len(t, denyList.DenyList, len(cr.DenyList))
|
|
|
|
// Now iterate over the deny list and assert that each
|
|
// value appears in our expected deny list.
|
|
for _, channel := range denyList.DenyList {
|
|
require.True(t, expectedDenyList[channel])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChannelRestrictResilience ensures that the ChannelRestrictEnforcer is
|
|
// resilient to missing channels during initialization.
|
|
func TestChannelRestrictResilience(t *testing.T) {
|
|
var (
|
|
ctx = context.Background()
|
|
mgr = NewChannelRestrictMgr()
|
|
)
|
|
|
|
// Set up two channel points and IDs.
|
|
txid1, index1, err := newTXID()
|
|
require.NoError(t, err)
|
|
chanPointStr1 := fmt.Sprintf("%s:%d", hex.EncodeToString(txid1), index1)
|
|
chanID1, _ := firewalldb.NewPseudoUint64()
|
|
chanPoint1 := &lnrpc.ChannelPoint{
|
|
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
|
|
FundingTxidStr: hex.EncodeToString(txid1),
|
|
},
|
|
OutputIndex: index1,
|
|
}
|
|
|
|
txid2, index2, err := newTXID()
|
|
require.NoError(t, err)
|
|
chanPointStr2 := fmt.Sprintf("%s:%d", hex.EncodeToString(txid2), index2)
|
|
chanID2, _ := firewalldb.NewPseudoUint64()
|
|
chanPoint2 := &lnrpc.ChannelPoint{
|
|
FundingTxid: &lnrpc.ChannelPoint_FundingTxidStr{
|
|
FundingTxidStr: hex.EncodeToString(txid2),
|
|
},
|
|
OutputIndex: index2,
|
|
}
|
|
|
|
// Request: A request that tries to fetch a channel point that is not
|
|
// known yet. We expect the manager to try to refresh the channel list
|
|
// again to find the missing channel. The negative cache is empty at
|
|
// this point. The call fails because chanPoint2 is not known yet.
|
|
cfg := &mockLndClient{}
|
|
cfg.On(
|
|
"ListChannels", mock.Anything, mock.Anything, mock.Anything,
|
|
mock.Anything,
|
|
).Return(
|
|
[]lndclient.ChannelInfo{
|
|
// Initially we only have chanID1 open. Somebody was
|
|
// able to guess chanID2 even though it's not open yet.
|
|
{
|
|
ChannelID: chanID1,
|
|
ChannelPoint: chanPointStr1,
|
|
},
|
|
}, nil)
|
|
|
|
// Each time a request comes in, a new enforcer is created.
|
|
enf, err := mgr.NewEnforcer(ctx, cfg, &ChannelRestrict{
|
|
DenyList: []uint64{chanID2},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint2,
|
|
},
|
|
},
|
|
)
|
|
|
|
// The request fails because the manager doesn't know about the mapping
|
|
// of chanPoint2 to chanID2. The negative cache is reset to force a
|
|
// reload of the mapping on the next request.
|
|
require.ErrorContains(t, err, "unknown channel point, please retry "+
|
|
"the request")
|
|
cfg.AssertExpectations(t)
|
|
|
|
// Request: Another request that tries to fetch a known channel point.
|
|
// We expect another call to ListChannels to refresh the mapping, since
|
|
// the negative cache was cleared after the last failed request.
|
|
cfg = &mockLndClient{}
|
|
cfg.On(
|
|
"ListChannels", mock.Anything, mock.Anything, mock.Anything,
|
|
mock.Anything,
|
|
).Return(
|
|
[]lndclient.ChannelInfo{
|
|
{
|
|
ChannelID: chanID1,
|
|
ChannelPoint: chanPointStr1,
|
|
},
|
|
}, nil)
|
|
|
|
enf, err = mgr.NewEnforcer(ctx, cfg, &ChannelRestrict{
|
|
DenyList: []uint64{chanID2},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint1,
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
cfg.AssertExpectations(t)
|
|
|
|
// Request: In case we retry the request for the unknown channel, we
|
|
// should error again. This time we don't expect another call to
|
|
// ListChannels because the negative cache was not invalidated before.
|
|
cfg = &mockLndClient{}
|
|
enf, err = mgr.NewEnforcer(ctx, cfg, &ChannelRestrict{
|
|
DenyList: []uint64{chanID2},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint2,
|
|
},
|
|
},
|
|
)
|
|
|
|
// The call errors, which invalidates the negative cache again.
|
|
require.ErrorContains(t, err, "unknown channel point, please retry "+
|
|
"the request")
|
|
cfg.AssertExpectations(t)
|
|
|
|
// We simulate the channel getting confirmed.
|
|
cfg = &mockLndClient{}
|
|
cfg.On(
|
|
"ListChannels", mock.Anything, mock.Anything, mock.Anything,
|
|
mock.Anything,
|
|
).Return(
|
|
[]lndclient.ChannelInfo{
|
|
{
|
|
ChannelID: chanID1,
|
|
ChannelPoint: chanPointStr1,
|
|
},
|
|
{
|
|
ChannelID: chanID2,
|
|
ChannelPoint: chanPointStr2,
|
|
},
|
|
}, nil)
|
|
|
|
// Request: Now the channel is known and in the deny list. The manager
|
|
// resyncs the channel list again and should now know about chanID2
|
|
// mapping to chanPoint2.
|
|
enf, err = mgr.NewEnforcer(ctx, cfg, &ChannelRestrict{
|
|
DenyList: []uint64{chanID2},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// The request gets blocked.
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint2,
|
|
},
|
|
},
|
|
)
|
|
require.ErrorContains(t, err, "illegal action on channel in channel "+
|
|
"restriction list")
|
|
cfg.AssertExpectations(t)
|
|
|
|
// Request: Request to a channel not in the deny list. It should be
|
|
// allowed, without fetching the channel list again.
|
|
cfg = &mockLndClient{}
|
|
enf, err = mgr.NewEnforcer(ctx, cfg, &ChannelRestrict{
|
|
DenyList: []uint64{chanID2},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = enf.HandleRequest(
|
|
ctx, "/lnrpc.Lightning/UpdateChannelPolicy",
|
|
&lnrpc.PolicyUpdateRequest{
|
|
Scope: &lnrpc.PolicyUpdateRequest_ChanPoint{
|
|
ChanPoint: chanPoint1,
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
cfg.AssertExpectations(t)
|
|
}
|