diff --git a/.gitignore b/.gitignore index 51abb5c..f2b8ad6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ /aperture cmd/aperture/aperture + +# misc +.vscode \ No newline at end of file diff --git a/aperture.go b/aperture.go index 08fec9f..a57b458 100644 --- a/aperture.go +++ b/aperture.go @@ -662,6 +662,7 @@ func createProxy(cfg *Config, challenger *LndChallenger, Challenger: challenger, Secrets: newSecretStore(etcdClient), ServiceLimiter: newStaticServiceLimiter(cfg.Services), + Now: time.Now, }) authenticator := auth.NewLsatAuthenticator(minter, challenger) diff --git a/lsat/satisfier.go b/lsat/satisfier.go index 5f7f7b5..2dbbe61 100644 --- a/lsat/satisfier.go +++ b/lsat/satisfier.go @@ -2,7 +2,9 @@ package lsat import ( "fmt" + "strconv" "strings" + "time" ) // Satisfier provides a generic interface to satisfy a caveat based on its @@ -79,7 +81,9 @@ func NewServicesSatisfier(targetService string) Satisfier { // NewCapabilitiesSatisfier implements a satisfier to determine whether the // target capability for a service is authorized for a given LSAT. -func NewCapabilitiesSatisfier(service string, targetCapability string) Satisfier { +func NewCapabilitiesSatisfier(service string, + targetCapability string) Satisfier { + return Satisfier{ Condition: service + CondCapabilitiesSuffix, SatisfyPrevious: func(prev, cur Caveat) error { @@ -115,3 +119,62 @@ func NewCapabilitiesSatisfier(service string, targetCapability string) Satisfier }, } } + +// NewTimeoutSatisfier checks if an LSAT is expired or not. The Satisfier takes +// a service name to set as the condition prefix and currentTimestamp to +// compare against the expiration(s) in the caveats. The expiration time is +// retrieved from the caveat values themselves. The satisfier will also make +// sure that each subsequent caveat of the same condition only has increasingly +// strict expirations. +func NewTimeoutSatisfier(service string, now func() time.Time) Satisfier { + return Satisfier{ + Condition: service + CondTimeoutSuffix, + SatisfyPrevious: func(prev, cur Caveat) error { + prevValue, err := strconv.ParseInt(prev.Value, 10, 64) + if err != nil { + return fmt.Errorf("error parsing previous "+ + "caveat value: %w", err) + } + + currValue, err := strconv.ParseInt(cur.Value, 10, 64) + if err != nil { + return fmt.Errorf("error parsing caveat "+ + "value: %w", err) + } + + prevTime := time.Unix(prevValue, 0) + currTime := time.Unix(currValue, 0) + + // Satisfier should fail if a previous timestamp in the + // list is earlier than ones after it b/c that means + // they are getting more permissive. + if prevTime.Before(currTime) { + return fmt.Errorf("%s caveat violates "+ + "increasing restrictiveness", + service+CondTimeoutSuffix) + } + + return nil + }, + SatisfyFinal: func(c Caveat) error { + expirationTimestamp, err := strconv.ParseInt( + c.Value, 10, 64, + ) + if err != nil { + return fmt.Errorf("caveat value not a valid "+ + "integer: %v", err) + } + + expirationTime := time.Unix(expirationTimestamp, 0) + + // Make sure that the final relevant caveat is not + // passed the current date/time. + if now().Before(expirationTime) { + return nil + } + + return fmt.Errorf("not authorized to access " + + "service. LSAT has expired") + }, + } +} diff --git a/lsat/satisfier_test.go b/lsat/satisfier_test.go new file mode 100644 index 0000000..a50b02e --- /dev/null +++ b/lsat/satisfier_test.go @@ -0,0 +1,87 @@ +package lsat + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestTimeoutSatisfier tests that the Timeout Satisfier implementation behaves +// as expected and correctly accepts or rejects calls based on if the +// timeout has been reached or not. +func TestTimeoutSatisfier(t *testing.T) { + t.Parallel() + + now := int64(0) + + var tests = []struct { + name string + timeouts []int64 + expectFinalErr bool + expectPrevErr bool + }{ + { + name: "current time is before expiration", + timeouts: []int64{now + 1000}, + }, + { + name: "time passed is greater than " + + "expiration", + timeouts: []int64{now - 1000}, + expectFinalErr: true, + }, + { + name: "successive caveats are increasingly " + + "restrictive and not yet expired", + timeouts: []int64{now + 1000, now + 500}, + }, + { + name: "latter caveat is less restrictive " + + "then previous", + timeouts: []int64{now + 500, now + 1000}, + expectPrevErr: true, + }, + } + + var ( + service = "restricted" + condition = service + CondTimeoutSuffix + satisfier = NewTimeoutSatisfier(service, func() time.Time { + return time.Unix(now, 0) + }) + ) + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + var prev *Caveat + for _, timeout := range test.timeouts { + caveat := NewCaveat( + condition, fmt.Sprintf("%d", timeout), + ) + + if prev != nil { + err := satisfier.SatisfyPrevious( + *prev, caveat, + ) + if test.expectPrevErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } + + err := satisfier.SatisfyFinal(caveat) + if test.expectFinalErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + prev = &caveat + } + }) + } +} diff --git a/lsat/service.go b/lsat/service.go index ff5e8d1..2484d20 100644 --- a/lsat/service.go +++ b/lsat/service.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" ) const ( @@ -15,6 +16,10 @@ const ( // capabilities caveat. For example, the condition of a capabilities // caveat for a service named `loop` would be `loop_capabilities`. CondCapabilitiesSuffix = "_capabilities" + + // CondTimeoutSuffix is the condition suffix used for a service's + // timeout caveat. + CondTimeoutSuffix = "_valid_until" ) var ( @@ -129,3 +134,19 @@ func NewCapabilitiesCaveat(serviceName string, capabilities string) Caveat { Value: capabilities, } } + +// NewTimeoutCaveat creates a new caveat that will result in a macaroon being +// valid for numSeconds after the current time. +func NewTimeoutCaveat(serviceName string, numSeconds int64, + now func() time.Time) Caveat { + + var ( + macaroonTimeout = time.Duration(numSeconds) * time.Second + requestTimeout = now().Add(macaroonTimeout) + ) + + return Caveat{ + Condition: serviceName + CondTimeoutSuffix, + Value: strconv.FormatInt(requestTimeout.Unix(), 10), + } +} diff --git a/mint/mint.go b/mint/mint.go index b2c905d..21b5dae 100644 --- a/mint/mint.go +++ b/mint/mint.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "errors" "fmt" + "time" "github.com/lightninglabs/aperture/lsat" "github.com/lightningnetwork/lnd/lntypes" @@ -60,6 +61,10 @@ type ServiceLimiter interface { // enforces additional constraints on a particular service/service // capability. ServiceConstraints(context.Context, ...lsat.Service) ([]lsat.Caveat, error) + + // ServiceTimeouts returns the timeout caveat for each service. This + // will determine if and when service access can expire. + ServiceTimeouts(context.Context, ...lsat.Service) ([]lsat.Caveat, error) } // Config packages all of the required dependencies to instantiate a new LSAT @@ -76,6 +81,9 @@ type Config struct { // ServiceLimiter provides us with how we should limit a new LSAT based // on its target services. ServiceLimiter ServiceLimiter + + // Now returns the current time. + Now func() time.Time } // Mint is an entity that is able to mint and verify LSATs for a set of @@ -210,10 +218,15 @@ func (m *Mint) caveatsForServices(ctx context.Context, if err != nil { return nil, err } + timeouts, err := m.cfg.ServiceLimiter.ServiceTimeouts(ctx, services...) + if err != nil { + return nil, err + } caveats := []lsat.Caveat{servicesCaveat} caveats = append(caveats, capabilities...) caveats = append(caveats, constraints...) + caveats = append(caveats, timeouts...) return caveats, nil } @@ -269,6 +282,8 @@ func (m *Mint) VerifyLSAT(ctx context.Context, params *VerificationParams) error caveats = append(caveats, caveat) } return lsat.VerifyCaveats( - caveats, lsat.NewServicesSatisfier(params.TargetService), + caveats, + lsat.NewServicesSatisfier(params.TargetService), + lsat.NewTimeoutSatisfier(params.TargetService, m.cfg.Now), ) } diff --git a/mint/mint_test.go b/mint/mint_test.go index c000b57..48ff57f 100644 --- a/mint/mint_test.go +++ b/mint/mint_test.go @@ -5,8 +5,10 @@ import ( "crypto/sha256" "strings" "testing" + "time" "github.com/lightninglabs/aperture/lsat" + "github.com/stretchr/testify/require" "gopkg.in/macaroon.v2" ) @@ -27,6 +29,7 @@ func TestBasicLSAT(t *testing.T) { Secrets: newMockSecretStore(), Challenger: newMockChallenger(), ServiceLimiter: newMockServiceLimiter(), + Now: time.Now, }) // Mint a basic LSAT which is only able to access the given service. @@ -46,7 +49,7 @@ func TestBasicLSAT(t *testing.T) { // It should not be able to access an unknown service. unknownParams := params - unknownParams.TargetService = "uknown" + unknownParams.TargetService = "unknown" err = mint.VerifyLSAT(ctx, &unknownParams) if !strings.Contains(err.Error(), "not authorized") { t.Fatal("expected LSAT to not be authorized") @@ -63,6 +66,7 @@ func TestAdminLSAT(t *testing.T) { Secrets: newMockSecretStore(), Challenger: newMockChallenger(), ServiceLimiter: newMockServiceLimiter(), + Now: time.Now, }) // Mint an admin LSAT by not including any services. @@ -92,6 +96,7 @@ func TestRevokedLSAT(t *testing.T) { Secrets: newMockSecretStore(), Challenger: newMockChallenger(), ServiceLimiter: newMockServiceLimiter(), + Now: time.Now, }) // Mint an LSAT and verify it. @@ -128,6 +133,7 @@ func TestTamperedLSAT(t *testing.T) { Secrets: newMockSecretStore(), Challenger: newMockChallenger(), ServiceLimiter: newMockServiceLimiter(), + Now: time.Now, }) // Mint a new LSAT and verify it is valid. @@ -175,6 +181,7 @@ func TestDemotedServicesLSAT(t *testing.T) { Secrets: newMockSecretStore(), Challenger: newMockChallenger(), ServiceLimiter: newMockServiceLimiter(), + Now: time.Now, }) unauthorizedService := testService @@ -225,3 +232,66 @@ func TestDemotedServicesLSAT(t *testing.T) { t.Fatal("expected macaroon to be invalid") } } + +// TestExpiredServicesLSAT asserts the behavior of the Timeout caveat. +func TestExpiredServicesLSAT(t *testing.T) { + t.Parallel() + + initialTime := int64(1000) + mockTime := newMockTime(initialTime) + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + Now: mockTime.now, + }) + + // Mint a new lsat for accessing a test service. + mac, _, err := mint.MintLSAT(ctx, testService) + require.NoError(t, err) + + authorizedParams := VerificationParams{ + Macaroon: mac, + Preimage: testPreimage, + TargetService: testService.Name, + } + + // It should be able to access the service if no timeout caveat added. + require.NoError(t, mint.VerifyLSAT(ctx, &authorizedParams)) + + // Add a timeout caveat that expires in the future. + timeout := lsat.NewTimeoutCaveat(testService.Name, 1000, mockTime.now) + require.NoError(t, lsat.AddFirstPartyCaveats(mac, timeout)) + + // Make sure that the LSAT is still valid after timeout is added since + // the timeout has not yet been reached. + require.NoError(t, mint.VerifyLSAT(ctx, &authorizedParams)) + + // Force time to pass such that the LSAT should no longer be valid. + mockTime.setTime(initialTime + 1001) + + // Assert that the LSAT is no longer valid due to the timeout being + // reached. + err = mint.VerifyLSAT(ctx, &authorizedParams) + require.Contains(t, err.Error(), "not authorized") +} + +type mockTime struct { + time time.Time +} + +func newMockTime(initialTime int64) *mockTime { + return &mockTime{ + time: time.Unix(initialTime, 0), + } +} + +func (mt *mockTime) now() time.Time { + return mt.time +} + +func (mt *mockTime) setTime(timestamp int64) { + mt.time = time.Unix(timestamp, 0) +} diff --git a/mint/mock_test.go b/mint/mock_test.go index e14a700..6156540 100644 --- a/mint/mock_test.go +++ b/mint/mock_test.go @@ -73,6 +73,7 @@ func newMockSecretStore() *mockSecretStore { type mockServiceLimiter struct { capabilities map[lsat.Service]lsat.Caveat constraints map[lsat.Service][]lsat.Caveat + timeouts map[lsat.Service]lsat.Caveat } var _ ServiceLimiter = (*mockServiceLimiter)(nil) @@ -81,6 +82,7 @@ func newMockServiceLimiter() *mockServiceLimiter { return &mockServiceLimiter{ capabilities: make(map[lsat.Service]lsat.Caveat), constraints: make(map[lsat.Service][]lsat.Caveat), + timeouts: make(map[lsat.Service]lsat.Caveat), } } @@ -111,3 +113,17 @@ func (l *mockServiceLimiter) ServiceConstraints(ctx context.Context, } return res, nil } + +func (l *mockServiceLimiter) ServiceTimeouts(ctx context.Context, + services ...lsat.Service) ([]lsat.Caveat, error) { + + res := make([]lsat.Caveat, 0, len(services)) + for _, service := range services { + timeouts, ok := l.timeouts[service] + if !ok { + continue + } + res = append(res, timeouts) + } + return res, nil +} diff --git a/proxy/service.go b/proxy/service.go index 11651b6..e1a0634 100644 --- a/proxy/service.go +++ b/proxy/service.go @@ -72,6 +72,12 @@ type Service struct { // the file is sent encoded as base64. Headers map[string]string `long:"headers" description:"Header fields to always pass to the service"` + // Timeout is an optional value that indicates in how many seconds the + // service's caveat should time out relative to the time of creation. So + // if a value of 100 is set, then the timeout will be 100 seconds + // after creation of the LSAT. + Timeout int64 `long:"timeout" description:"An integer value that indicates the number of seconds until the service access expires"` + // Capabilities is the list of capabilities authorized for the service // at the base tier. Capabilities string `long:"capabilities" description:"A comma-separated list of the service capabilities authorized for the base tier"` diff --git a/sample-conf.yaml b/sample-conf.yaml index 5dd38de..224a7fa 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -84,7 +84,13 @@ services: # The set of constraints that are applied to tokens of the service at the # base tier. constraints: - "valid_until": "2020-01-01" + # This is just an example of how aperture could be extended + # but would not have any effect without additional support added. + "valid_until": 1682483169 + + # a caveat will be added that expires the LSAT after this many seconds, + # 31557600 = 1 year. + timeout: 31557600 # The LSAT value in satoshis for the service. It is ignored if # dynamicprice.enabled is set to true. diff --git a/services.go b/services.go index 0f5a51d..0cbb149 100644 --- a/services.go +++ b/services.go @@ -2,6 +2,7 @@ package aperture import ( "context" + "time" "github.com/lightninglabs/aperture/lsat" "github.com/lightninglabs/aperture/mint" @@ -14,6 +15,7 @@ import ( type staticServiceLimiter struct { capabilities map[lsat.Service]lsat.Caveat constraints map[lsat.Service][]lsat.Caveat + timeouts map[lsat.Service]lsat.Caveat } // A compile-time constraint to ensure staticServiceLimiter implements @@ -22,9 +24,12 @@ var _ mint.ServiceLimiter = (*staticServiceLimiter)(nil) // newStaticServiceLimiter instantiates a new static service limiter backed by // the given restrictions. -func newStaticServiceLimiter(proxyServices []*proxy.Service) *staticServiceLimiter { +func newStaticServiceLimiter( + proxyServices []*proxy.Service) *staticServiceLimiter { + capabilities := make(map[lsat.Service]lsat.Caveat) constraints := make(map[lsat.Service][]lsat.Caveat) + timeouts := make(map[lsat.Service]lsat.Caveat) for _, proxyService := range proxyServices { s := lsat.Service{ @@ -32,6 +37,15 @@ func newStaticServiceLimiter(proxyServices []*proxy.Service) *staticServiceLimit Tier: lsat.BaseTier, Price: proxyService.Price, } + + if proxyService.Timeout > 0 { + timeouts[s] = lsat.NewTimeoutCaveat( + proxyService.Name, + proxyService.Timeout, + time.Now, + ) + } + capabilities[s] = lsat.NewCapabilitiesCaveat( proxyService.Name, proxyService.Capabilities, ) @@ -44,6 +58,7 @@ func newStaticServiceLimiter(proxyServices []*proxy.Service) *staticServiceLimit return &staticServiceLimiter{ capabilities: capabilities, constraints: constraints, + timeouts: timeouts, } } @@ -80,3 +95,20 @@ func (l *staticServiceLimiter) ServiceConstraints(ctx context.Context, return res, nil } + +// ServiceTimeouts returns the timeout caveat for each service. This enforces +// an expiration time for service access if enabled. +func (l *staticServiceLimiter) ServiceTimeouts(ctx context.Context, + services ...lsat.Service) ([]lsat.Caveat, error) { + + res := make([]lsat.Caveat, 0, len(services)) + for _, service := range services { + timeout, ok := l.timeouts[service] + if !ok { + continue + } + res = append(res, timeout) + } + + return res, nil +}