diff --git a/auth/authenticator.go b/auth/authenticator.go index 080245a..9273d38 100644 --- a/auth/authenticator.go +++ b/auth/authenticator.go @@ -41,7 +41,7 @@ func (l *L402Authenticator) Accept(header *http.Header, serviceName string) bool // Try reading the macaroon and preimage from the HTTP header. This can // be in different header fields depending on the implementation and/or // protocol. - mac, preimage, err := l402.FromHeader(header) + mac, preimage, discharges, err := l402.FromHeader(header) if err != nil { log.Debugf("Deny: %v", err) return false @@ -51,6 +51,7 @@ func (l *L402Authenticator) Accept(header *http.Header, serviceName string) bool Macaroon: mac, Preimage: preimage, TargetService: serviceName, + Discharges: discharges, } err = l.minter.VerifyL402(context.Background(), verificationParams) if err != nil { diff --git a/l402/header.go b/l402/header.go index bca5419..941980b 100644 --- a/l402/header.go +++ b/l402/header.go @@ -42,7 +42,13 @@ var ( // // If only the macaroon is sent in header 2 or three then it is expected to have // a caveat with the preimage attached to it. -func FromHeader(header *http.Header) (*macaroon.Macaroon, lntypes.Preimage, error) { +// +// The returned discharge macaroons will be non-nil when the binary-encoded +// macaroon data contains more than one macaroon (as per the macaroon.Slice +// convention, the first is the root macaroon and the rest are discharges for +// its third-party caveats). +func FromHeader(header *http.Header) (*macaroon.Macaroon, lntypes.Preimage, + []*macaroon.Macaroon, error) { var authHeader string switch { @@ -63,32 +69,50 @@ func FromHeader(header *http.Header) (*macaroon.Macaroon, lntypes.Preimage, erro } if len(matches) != 4 { - return nil, lntypes.Preimage{}, fmt.Errorf("invalid "+ - "auth header format: %s", authHeader) + return nil, lntypes.Preimage{}, nil, + fmt.Errorf("invalid auth header "+ + "format: %s", authHeader) } // Decode the content of the two parts of the header value. macBase64, preimageHex := matches[2], matches[3] macBytes, err := base64.StdEncoding.DecodeString(macBase64) if err != nil { - return nil, lntypes.Preimage{}, fmt.Errorf("base64 "+ - "decode of macaroon failed: %v", err) + return nil, lntypes.Preimage{}, nil, + fmt.Errorf("base64 decode of macaroon "+ + "failed: %v", err) } - mac := &macaroon.Macaroon{} - err = mac.UnmarshalBinary(macBytes) - if err != nil { - return nil, lntypes.Preimage{}, fmt.Errorf("unable to "+ - "unmarshal macaroon: %v", err) + + // Use Slice to unmarshal so we can extract discharge + // macaroons if present. By convention the first macaroon + // is the root and the rest are discharges. + var slice macaroon.Slice + if err := slice.UnmarshalBinary(macBytes); err != nil { + return nil, lntypes.Preimage{}, nil, + fmt.Errorf("unable to unmarshal "+ + "macaroon: %v", err) + } + if len(slice) == 0 { + return nil, lntypes.Preimage{}, nil, + fmt.Errorf("no macaroon found in " + + "auth header") + } + mac := slice[0] + var discharges []*macaroon.Macaroon + if len(slice) > 1 { + discharges = slice[1:] } + preimage, err := lntypes.MakePreimageFromStr(preimageHex) if err != nil { - return nil, lntypes.Preimage{}, fmt.Errorf("hex "+ - "decode of preimage failed: %v", err) + return nil, lntypes.Preimage{}, nil, + fmt.Errorf("hex decode of preimage "+ + "failed: %v", err) } // All done, we don't need to extract anything from the // macaroon since the preimage was presented separately. - return mac, preimage, nil + return mac, preimage, discharges, nil // Header field 2: Contains only the macaroon. case header.Get(HeaderMacaroonMD) != "": @@ -99,43 +123,60 @@ func FromHeader(header *http.Header) (*macaroon.Macaroon, lntypes.Preimage, erro authHeader = header.Get(HeaderMacaroon) default: - return nil, lntypes.Preimage{}, fmt.Errorf("no auth header " + - "provided") + return nil, lntypes.Preimage{}, nil, fmt.Errorf( + "no auth header provided", + ) } // For case 2 and 3, we need to actually unmarshal the macaroon to - // extract the preimage. + // extract the preimage. Use Slice to support discharge macaroons. macBytes, err := hex.DecodeString(authHeader) if err != nil { - return nil, lntypes.Preimage{}, fmt.Errorf("hex decode of "+ - "macaroon failed: %v", err) + return nil, lntypes.Preimage{}, nil, fmt.Errorf("hex decode "+ + "of macaroon failed: %v", err) } - mac := &macaroon.Macaroon{} - err = mac.UnmarshalBinary(macBytes) - if err != nil { - return nil, lntypes.Preimage{}, fmt.Errorf("unable to "+ + var slice macaroon.Slice + if err := slice.UnmarshalBinary(macBytes); err != nil { + return nil, lntypes.Preimage{}, nil, fmt.Errorf("unable to "+ "unmarshal macaroon: %v", err) } + if len(slice) == 0 { + return nil, lntypes.Preimage{}, nil, fmt.Errorf( + "no macaroon found in header", + ) + } + mac := slice[0] + var discharges []*macaroon.Macaroon + if len(slice) > 1 { + discharges = slice[1:] + } + preimageHex, ok := HasCaveat(mac, PreimageKey) if !ok { - return nil, lntypes.Preimage{}, errors.New("preimage caveat " + - "not found") + return nil, lntypes.Preimage{}, nil, errors.New( + "preimage caveat not found", + ) } preimage, err := lntypes.MakePreimageFromStr(preimageHex) if err != nil { - return nil, lntypes.Preimage{}, fmt.Errorf("hex decode of "+ - "preimage failed: %v", err) + return nil, lntypes.Preimage{}, nil, fmt.Errorf("hex decode "+ + "of preimage failed: %v", err) } - return mac, preimage, nil + return mac, preimage, discharges, nil } // SetHeader sets the provided authentication elements as the default/standard -// HTTP header for the L402 protocol. +// HTTP header for the L402 protocol. If discharges are provided, they are +// serialized alongside the root macaroon using the macaroon.Slice convention. func SetHeader(header *http.Header, mac *macaroon.Macaroon, - preimage fmt.Stringer) error { + preimage fmt.Stringer, discharges []*macaroon.Macaroon) error { - macBytes, err := mac.MarshalBinary() + // Build a Slice with the root macaroon first, followed by any + // discharge macaroons, then serialize. + slice := macaroon.Slice{mac} + slice = append(slice, discharges...) + macBytes, err := slice.MarshalBinary() if err != nil { return err } diff --git a/l402/header_test.go b/l402/header_test.go new file mode 100644 index 0000000..2ed6838 --- /dev/null +++ b/l402/header_test.go @@ -0,0 +1,237 @@ +package l402 + +import ( + "encoding/base64" + "encoding/hex" + "net/http" + "testing" + + "github.com/lightningnetwork/lnd/lntypes" + "github.com/stretchr/testify/require" + "gopkg.in/macaroon.v2" +) + +var ( + testRootKey = []byte("aabbccddeeff00112233445566778899") + testPreimage = lntypes.Preimage{ + 0x49, 0x34, 0x9d, 0xfe, 0xa4, 0xab, 0xed, 0x3c, + 0xd1, 0x4f, 0x6d, 0x35, 0x6a, 0xfa, 0x83, 0xde, + 0x97, 0x87, 0xb6, 0x09, 0xf0, 0x88, 0xc8, 0xdf, + 0x09, 0xba, 0xcc, 0x7b, 0x4b, 0xd2, 0x1b, 0x39, + } + testPreimageHex = "49349dfea4abed3cd14f6d356afa83de" + + "9787b609f088c8df09bacc7b4bd21b39" +) + +// newTestMacaroon creates a macaroon with a preimage caveat for testing. +func newTestMacaroon(t *testing.T) *macaroon.Macaroon { + t.Helper() + + mac, err := macaroon.New( + testRootKey, []byte("test-id"), "aperture", + macaroon.LatestVersion, + ) + require.NoError(t, err) + + return mac +} + +// newTestMacaroonWithPreimage creates a macaroon with a preimage caveat +// embedded, suitable for the hex-only header formats. +func newTestMacaroonWithPreimage(t *testing.T) *macaroon.Macaroon { + t.Helper() + + mac := newTestMacaroon(t) + err := AddFirstPartyCaveats(mac, Caveat{ + Condition: PreimageKey, + Value: testPreimageHex, + }) + require.NoError(t, err) + + return mac +} + +// newTestDischarge creates a discharge macaroon bound to the given root +// macaroon's signature. +func newTestDischarge(t *testing.T, root *macaroon.Macaroon) *macaroon.Macaroon { + t.Helper() + + thirdPartyKey := []byte("third-party-shared-secret-key!!!") + caveatID := []byte("tp-caveat-id") + + err := root.AddThirdPartyCaveat( + thirdPartyKey, caveatID, "https://thirdparty", + ) + require.NoError(t, err) + + discharge, err := macaroon.New( + thirdPartyKey, caveatID, "https://thirdparty", + macaroon.LatestVersion, + ) + require.NoError(t, err) + + discharge.Bind(root.Signature()) + + return discharge +} + +// TestFromHeaderAuthDischarges tests that discharge macaroons survive a +// round-trip through SetHeader/FromHeader for the Authorization header format. +func TestFromHeaderAuthDischarges(t *testing.T) { + t.Parallel() + + mac := newTestMacaroon(t) + discharge := newTestDischarge(t, mac) + + // Serialize root + discharge into the Authorization header. + header := http.Header{} + err := SetHeader(&header, mac, testPreimage, []*macaroon.Macaroon{ + discharge, + }) + require.NoError(t, err) + + // Parse them back out. + gotMac, gotPreimage, gotDischarges, err := FromHeader(&header) + require.NoError(t, err) + require.True(t, mac.Equal(gotMac)) + require.Equal(t, testPreimage, gotPreimage) + require.Len(t, gotDischarges, 1) + require.True(t, discharge.Equal(gotDischarges[0])) +} + +// TestFromHeaderAuthNoDischarges tests that the Authorization header format +// still works when no discharges are present. +func TestFromHeaderAuthNoDischarges(t *testing.T) { + t.Parallel() + + mac := newTestMacaroon(t) + + header := http.Header{} + err := SetHeader(&header, mac, testPreimage, nil) + require.NoError(t, err) + + gotMac, gotPreimage, gotDischarges, err := FromHeader(&header) + require.NoError(t, err) + require.True(t, mac.Equal(gotMac)) + require.Equal(t, testPreimage, gotPreimage) + require.Nil(t, gotDischarges) +} + +// TestFromHeaderHexDischarges tests that discharge macaroons are correctly +// extracted from the hex-encoded Grpc-Metadata-Macaroon header format. +func TestFromHeaderHexDischarges(t *testing.T) { + t.Parallel() + + mac := newTestMacaroonWithPreimage(t) + discharge := newTestDischarge(t, mac) + + // Manually build the hex-encoded header with a Slice containing + // root + discharge. + slice := macaroon.Slice{mac, discharge} + sliceBytes, err := slice.MarshalBinary() + require.NoError(t, err) + + header := http.Header{ + HeaderMacaroonMD: []string{hex.EncodeToString(sliceBytes)}, + } + + gotMac, gotPreimage, gotDischarges, err := FromHeader(&header) + require.NoError(t, err) + require.True(t, mac.Equal(gotMac)) + require.Equal(t, testPreimage, gotPreimage) + require.Len(t, gotDischarges, 1) + require.True(t, discharge.Equal(gotDischarges[0])) +} + +// TestFromHeaderHexNoDischarges tests that the hex-encoded header format +// still works for a single macaroon without discharges. +func TestFromHeaderHexNoDischarges(t *testing.T) { + t.Parallel() + + mac := newTestMacaroonWithPreimage(t) + + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + + header := http.Header{ + HeaderMacaroon: []string{hex.EncodeToString(macBytes)}, + } + + gotMac, gotPreimage, gotDischarges, err := FromHeader(&header) + require.NoError(t, err) + require.True(t, mac.Equal(gotMac)) + require.Equal(t, testPreimage, gotPreimage) + require.Nil(t, gotDischarges) +} + +// TestSetHeaderRoundTrip tests that SetHeader and FromHeader are inverse +// operations for the Authorization header, preserving multiple discharges. +func TestSetHeaderRoundTrip(t *testing.T) { + t.Parallel() + + mac := newTestMacaroon(t) + + // Add two third-party caveats with separate discharges. + tpKey1 := []byte("third-party-key-one-32-bytes!!!!") + caveatID1 := []byte("tp-caveat-1") + require.NoError(t, mac.AddThirdPartyCaveat( + tpKey1, caveatID1, "https://tp1", + )) + discharge1, err := macaroon.New( + tpKey1, caveatID1, "https://tp1", macaroon.LatestVersion, + ) + require.NoError(t, err) + discharge1.Bind(mac.Signature()) + + tpKey2 := []byte("third-party-key-two-32-bytes!!!!") + caveatID2 := []byte("tp-caveat-2") + require.NoError(t, mac.AddThirdPartyCaveat( + tpKey2, caveatID2, "https://tp2", + )) + discharge2, err := macaroon.New( + tpKey2, caveatID2, "https://tp2", macaroon.LatestVersion, + ) + require.NoError(t, err) + discharge2.Bind(mac.Signature()) + + discharges := []*macaroon.Macaroon{discharge1, discharge2} + + header := http.Header{} + err = SetHeader(&header, mac, testPreimage, discharges) + require.NoError(t, err) + + gotMac, gotPreimage, gotDischarges, err := FromHeader(&header) + require.NoError(t, err) + require.True(t, mac.Equal(gotMac)) + require.Equal(t, testPreimage, gotPreimage) + require.Len(t, gotDischarges, 2) + require.True(t, discharge1.Equal(gotDischarges[0])) + require.True(t, discharge2.Equal(gotDischarges[1])) +} + +// TestSetHeaderBackwardsCompatible tests that a header set without discharges +// produces the same base64 blob as marshaling just the root macaroon, ensuring +// backwards compatibility with clients that don't know about discharges. +func TestSetHeaderBackwardsCompatible(t *testing.T) { + t.Parallel() + + mac := newTestMacaroon(t) + + // Marshal the macaroon directly (old behavior). + macBytes, err := mac.MarshalBinary() + require.NoError(t, err) + expectedBase64 := base64.StdEncoding.EncodeToString(macBytes) + + // SetHeader with nil discharges should produce the same base64. + header := http.Header{} + err = SetHeader(&header, mac, testPreimage, nil) + require.NoError(t, err) + + // The L402 header (second value) should contain the expected base64. + authValues := header.Values(HeaderAuthorization) + require.Len(t, authValues, 2) + + // Check the L402 header (second one added). + expected := "L402 " + expectedBase64 + ":" + testPreimage.String() + require.Equal(t, expected, authValues[1]) +} diff --git a/l402/server_interceptor.go b/l402/server_interceptor.go index f2af76b..d8243b6 100644 --- a/l402/server_interceptor.go +++ b/l402/server_interceptor.go @@ -92,7 +92,7 @@ func tokenFromContext(ctx context.Context) (*TokenID, error) { } log.Debugf("Auth header present in request: %s", md.Get(HeaderAuthorization)) - macaroon, _, err := FromHeader(header) + macaroon, _, _, err := FromHeader(header) if err != nil { return nil, fmt.Errorf("auth header extraction failed: %v", err) } diff --git a/mint/mint.go b/mint/mint.go index b8fab8c..745a16c 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -297,6 +297,10 @@ type VerificationParams struct { // TargetService is the target service a user of an L402 is attempting // to access. TargetService string + + // Discharges is an optional set of discharge macaroons that should be + // provided when verifying a macaroon with third-party caveats. + Discharges []*macaroon.Macaroon } // VerifyL402 attempts to verify an L402 with the given parameters. @@ -321,7 +325,9 @@ func (m *Mint) VerifyL402(ctx context.Context, if err != nil { return err } - rawCaveats, err := params.Macaroon.VerifySignature(secret[:], nil) + rawCaveats, err := params.Macaroon.VerifySignature( + secret[:], params.Discharges, + ) if err != nil { return err } diff --git a/mint/mint_test.go b/mint/mint_test.go index 224691e..f9200e9 100644 --- a/mint/mint_test.go +++ b/mint/mint_test.go @@ -300,6 +300,62 @@ func TestMintL402IgnoresTransactionStoreErrors(t *testing.T) { require.True(t, txStore.called) } +// TestThirdPartyCaveatL402 ensures that a macaroon with a third-party caveat +// can be verified when the appropriate discharge macaroon is provided, and +// fails when it is not. +func TestThirdPartyCaveatL402(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + Now: time.Now, + }) + + // Mint an L402 for a test service. + mac, _, err := mint.MintL402(ctx, testService) + require.NoError(t, err) + + // Add a third-party caveat. The shared root key would normally be + // negotiated with the third party out of band. + thirdPartyKey := []byte("third-party-shared-secret-key!!!") + caveatID := []byte("third-party-caveat-id") + err = mac.AddThirdPartyCaveat(thirdPartyKey, caveatID, "https://thirdparty") + require.NoError(t, err) + + // Verification without a discharge should fail because the library + // cannot find a discharge for the third-party caveat. + params := VerificationParams{ + Macaroon: mac, + Preimage: testPreimage, + TargetService: testService.Name, + } + err = mint.VerifyL402(ctx, ¶ms) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot find discharge") + + // Create a discharge macaroon issued by the third party. + discharge, err := macaroon.New( + thirdPartyKey, caveatID, "https://thirdparty", + macaroon.LatestVersion, + ) + require.NoError(t, err) + + // Bind the discharge to the root macaroon before use. + discharge.Bind(mac.Signature()) + + // Verification with the correct discharge should succeed. + paramsWithDischarge := VerificationParams{ + Macaroon: mac, + Preimage: testPreimage, + TargetService: testService.Name, + Discharges: []*macaroon.Macaroon{discharge}, + } + require.NoError(t, mint.VerifyL402(ctx, ¶msWithDischarge)) +} + type mockTime struct { time time.Time } diff --git a/proxy/proxy.go b/proxy/proxy.go index 855fdc0..b462282 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -526,12 +526,14 @@ func (p *Proxy) director(req *http.Request) { // correct/default format so the backend knows what to do // with it. For MPP Payment credentials, the header is // already in the correct format and doesn't need rewriting. - mac, preimage, err := l402.FromHeader(&req.Header) + mac, preimage, discharges, err := l402.FromHeader(&req.Header) if err == nil { // It could be that there is no auth information because // none is needed for this particular request. So we // only continue if no error is set. - err := l402.SetHeader(&req.Header, mac, preimage) + err := l402.SetHeader( + &req.Header, mac, preimage, discharges, + ) if err != nil { log.Errorf("could not set header: %v", err) } diff --git a/proxy/ratelimiter.go b/proxy/ratelimiter.go index ad837f9..507820c 100644 --- a/proxy/ratelimiter.go +++ b/proxy/ratelimiter.go @@ -235,7 +235,7 @@ func ExtractRateLimitKey(r *http.Request, remoteIP net.IP, // Only use L402 token ID if the request has been authenticated. // This prevents DoS attacks where garbage L402 tokens flood the cache. if authenticated { - mac, _, err := l402.FromHeader(&r.Header) + mac, _, _, err := l402.FromHeader(&r.Header) if err == nil && mac != nil { identifier, err := l402.DecodeIdentifier( bytes.NewBuffer(mac.Id()),