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:
bitromortac 2022-11-04 10:57:47 +01:00 committed by Elle Mouton
parent 5a453cd36c
commit a74ae880c4
No known key found for this signature in database
GPG key ID: D7D916376026F177
3 changed files with 348 additions and 38 deletions

View file

@ -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

View file

@ -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)
}

View file

@ -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,
}