Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
gofeuer committed Jul 25, 2024
1 parent 4f000c2 commit 799d95b
Show file tree
Hide file tree
Showing 10 changed files with 518 additions and 2 deletions.
4 changes: 2 additions & 2 deletions README.md
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
54 changes: 54 additions & 0 deletions authenticator.go
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)
}
26 changes: 26 additions & 0 deletions errors.go
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)
}
}
7 changes: 7 additions & 0 deletions go.mod
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
13 changes: 13 additions & 0 deletions go.sum
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=
89 changes: 89 additions & 0 deletions macaroon.go
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
}
108 changes: 108 additions & 0 deletions macaroon_test.go
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)
}
})
}
}
75 changes: 75 additions & 0 deletions middleware.go
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
}
Loading

0 comments on commit 799d95b

Please sign in to comment.