mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
firewall: randomize responses with PrivacyMapper
Adds amount, timestamp, and channel initiator obfuscation to the two response handlers `handleFwdHistoryResponse` and `handleListChannelsResponse`. In order to preserve privacy and still ensure functioning of algorithms that rely on the randomized data, a trade-off between randomization and accuracy needs to be found. We choose ten minutes for forwarding timestamps as this breaks time correlation of payments. The amount obfuscation is chosen to be 5% and applies to the forwarding amount and channel details to hide balances. We also remove details of pending HTLCs in channels. Random obfuscation for amounts is chosen here instead of rounding to have non-deterministic alteration of amounts, which is especially important for forwardings to also break amount correlation. Randomly varying around a certain value will statistically skew averages less than rounding for algorithms that rely on aggregation of individual data. The privacy mapper is chosen to accept a randomness input in order to ensure deterministic testing even when other handlers are changed in the future.
This commit is contained in:
parent
5a453cd36c
commit
a74ae880c4
3 changed files with 348 additions and 38 deletions
|
|
@ -46,14 +46,16 @@ var _ mid.RequestInterceptor = (*PrivacyMapper)(nil)
|
|||
// PrivacyMapper is a RequestInterceptor that maps any pseudo names in certain
|
||||
// requests to their real values and vice versa for responses.
|
||||
type PrivacyMapper struct {
|
||||
newDB firewalldb.NewPrivacyMapDB
|
||||
newDB firewalldb.NewPrivacyMapDB
|
||||
randIntn func(int) (int, error)
|
||||
}
|
||||
|
||||
// NewPrivacyMapper returns a new instance of PrivacyMapper.
|
||||
func NewPrivacyMapper(newDB firewalldb.NewPrivacyMapDB) *PrivacyMapper {
|
||||
return &PrivacyMapper{
|
||||
newDB: newDB,
|
||||
}
|
||||
// NewPrivacyMapper returns a new instance of PrivacyMapper. The randIntn
|
||||
// function is used to draw randomness for request field obfuscation.
|
||||
func NewPrivacyMapper(newDB firewalldb.NewPrivacyMapDB,
|
||||
randIntn func(int) (int, error)) *PrivacyMapper {
|
||||
|
||||
return &PrivacyMapper{newDB: newDB, randIntn: randIntn}
|
||||
}
|
||||
|
||||
// Name returns the name of the interceptor.
|
||||
|
|
@ -224,7 +226,7 @@ func (p *PrivacyMapper) checkers(
|
|||
"/lnrpc.Lightning/ForwardingHistory": mid.NewResponseRewriter(
|
||||
&lnrpc.ForwardingHistoryRequest{},
|
||||
&lnrpc.ForwardingHistoryResponse{},
|
||||
handleFwdHistoryResponse(db),
|
||||
handleFwdHistoryResponse(db, p.randIntn),
|
||||
mid.PassThroughErrorHandler,
|
||||
),
|
||||
"/lnrpc.Lightning/FeeReport": mid.NewResponseRewriter(
|
||||
|
|
@ -236,7 +238,8 @@ func (p *PrivacyMapper) checkers(
|
|||
&lnrpc.ListChannelsRequest{},
|
||||
&lnrpc.ListChannelsResponse{},
|
||||
handleListChannelsRequest(db),
|
||||
handleListChannelsResponse(db),
|
||||
handleListChannelsResponse(db, p.randIntn),
|
||||
|
||||
mid.PassThroughErrorHandler,
|
||||
),
|
||||
"/lnrpc.Lightning/UpdateChannelPolicy": mid.NewFullRewriter(
|
||||
|
|
@ -282,15 +285,16 @@ func handleGetInfoRequest(db firewalldb.PrivacyMapDB) func(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
func handleFwdHistoryResponse(db firewalldb.PrivacyMapDB) func(
|
||||
ctx context.Context, r *lnrpc.ForwardingHistoryResponse) (proto.Message,
|
||||
error) {
|
||||
func handleFwdHistoryResponse(db firewalldb.PrivacyMapDB,
|
||||
randIntn func(int) (int, error)) func(ctx context.Context,
|
||||
r *lnrpc.ForwardingHistoryResponse) (proto.Message, error) {
|
||||
|
||||
return func(ctx context.Context, r *lnrpc.ForwardingHistoryResponse) (
|
||||
return func(_ context.Context, r *lnrpc.ForwardingHistoryResponse) (
|
||||
proto.Message, error) {
|
||||
|
||||
err := db.Update(func(tx firewalldb.PrivacyMapTx) error {
|
||||
for _, fe := range r.ForwardingEvents {
|
||||
// Deterministically hide channel ids.
|
||||
chanIn, err := firewalldb.HideUint64(
|
||||
tx, fe.ChanIdIn,
|
||||
)
|
||||
|
|
@ -306,6 +310,44 @@ func handleFwdHistoryResponse(db firewalldb.PrivacyMapDB) func(
|
|||
return err
|
||||
}
|
||||
fe.ChanIdOut = chanOut
|
||||
|
||||
// We randomize the outgoing amount for privacy.
|
||||
hiddenAmtOutMsat, err := hideAmount(
|
||||
randIntn, amountVariation,
|
||||
fe.AmtOutMsat,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fe.AmtOutMsat = hiddenAmtOutMsat
|
||||
|
||||
// We randomize fees for privacy.
|
||||
hiddenFeeMsat, err := hideAmount(
|
||||
randIntn, amountVariation, fe.FeeMsat,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fe.FeeMsat = hiddenFeeMsat
|
||||
|
||||
// Populate other fields in a consistent manner.
|
||||
fe.AmtInMsat = fe.AmtOutMsat + fe.FeeMsat
|
||||
fe.AmtOut = fe.AmtOutMsat / 1000
|
||||
fe.AmtIn = fe.AmtInMsat / 1000
|
||||
fe.Fee = fe.FeeMsat / 1000
|
||||
|
||||
// We randomize the forwarding timestamp.
|
||||
timestamp := time.Unix(0, int64(fe.TimestampNs))
|
||||
hiddenTimestamp, err := hideTimestamp(
|
||||
randIntn, timeVariation, timestamp,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fe.TimestampNs = uint64(
|
||||
hiddenTimestamp.UnixNano(),
|
||||
)
|
||||
fe.Timestamp = uint64(hiddenTimestamp.Unix())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
|
@ -382,22 +424,37 @@ func handleListChannelsRequest(db firewalldb.PrivacyMapDB) func(
|
|||
}
|
||||
}
|
||||
|
||||
func handleListChannelsResponse(db firewalldb.PrivacyMapDB) func(
|
||||
ctx context.Context, r *lnrpc.ListChannelsResponse) (proto.Message,
|
||||
error) {
|
||||
func handleListChannelsResponse(db firewalldb.PrivacyMapDB,
|
||||
randIntn func(int) (int, error)) func(ctx context.Context,
|
||||
r *lnrpc.ListChannelsResponse) (proto.Message, error) {
|
||||
|
||||
return func(ctx context.Context, r *lnrpc.ListChannelsResponse) (
|
||||
return func(_ context.Context, r *lnrpc.ListChannelsResponse) (
|
||||
proto.Message, error) {
|
||||
|
||||
hideAmount := func(a int64) (int64, error) {
|
||||
hiddenAmount, err := hideAmount(
|
||||
randIntn, amountVariation, uint64(a),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int64(hiddenAmount), nil
|
||||
}
|
||||
|
||||
err := db.Update(func(tx firewalldb.PrivacyMapTx) error {
|
||||
for i, c := range r.Channels {
|
||||
ch := r.Channels[i]
|
||||
|
||||
// Deterministically hide the peer pubkey,
|
||||
// the channel point, and the channel id.
|
||||
pk, err := firewalldb.HideString(
|
||||
tx, c.RemotePubkey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Channels[i].RemotePubkey = pk
|
||||
ch.RemotePubkey = pk
|
||||
|
||||
cp, err := firewalldb.HideChanPointStr(
|
||||
tx, c.ChannelPoint,
|
||||
|
|
@ -405,13 +462,83 @@ func handleListChannelsResponse(db firewalldb.PrivacyMapDB) func(
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Channels[i].ChannelPoint = cp
|
||||
ch.ChannelPoint = cp
|
||||
|
||||
cid, err := firewalldb.HideUint64(tx, c.ChanId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Channels[i].ChanId = cid
|
||||
ch.ChanId = cid
|
||||
|
||||
// We hide the initiator.
|
||||
initiator, err := hideBool(randIntn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.Initiator = initiator
|
||||
|
||||
// Consider the capacity to be public
|
||||
// information. We don't care about reserves, as
|
||||
// having some funds as a balance is the normal
|
||||
// state over the lifetime of a channel. The
|
||||
// balance would be zero only for the initial
|
||||
// state as a non-funder.
|
||||
|
||||
// We randomize local/remote balances.
|
||||
localBalance, err := hideAmount(c.LocalBalance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We may have a too large value for the local
|
||||
// balance, restrict it to the capacity.
|
||||
if localBalance > c.Capacity {
|
||||
localBalance = c.Capacity
|
||||
}
|
||||
if ch.Initiator {
|
||||
localBalance -= ch.CommitFee
|
||||
}
|
||||
ch.LocalBalance = localBalance
|
||||
|
||||
// We adapt the remote balance accordingly.
|
||||
remoteBalance := c.Capacity - localBalance -
|
||||
c.CommitFee
|
||||
if !ch.Initiator {
|
||||
remoteBalance -= ch.CommitFee
|
||||
}
|
||||
ch.RemoteBalance = remoteBalance
|
||||
|
||||
// We hide the total sats sent and received.
|
||||
hiddenSatsReceived, err := hideAmount(
|
||||
c.TotalSatoshisReceived,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.TotalSatoshisReceived = hiddenSatsReceived
|
||||
|
||||
hiddenSatsSent, err := hideAmount(
|
||||
c.TotalSatoshisSent,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.TotalSatoshisSent = hiddenSatsSent
|
||||
|
||||
// We only keep track of the number of unsettled
|
||||
// HTLCs.
|
||||
ch.PendingHtlcs = make(
|
||||
[]*lnrpc.HTLC, len(ch.PendingHtlcs),
|
||||
)
|
||||
|
||||
// We hide the unsettled balance.
|
||||
unsettled, err := hideAmount(
|
||||
c.UnsettledBalance,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch.UnsettledBalance = unsettled
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -48,28 +48,56 @@ func TestPrivacyMapper(t *testing.T) {
|
|||
msg: &lnrpc.ForwardingHistoryResponse{
|
||||
ForwardingEvents: []*lnrpc.ForwardingEvent{
|
||||
{
|
||||
AmtIn: 100,
|
||||
ChanIdIn: 123,
|
||||
ChanIdOut: 321,
|
||||
AmtIn: 2_000,
|
||||
AmtInMsat: 2_000_000,
|
||||
AmtOut: 1_000,
|
||||
AmtOutMsat: 1_000_000,
|
||||
Fee: 1_000,
|
||||
FeeMsat: 1_000_000,
|
||||
Timestamp: 1_000,
|
||||
TimestampNs: 1_000_000_000_000,
|
||||
ChanIdIn: 123,
|
||||
ChanIdOut: 321,
|
||||
},
|
||||
{
|
||||
Fee: 200,
|
||||
ChanIdIn: 678,
|
||||
ChanIdOut: 876,
|
||||
AmtIn: 3_000,
|
||||
AmtInMsat: 3_000_000,
|
||||
AmtOut: 2_000,
|
||||
AmtOutMsat: 2_000_000,
|
||||
Fee: 1_000,
|
||||
FeeMsat: 1_000_000,
|
||||
Timestamp: 1_000,
|
||||
TimestampNs: 1_000_000_000_000,
|
||||
ChanIdIn: 678,
|
||||
ChanIdOut: 876,
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedReplacement: &lnrpc.ForwardingHistoryResponse{
|
||||
ForwardingEvents: []*lnrpc.ForwardingEvent{
|
||||
{
|
||||
AmtIn: 100,
|
||||
ChanIdIn: 5178778334600911958,
|
||||
ChanIdOut: 3446430762436373227,
|
||||
AmtIn: 1_950,
|
||||
AmtInMsat: 1_950_200,
|
||||
AmtOut: 975,
|
||||
AmtOutMsat: 975_100,
|
||||
Fee: 975,
|
||||
FeeMsat: 975_100,
|
||||
Timestamp: 700,
|
||||
TimestampNs: 700_000_000_100,
|
||||
ChanIdIn: 5178778334600911958,
|
||||
ChanIdOut: 3446430762436373227,
|
||||
},
|
||||
{
|
||||
Fee: 200,
|
||||
ChanIdIn: 8672172843977902018,
|
||||
ChanIdOut: 1378354177616075123,
|
||||
AmtIn: 2_925,
|
||||
AmtInMsat: 2_925_200,
|
||||
AmtOut: 1_950,
|
||||
AmtOutMsat: 1_950_100,
|
||||
Fee: 975,
|
||||
FeeMsat: 975_100,
|
||||
Timestamp: 700,
|
||||
TimestampNs: 700_000_000_100,
|
||||
ChanIdIn: 8672172843977902018,
|
||||
ChanIdOut: 1378354177616075123,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -121,18 +149,34 @@ func TestPrivacyMapper(t *testing.T) {
|
|||
msg: &lnrpc.ListChannelsResponse{
|
||||
Channels: []*lnrpc.Channel{
|
||||
{
|
||||
RemotePubkey: "01020304",
|
||||
ChanId: 123,
|
||||
ChannelPoint: "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd:0",
|
||||
Capacity: 1_000_000,
|
||||
RemoteBalance: 600_000,
|
||||
LocalBalance: 499_000,
|
||||
CommitFee: 1_000,
|
||||
TotalSatoshisSent: 500_000,
|
||||
TotalSatoshisReceived: 450_000,
|
||||
RemotePubkey: "01020304",
|
||||
Initiator: false,
|
||||
ChanId: 123,
|
||||
ChannelPoint: "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd:0",
|
||||
PendingHtlcs: []*lnrpc.HTLC{{HashLock: []byte("aaaa")}, {HashLock: []byte("bbbb")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedReplacement: &lnrpc.ListChannelsResponse{
|
||||
Channels: []*lnrpc.Channel{
|
||||
{
|
||||
RemotePubkey: "c8134495",
|
||||
ChanId: 5178778334600911958,
|
||||
ChannelPoint: "097ef666a61919ff3413b3b701eae3a5cbac08f70c0ca567806e1fa6acbfe384:2161781494",
|
||||
Capacity: 1_000_000,
|
||||
RemoteBalance: 513_375,
|
||||
LocalBalance: 485_625,
|
||||
CommitFee: 1_000,
|
||||
TotalSatoshisSent: 487_600,
|
||||
TotalSatoshisReceived: 438_850,
|
||||
RemotePubkey: "c8134495",
|
||||
Initiator: true,
|
||||
ChanId: 5178778334600911958,
|
||||
ChannelPoint: "097ef666a61919ff3413b3b701eae3a5cbac08f70c0ca567806e1fa6acbfe384:2161781494",
|
||||
PendingHtlcs: []*lnrpc.HTLC{{}, {}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -247,7 +291,10 @@ func TestPrivacyMapper(t *testing.T) {
|
|||
}
|
||||
|
||||
db := newMockDB(t, mapPreloadRealToPseudo, sessionID)
|
||||
p := NewPrivacyMapper(db.NewSessionDB)
|
||||
|
||||
// randIntn is used for deterministic testing.
|
||||
randIntn := func(n int) (int, error) { return 100, nil }
|
||||
p := NewPrivacyMapper(db.NewSessionDB, randIntn)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
|
@ -286,6 +333,111 @@ func TestPrivacyMapper(t *testing.T) {
|
|||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Subtest to test behavior with real randomness.
|
||||
t.Run("Response with randomness", func(t *testing.T) {
|
||||
msg := &lnrpc.ForwardingHistoryResponse{
|
||||
ForwardingEvents: []*lnrpc.ForwardingEvent{
|
||||
{
|
||||
AmtIn: 2_000,
|
||||
AmtInMsat: 2_000_000,
|
||||
AmtOut: 1_000,
|
||||
AmtOutMsat: 1_000_000,
|
||||
Fee: 0,
|
||||
FeeMsat: 1,
|
||||
Timestamp: 1_000_000,
|
||||
TimestampNs: 1_000_000 * 1e9,
|
||||
ChanIdIn: 123,
|
||||
ChanIdOut: 321,
|
||||
},
|
||||
},
|
||||
}
|
||||
rawMsg, err := proto.Marshal(msg)
|
||||
require.NoError(t, err)
|
||||
|
||||
p = NewPrivacyMapper(db.NewSessionDB, CryptoRandIntn)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We test the independent outgoing amount (incoming amount
|
||||
// would also be dependend on the fee variation).
|
||||
amtOutMsat := msg.ForwardingEvents[0].AmtOutMsat
|
||||
amtInterval := uint64(amountVariation * float64(amtOutMsat))
|
||||
minAmt := amtOutMsat - amtInterval/2
|
||||
maxAmt := amtOutMsat + amtInterval/2
|
||||
|
||||
// We keep track of the timestamp. We test only the timestamp in
|
||||
// seconds as there can be numerical inaccuracies with the
|
||||
// nanosecond one.
|
||||
timestamp := msg.ForwardingEvents[0].Timestamp
|
||||
timestampInterval := uint64(timeVariation) / 1e9
|
||||
minTime := timestamp - timestampInterval/2
|
||||
maxTime := timestamp + timestampInterval/2
|
||||
|
||||
// We need a certain number of samples to have statistical
|
||||
// accuracy.
|
||||
numSamples := 10_000
|
||||
|
||||
// We require a five percent accuracy for 10_000 samples.
|
||||
relativeTestAccuracy := 0.05
|
||||
|
||||
amounts := make([]uint64, numSamples)
|
||||
timestamps := make([]uint64, numSamples)
|
||||
|
||||
for i := 0; i < numSamples; i++ {
|
||||
interceptReq := &rpcperms.InterceptionRequest{
|
||||
Type: rpcperms.TypeResponse,
|
||||
Macaroon: mac,
|
||||
RawMacaroon: macBytes,
|
||||
FullURI: "/lnrpc.Lightning/ForwardingHistory",
|
||||
ProtoSerialized: rawMsg,
|
||||
ProtoTypeName: string(
|
||||
proto.MessageName(msg),
|
||||
),
|
||||
}
|
||||
|
||||
mwReq, err := interceptReq.ToRPC(1, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := p.Intercept(context.Background(), mwReq)
|
||||
require.NoError(t, err)
|
||||
|
||||
feedback := resp.GetFeedback()
|
||||
|
||||
fw := &lnrpc.ForwardingHistoryResponse{}
|
||||
err = proto.Unmarshal(
|
||||
feedback.ReplacementSerialized, fw,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
amounts[i] = fw.ForwardingEvents[0].AmtOutMsat
|
||||
require.LessOrEqual(t, amounts[i], maxAmt)
|
||||
require.GreaterOrEqual(t, amounts[i], minAmt)
|
||||
|
||||
timestamps[i] = fw.ForwardingEvents[0].Timestamp
|
||||
require.LessOrEqual(t, timestamps[i], maxTime)
|
||||
require.GreaterOrEqual(t, timestamps[i], minTime)
|
||||
}
|
||||
|
||||
// The formula for the expected variance is taken from
|
||||
// https://en.wikipedia.org/wiki/Continuous_uniform_distribution
|
||||
expectedVar := func(min, max uint64) uint64 {
|
||||
return (max - min) * (max - min) / 12
|
||||
}
|
||||
|
||||
// Test amounts for mean and variance.
|
||||
expectedAmtVariance := expectedVar(minAmt, maxAmt)
|
||||
require.InEpsilon(t, expectedAmtVariance, variance(amounts),
|
||||
relativeTestAccuracy)
|
||||
require.InEpsilon(t, amtOutMsat, mean(amounts),
|
||||
relativeTestAccuracy)
|
||||
|
||||
// Test timestamps for mean and variance.
|
||||
expectedTimeVariance := expectedVar(minTime, maxTime)
|
||||
require.InEpsilon(t, expectedTimeVariance, variance(timestamps),
|
||||
relativeTestAccuracy)
|
||||
require.InEpsilon(t, timestamp, mean(timestamps),
|
||||
relativeTestAccuracy)
|
||||
})
|
||||
}
|
||||
|
||||
type mockDB map[string]*mockPrivacyMapDB
|
||||
|
|
@ -518,3 +670,29 @@ func TestHideBool(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.False(t, val)
|
||||
}
|
||||
|
||||
// mean computes the mean of the given slice of numbers.
|
||||
func mean(numbers []uint64) uint64 {
|
||||
sum := uint64(0)
|
||||
|
||||
for _, n := range numbers {
|
||||
sum += n
|
||||
}
|
||||
|
||||
return sum / uint64(len(numbers))
|
||||
}
|
||||
|
||||
// variance computes the variance of the given slice of numbers.
|
||||
func variance(numbers []uint64) uint64 {
|
||||
mean := mean(numbers)
|
||||
sum := 0.0
|
||||
|
||||
// We divide in each step to have smaller numbers.
|
||||
norm := float64(len(numbers) - 1)
|
||||
|
||||
for _, n := range numbers {
|
||||
sum += float64((n-mean)*(n-mean)) / norm
|
||||
}
|
||||
|
||||
return uint64(sum)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -728,7 +728,12 @@ func (g *LightningTerminal) startSubservers() error {
|
|||
g.accountServiceStarted = true
|
||||
|
||||
requestLogger := firewall.NewRequestLogger(g.firewallDB)
|
||||
privacyMapper := firewall.NewPrivacyMapper(
|
||||
g.firewallDB.PrivacyDB, firewall.CryptoRandIntn,
|
||||
)
|
||||
|
||||
mw := []mid.RequestInterceptor{
|
||||
privacyMapper,
|
||||
g.accountService,
|
||||
requestLogger,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue