Skip to content

Commit a7ca140

Browse files
authored
Merge pull request #363 from lightninglabs/accounts
Add support for custodial off-chain accounts
2 parents bf11e17 + fc4f4a4 commit a7ca140

35 files changed

+6431
-131
lines changed

accounts/checkers.go

Lines changed: 616 additions & 0 deletions
Large diffs are not rendered by default.

accounts/checkers_test.go

Lines changed: 461 additions & 0 deletions
Large diffs are not rendered by default.

accounts/context.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package accounts
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// ContextKey is the type that we use to identify account specific values in the
9+
// request context. We wrap the string inside a struct because of this comment
10+
// in the context API: "The provided key must be comparable and should not be of
11+
// type string or any other built-in type to avoid collisions between packages
12+
// using context."
13+
type ContextKey struct {
14+
Name string
15+
}
16+
17+
var (
18+
// KeyAccount is the key under which we store the account in the request
19+
// context.
20+
KeyAccount = ContextKey{"account"}
21+
)
22+
23+
// FromContext tries to extract a value from the given context.
24+
func FromContext(ctx context.Context, key ContextKey) interface{} {
25+
return ctx.Value(key)
26+
}
27+
28+
// AddToContext adds the given value to the context for easy retrieval later on.
29+
func AddToContext(ctx context.Context, key ContextKey,
30+
value *OffChainBalanceAccount) context.Context {
31+
32+
return context.WithValue(ctx, key, value)
33+
}
34+
35+
// AccountFromContext attempts to extract an account from the given context.
36+
func AccountFromContext(ctx context.Context) (*OffChainBalanceAccount, error) {
37+
val := FromContext(ctx, KeyAccount)
38+
if val == nil {
39+
return nil, fmt.Errorf("no account found in context")
40+
}
41+
42+
acct, ok := val.(*OffChainBalanceAccount)
43+
if !ok {
44+
return nil, fmt.Errorf("invalid account value in context")
45+
}
46+
47+
return acct, nil
48+
}

