-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
518 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,2 @@ | ||
# l402 | ||
L402 (Lightning HTTP 402) library implementation | ||
# L402 Core | ||
The L402 (Lightning HTTP 402) library implementation |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
package l402 | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
) | ||
|
||
type Rejection error | ||
|
||
type RecoverableRejection interface { | ||
SignalRecovery(http.Header) | ||
error | ||
} | ||
|
||
type authenticator struct { | ||
macaroonMinter MacaroonMinter | ||
errorHandler http.HandlerFunc | ||
} | ||
|
||
func Authenticator(minter MacaroonMinter, errorHandler http.HandlerFunc) authenticator { | ||
return authenticator{ | ||
macaroonMinter: minter, | ||
errorHandler: errorHandler, | ||
} | ||
} | ||
|
||
func (a authenticator) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
rejection := context.Cause(r.Context()) | ||
|
||
var recoverableRejection RecoverableRejection | ||
if errors.As(rejection, &recoverableRejection) { | ||
// Rejecting access to an API resourse triggers a re-authentication opportunity | ||
// The rejection way be reverted without the need for a new payment | ||
// For that, we use the response header to signal the client what action can be taken | ||
// Recovery can usually happen by removing some of the macaroon's caveats | ||
recoverableRejection.SignalRecovery(w.Header()) | ||
} else if rejection == nil { | ||
rejection = ErrAuthenticationRequired | ||
} | ||
|
||
macaroonBase64, invoice, err := a.macaroonMinter.MintWithInvoice(r) | ||
if err != nil { | ||
ctx, cancelCause := context.WithCancelCause(r.Context()) | ||
cancelCause(fmt.Errorf("%w: %w", ErrFailedMacaroonMinting, err)) | ||
a.errorHandler(w, r.WithContext(ctx)) | ||
return | ||
} | ||
|
||
// TODO: Maybe support BOLT 12/LNURL: L402 macaroon="%s", offer="%s" | ||
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`L402 macaroon="%s", invoice="%s"`, macaroonBase64, invoice)) | ||
http.Error(w, rejection.Error(), http.StatusPaymentRequired) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
package l402 | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"net/http" | ||
) | ||
|
||
var ( | ||
ErrInvalidMacaroon = errors.New("invalid macaroon") | ||
ErrInvalidPreimage = errors.New("invalid preimage") | ||
ErrFailedInvoiceRequest = errors.New("failed invoice request") | ||
ErrFailedMacaroonMinting = errors.New("failed macaroon minting") | ||
ErrAuthenticationRequired = errors.New("authentication required") | ||
ErrUnknownVersion = errors.New("unknown L402 version") | ||
) | ||
|
||
func DefaultErrorHandler(w http.ResponseWriter, r *http.Request) { | ||
err := context.Cause(r.Context()) | ||
switch { | ||
case errors.Is(err, ErrInvalidMacaroon), errors.Is(err, ErrInvalidPreimage): | ||
http.Error(w, err.Error(), http.StatusBadRequest) | ||
default: | ||
http.Error(w, err.Error(), http.StatusInternalServerError) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
module github.com/gofeuer/l402 | ||
|
||
go 1.22 | ||
|
||
require gopkg.in/macaroon.v2 v2.1.0 | ||
|
||
require golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb // indirect |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
github.com/frankban/quicktest v1.0.0 h1:QgmxFbprE29UG4oL88tGiiL/7VuiBl5xCcz+wJcJhc0= | ||
github.com/frankban/quicktest v1.0.0/go.mod h1:R98jIehRai+d1/3Hv2//jOVCTJhW1VBavT6B6CuGq2k= | ||
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= | ||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= | ||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= | ||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | ||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | ||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | ||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | ||
golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb h1:Ah9YqXLj6fEgeKqcmBuLCbAsrF3ScD7dJ/bYM0C6tXI= | ||
golang.org/x/crypto v0.0.0-20180723164146-c126467f60eb/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= | ||
gopkg.in/macaroon.v2 v2.1.0 h1:HZcsjBCzq9t0eBPMKqTN/uSN6JOm78ZJ2INbqcBQOUI= | ||
gopkg.in/macaroon.v2 v2.1.0/go.mod h1:OUb+TQP/OP0WOerC2Jp/3CwhIKyIa9kQjuc7H24e6/o= |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package l402 | ||
|
||
import ( | ||
"crypto/sha256" | ||
"encoding/base64" | ||
"encoding/binary" | ||
"fmt" | ||
"reflect" | ||
|
||
macaroon "gopkg.in/macaroon.v2" | ||
) | ||
|
||
const BlockSize = sha256.Size | ||
|
||
type ( | ||
ByteBlock [BlockSize]byte | ||
Hash ByteBlock | ||
ID ByteBlock | ||
) | ||
|
||
type Identifier struct { | ||
Version uint16 | ||
PaymentHash Hash | ||
Id ID | ||
} | ||
|
||
func UnmarshalMacaroon(macaroonBase64 string) (macaroon.Macaroon, Identifier, error) { | ||
macaroonBytes, err := base64.StdEncoding.DecodeString(macaroonBase64) | ||
if err != nil { | ||
return macaroon.Macaroon{}, Identifier{}, err | ||
} | ||
|
||
macaroon := macaroon.Macaroon{} | ||
if err := macaroon.UnmarshalBinary(macaroonBytes); err != nil { | ||
return macaroon, Identifier{}, err | ||
} | ||
|
||
identifier, err := UnmarshalIdentifier(macaroon.Id()) | ||
|
||
return macaroon, identifier, err | ||
} | ||
|
||
func MarshalMacaroon(macaroon macaroon.Macaroon) (string, error) { | ||
if encodedMacaroon, err := macaroon.MarshalBinary(); err != nil { | ||
return "", err | ||
} else { | ||
return base64.StdEncoding.EncodeToString(encodedMacaroon), nil | ||
} | ||
} | ||
|
||
var byteOrder = binary.BigEndian | ||
|
||
var ( | ||
macaroonIdSize = reflect.TypeFor[Identifier]().Size() | ||
versionOffet = reflect.TypeFor[uint16]().Size() | ||
paymentHashOffset = reflect.TypeFor[Hash]().Size() | ||
) | ||
|
||
func MarchalIdentifier(identifier Identifier) ([]byte, error) { | ||
if identifier.Version != 0 { | ||
return nil, fmt.Errorf("%w: %v", ErrUnknownVersion, identifier.Version) | ||
} | ||
|
||
macaroonID := make([]byte, macaroonIdSize) | ||
|
||
offset := versionOffet // Skip the version location, it's already initialized as zero | ||
copy(macaroonID[offset:], identifier.PaymentHash[:]) | ||
|
||
offset += paymentHashOffset | ||
copy(macaroonID[offset:], identifier.Id[:]) | ||
|
||
return macaroonID, nil | ||
} | ||
|
||
func UnmarshalIdentifier(identifierBytes []byte) (Identifier, error) { | ||
if version := byteOrder.Uint16(identifierBytes); version != 0 { | ||
return Identifier{}, fmt.Errorf("%w: %v", ErrUnknownVersion, version) | ||
} | ||
|
||
var identifier Identifier | ||
|
||
offset := versionOffet // Skip the version, we alredy know it's zero | ||
copy(identifier.PaymentHash[:], identifierBytes[offset:]) | ||
|
||
offset += paymentHashOffset | ||
copy(identifier.Id[:], identifierBytes[offset:]) | ||
|
||
return identifier, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
package l402 | ||
|
||
import ( | ||
"bytes" | ||
"errors" | ||
"testing" | ||
) | ||
|
||
func TestMarchalIdentifier(t *testing.T) { | ||
tests := map[string]struct { | ||
version uint16 | ||
paymentHash Hash | ||
id ID | ||
expectedMacaroonId []byte | ||
expectedErr error | ||
}{ | ||
"invalid version": { | ||
version: 1, | ||
expectedErr: ErrUnknownVersion, | ||
}, | ||
"success": { | ||
paymentHash: [32]byte{ | ||
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, | ||
}, | ||
id: [32]byte{ | ||
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, | ||
}, | ||
expectedMacaroonId: []byte{ | ||
0, 0, // Version | ||
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // Payment Hash | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, | ||
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // Id | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, | ||
}, | ||
}, | ||
} | ||
|
||
for name, test := range tests { | ||
t.Run(name, func(t *testing.T) { | ||
identifier := Identifier{ | ||
Version: test.version, | ||
PaymentHash: test.paymentHash, | ||
Id: test.id, | ||
} | ||
|
||
macaroonID, err := MarchalIdentifier(identifier) | ||
|
||
if !errors.Is(err, test.expectedErr) { | ||
t.Errorf("test failed: expected err: %v but got %v", test.expectedErr, err) | ||
} | ||
|
||
if !bytes.Equal(macaroonID, test.expectedMacaroonId) { | ||
t.Errorf("test failed: expected macaroonID: %s but got %s", test.expectedMacaroonId, macaroonID) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestUnmarshalIdentifier(t *testing.T) { | ||
tests := map[string]struct { | ||
macaroonID []byte | ||
expectedPaymentHash Hash | ||
expectedId ID | ||
expectedErr error | ||
}{ | ||
"invalid version": { | ||
macaroonID: []byte{0, 2}, | ||
expectedErr: ErrUnknownVersion, | ||
}, | ||
"success": { | ||
macaroonID: []byte{ | ||
0, 0, // Version | ||
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // Payment Hash | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, | ||
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // Id | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, | ||
}, | ||
expectedPaymentHash: [32]byte{ | ||
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, | ||
}, | ||
expectedId: [32]byte{ | ||
3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | ||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, | ||
}, | ||
}, | ||
} | ||
|
||
for name, test := range tests { | ||
t.Run(name, func(t *testing.T) { | ||
identifier, err := UnmarshalIdentifier(test.macaroonID) | ||
|
||
if !errors.Is(err, test.expectedErr) { | ||
t.Errorf("test failed: expected err: %v but got %v", test.expectedErr, err) | ||
} | ||
|
||
if identifier.PaymentHash != test.expectedPaymentHash { | ||
t.Errorf("test failed: expected PaymentHash: %s but got %s", test.expectedPaymentHash, identifier.PaymentHash) | ||
} | ||
|
||
if identifier.Id != test.expectedId { | ||
t.Errorf("test failed: expected ID: %s but got %s", test.expectedId, identifier.Id) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
package l402 | ||
|
||
import ( | ||
"context" | ||
"crypto/sha256" | ||
"encoding/hex" | ||
"fmt" | ||
"net/http" | ||
"regexp" | ||
) | ||
|
||
type ContextKey string | ||
|
||
const KeyMacaroon ContextKey = "proxy_macaroon" | ||
|
||
func (p proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
macaroonBase64, preimageHex, found := getL402AuthorizationHeader(r) | ||
if !found { | ||
ctx, cancelCause := context.WithCancelCause(r.Context()) | ||
cancelCause(ErrAuthenticationRequired) | ||
p.authenticator.ServeHTTP(w, r.WithContext(ctx)) | ||
return | ||
} | ||
|
||
macaroon, identifier, err := UnmarshalMacaroon(macaroonBase64) | ||
if err != nil { | ||
ctx, cancelCause := context.WithCancelCause(r.Context()) | ||
cancelCause(fmt.Errorf("%w: %w", ErrInvalidMacaroon, err)) | ||
p.errorHandler.ServeHTTP(w, r.WithContext(ctx)) | ||
return | ||
} | ||
|
||
ctx := context.WithValue(r.Context(), KeyMacaroon, macaroon) | ||
|
||
if valid := validatePreimage(preimageHex, identifier.PaymentHash); !valid { | ||
ctx, cancelCause := context.WithCancelCause(ctx) | ||
cancelCause(ErrInvalidPreimage) | ||
p.errorHandler.ServeHTTP(w, r.WithContext(ctx)) | ||
return | ||
} | ||
|
||
// Check if macarron is singed by a valid key and that it grants access to the requested resource | ||
if rejection := p.accessAuthority.ApproveAccess(r, macaroon, identifier); rejection != nil { | ||
// The presented macaroon might not have been singed properlly or was revoked | ||
// Or the presented macaroon is valid but doesn't grant access to this resourse | ||
// So we give the client the option to re-authenticate with a proper macaroon | ||
ctx, cancelCause := context.WithCancelCause(ctx) | ||
cancelCause(rejection) | ||
p.authenticator.ServeHTTP(w, r.WithContext(ctx)) | ||
return | ||
} | ||
|
||
// At this point the request is valid, so we proxy the API call | ||
p.apiHandler.ServeHTTP(w, r.WithContext(ctx)) | ||
} | ||
|
||
const hexBlockSize = BlockSize * 2 | ||
|
||
var authorizationMatcher = regexp.MustCompile(fmt.Sprintf("L402 (.*?):([a-f0-9]{%d})", hexBlockSize)) | ||
|
||
func getL402AuthorizationHeader(r *http.Request) (string, string, bool) { | ||
for _, v := range r.Header.Values("Authorization") { | ||
if matches := authorizationMatcher.FindStringSubmatch(v); len(matches) == 3 { | ||
return matches[1], matches[2], true | ||
} | ||
} | ||
return "", "", false | ||
} | ||
|
||
func validatePreimage(preimageHex string, paymentHash Hash) bool { | ||
var preimage Hash | ||
hex.Decode(preimage[:], []byte(preimageHex)) | ||
preimage = sha256.Sum256(preimage[:]) | ||
return preimage == paymentHash | ||
} |
Oops, something went wrong.