accounts/interceptor.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package accounts
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"errors"
7+
"fmt"
8+
9+
mid "github.com/lightninglabs/lightning-terminal/rpcmiddleware"
10+
"github.com/lightningnetwork/lnd/lnrpc"
11+
"github.com/lightningnetwork/lnd/macaroons"
12+
"google.golang.org/protobuf/proto"
13+
"gopkg.in/macaroon.v2"
14+
)
15+
16+
const (
17+
// CondAccount is the custom caveat condition that binds a macaroon to a
18+
// certain account.
19+
CondAccount = "account"
20+
21+
// accountMiddlewareName is the name that is used for the account system
22+
// when registering it to lnd as an RPC middleware.
23+
accountMiddlewareName = "lit-account"
24+
)
25+
26+
// Name returns the name of the interceptor.
27+
func (s *InterceptorService) Name() string {
28+
return accountMiddlewareName
29+
}
30+
31+
// ReadOnly returns true if this interceptor should be registered in read-only
32+
// mode. In read-only mode no custom caveat name can be specified.
33+
func (s *InterceptorService) ReadOnly() bool {
34+
return false
35+
}
36+
37+
// CustomCaveatName returns the name of the custom caveat that is expected to be
38+
// handled by this interceptor. Cannot be specified in read-only mode.
39+
func (s *InterceptorService) CustomCaveatName() string {
40+
return CondAccount
41+
}
42+
43+
// Intercept processes an RPC middleware interception request and returns the
44+
// interception result which either accepts or rejects the intercepted message.
45+
func (s *InterceptorService) Intercept(ctx context.Context,
46+
req *lnrpc.RPCMiddlewareRequest) (*lnrpc.RPCMiddlewareResponse, error) {
47+
48+
// We only allow a single request or response to be handled at the same
49+
// time. This should already be serialized by the RPC stream itself, but
50+
// with the lock we prevent a new request to be handled before we finish
51+
// handling the previous one.
52+
s.requestMtx.Lock()
53+
defer s.requestMtx.Unlock()
54+
55+
mac := &macaroon.Macaroon{}
56+
err := mac.UnmarshalBinary(req.RawMacaroon)
57+
if err != nil {
58+
return mid.RPCErrString(req, "error parsing macaroon: %v", err)
59+
}
60+
61+
acctID, err := accountFromMacaroon(mac)
62+
if err != nil {
63+
return mid.RPCErrString(
64+
req, "error parsing account from macaroon: %v", err,
65+
)
66+
}
67+
68+
// No account lock in the macaroon, something's weird. The interceptor
69+
// wouldn't have been triggered if there was no caveat, so we do expect
70+
// a macaroon here.
71+
if acctID == nil {
72+
return mid.RPCErrString(req, "expected account ID in "+
73+
"macaroon caveat")
74+
}
75+
76+
acct, err := s.Account(*acctID)
77+
if err != nil {
78+
return mid.RPCErrString(
79+
req, "error getting account %x: %v", acctID[:], err,
80+
)
81+
}
82+
83+
log.Debugf("Account auth intercepted, ID=%x, balance_sat=%d, "+
84+
"expired=%v", acct.ID[:], acct.CurrentBalanceSats(),
85+
acct.HasExpired())
86+
87+
if acct.HasExpired() {
88+
return mid.RPCErrString(
89+
req, "account %x has expired", acct.ID[:],
90+
)
91+
}
92+
93+
// We now add the account to the incoming context to give each checker
94+
// access to it if required.
95+
ctxAccount := AddToContext(ctx, KeyAccount, acct)
96+
97+
switch r := req.InterceptType.(type) {
98+
// In the authentication phase we just check that the account hasn't
99+
// expired yet (which we already did). This is only be used for
100+
// establishing streams, so we don't see a request yet.
101+
case *lnrpc.RPCMiddlewareRequest_StreamAuth:
102+
return mid.RPCOk(req)
103+
104+
// Parse incoming requests and act on them.
105+
case *lnrpc.RPCMiddlewareRequest_Request:
106+
msg, err := parseRPCMessage(r.Request)
107+
if err != nil {
108+
return mid.RPCErr(req, err)
109+
}
110+
111+
return mid.RPCErr(req, s.checkers.checkIncomingRequest(
112+
ctxAccount, r.Request.MethodFullUri, msg,
113+
))
114+
115+
// Parse and possibly manipulate outgoing responses.
116+
case *lnrpc.RPCMiddlewareRequest_Response:
117+
msg, err := parseRPCMessage(r.Response)
118+
if err != nil {
119+
return mid.RPCErr(req, err)
120+
}
121+
122+
replacement, err := s.checkers.replaceOutgoingResponse(
123+
ctxAccount, r.Response.MethodFullUri, msg,
124+
)
125+
if err != nil {
126+
return mid.RPCErr(req, err)
127+
}
128+
129+
// No error occurred but the response should be replaced with
130+
// the given custom response. Wrap it in the correct RPC
131+
// response of the interceptor now.
132+
if replacement != nil {
133+
return mid.RPCReplacement(req, replacement)
134+
}
135+
136+
// No error and no replacement, just return an empty response of
137+
// the correct type.
138+
return mid.RPCOk(req)
139+
140+
default:
141+
return mid.RPCErrString(req, "invalid intercept type: %v", r)
142+
}
143+
}
144+
145+
// parseRPCMessage parses a raw RPC message into the original protobuf message
146+
// type.
147+
func parseRPCMessage(msg *lnrpc.RPCMessage) (proto.Message, error) {
148+
// Are we intercepting an error message being returned?
149+
if msg.TypeName == "error" {
150+
return nil, errors.New(string(msg.Serialized))
151+
}
152+
153+
// No, it's a normal message.
154+
parsedMsg, err := mid.ParseProtobuf(msg.TypeName, msg.Serialized)
155+
if err != nil {
156+
return nil, fmt.Errorf("error parsing proto of type %v: %v",
157+
msg.TypeName, err)
158+
}
159+
160+
return parsedMsg, nil
161+
}
162+
163+
// accountFromMacaroon attempts to extract an account ID from the custom account
164+
// caveat in the macaroon.
165+
func accountFromMacaroon(mac *macaroon.Macaroon) (*AccountID, error) {
166+
// Extract the account caveat from the macaroon.
167+
macaroonAccount := macaroons.GetCustomCaveatCondition(mac, CondAccount)
168+
if macaroonAccount == "" {
169+
// There is no condition that locks the macaroon to an account,
170+
// so there is nothing to check.
171+
return nil, nil
172+
}
173+
174+
// The macaroon is indeed locked to an account. Fetch the account and
175+
// validate its balance.
176+
accountIDBytes, err := hex.DecodeString(macaroonAccount)
177+
if err != nil {
178+
return nil, err
179+
}
180+
181+
var accountID AccountID
182+
copy(accountID[:], accountIDBytes)
183+
return &accountID, nil
184+
}

0 commit comments

Comments
 (0)