diff --git a/mint/mint.go b/mint/mint.go new file mode 100644 index 0000000..6089c06 --- /dev/null +++ b/mint/mint.go @@ -0,0 +1,256 @@ +package mint + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + + "github.com/lightninglabs/loop/lsat" + "github.com/lightningnetwork/lnd/lntypes" + "gopkg.in/macaroon.v2" +) + +var ( + // ErrSecretNotFound is an error returned when we attempt to retrieve a + // secret by its key but it is not found. + ErrSecretNotFound = errors.New("secret not found") +) + +// Challenger is an interface used to present requesters of LSATs with a +// challenge that must be satisfied before an LSAT can be validated. This +// challenge takes the form of a Lightning payment request. +type Challenger interface { + // NewChallenge returns a new challenge in the form of a Lightning + // payment request. The payment hash is also returned as a convenience + // to avoid having to decode the payment request in order to retrieve + // its payment hash. + NewChallenge() (string, lntypes.Hash, error) +} + +// SecretStore is the store responsible for storing LSAT secrets. These secrets +// are required for proper verification of each minted LSAT. +type SecretStore interface { + // NewSecret creates a new cryptographically random secret which is + // keyed by the given hash. + NewSecret(context.Context, [sha256.Size]byte) ([lsat.SecretSize]byte, error) + + // GetSecret returns the cryptographically random secret that + // corresponds to the given hash. If there is no secret, then + // ErrSecretNotFound is returned. + GetSecret(context.Context, [sha256.Size]byte) ([lsat.SecretSize]byte, error) + + // RevokeSecret removes the cryptographically random secret that + // corresponds to the given hash. This acts as a NOP if the secret does + // not exist. + RevokeSecret(context.Context, [sha256.Size]byte) error +} + +// ServiceLimiter abstracts the source of caveats that should be applied to an +// LSAT for a particular service. +type ServiceLimiter interface { + // ServiceCapabilities returns the capabilities caveats for each + // service. This determines which capabilities of each service can be + // accessed. + ServiceCapabilities(context.Context, ...lsat.Service) ([]lsat.Caveat, error) + + // ServiceConstraints returns the constraints for each service. This + // enforces additional constraints on a particular service/service + // capability. + ServiceConstraints(context.Context, ...lsat.Service) ([]lsat.Caveat, error) +} + +// Config packages all of the required dependencies to instantiate a new LSAT +// mint. +type Config struct { + // Secrets is our source for LSAT secrets which will be used for + // verification purposes. + Secrets SecretStore + + // Challenger is our source of new challenges to present requesters of + // an LSAT with. + Challenger Challenger + + // ServiceLimiter provides us with how we should limit a new LSAT based + // on its target services. + ServiceLimiter ServiceLimiter +} + +// Mint is an entity that is able to mint and verify LSATs for a set of +// services. +type Mint struct { + cfg Config +} + +// New creates a new LSAT mint backed by its given dependencies. +func New(cfg *Config) *Mint { + return &Mint{cfg: *cfg} +} + +// MintLSAT mints a new LSAT for the target services. +func (m *Mint) MintLSAT(ctx context.Context, + services ...lsat.Service) (*macaroon.Macaroon, string, error) { + + // We'll start by retrieving a new challenge in the form of a Lightning + // payment request to present the requester of the LSAT with. + paymentRequest, paymentHash, err := m.cfg.Challenger.NewChallenge() + if err != nil { + return nil, "", err + } + + // TODO(wilmer): remove invoice if any of the operations below fail? + + // We can then proceed to mint the LSAT with a unique identifier that is + // mapped to a unique secret. + id, err := createUniqueIdentifier(paymentHash) + if err != nil { + return nil, "", err + } + idHash := sha256.Sum256(id) + secret, err := m.cfg.Secrets.NewSecret(ctx, idHash) + if err != nil { + return nil, "", err + } + macaroon, err := macaroon.New( + secret[:], id, "lsat", macaroon.LatestVersion, + ) + if err != nil { + // Attempt to revoke the secret to save space. + _ = m.cfg.Secrets.RevokeSecret(ctx, idHash) + return nil, "", err + } + + // Include any restrictions that should be immediately applied to the + // LSAT. + var caveats []lsat.Caveat + if len(services) > 0 { + var err error + caveats, err = m.caveatsForServices(ctx, services...) + if err != nil { + // Attempt to revoke the secret to save space. + _ = m.cfg.Secrets.RevokeSecret(ctx, idHash) + return nil, "", err + } + } + if err := lsat.AddFirstPartyCaveats(macaroon, caveats...); err != nil { + // Attempt to revoke the secret to save space. + _ = m.cfg.Secrets.RevokeSecret(ctx, idHash) + return nil, "", err + } + + return macaroon, paymentRequest, nil +} + +// createUniqueIdentifier creates a new LSAT identifier bound to a payment hash +// and a randomly generated ID. +func createUniqueIdentifier(paymentHash lntypes.Hash) ([]byte, error) { + tokenID, err := generateTokenID() + if err != nil { + return nil, err + } + + id := &lsat.Identifier{ + Version: lsat.LatestVersion, + PaymentHash: paymentHash, + TokenID: tokenID, + } + + var buf bytes.Buffer + if err := lsat.EncodeIdentifier(&buf, id); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// generateTokenID generates a new random LSAT ID. +func generateTokenID() ([lsat.TokenIDSize]byte, error) { + var tokenID [lsat.TokenIDSize]byte + _, err := rand.Read(tokenID[:]) + return tokenID, err +} + +// caveatsForServices returns all of the caveats that should be applied to an +// LSAT for the target services. +func (m *Mint) caveatsForServices(ctx context.Context, + services ...lsat.Service) ([]lsat.Caveat, error) { + + servicesCaveat, err := lsat.NewServicesCaveat(services...) + if err != nil { + return nil, err + } + capabilities, err := m.cfg.ServiceLimiter.ServiceCapabilities( + ctx, services..., + ) + if err != nil { + return nil, err + } + constraints, err := m.cfg.ServiceLimiter.ServiceConstraints( + ctx, services..., + ) + if err != nil { + return nil, err + } + + caveats := []lsat.Caveat{servicesCaveat} + caveats = append(caveats, capabilities...) + caveats = append(caveats, constraints...) + return caveats, nil +} + +// VerificationParams holds all of the requirements to properly verify an LSAT. +type VerificationParams struct { + // Macaroon is the macaroon as part of the LSAT we'll attempt to verify. + Macaroon *macaroon.Macaroon + + // Preimage is the preimage that should correspond to the LSAT's payment + // hash. + Preimage lntypes.Preimage + + // TargetService is the target service a user of an LSAT is attempting + // to access. + TargetService string +} + +// VerifyLSAT attempts to verify an LSAT with the given parameters. +func (m *Mint) VerifyLSAT(ctx context.Context, params *VerificationParams) error { + // We'll first perform a quick check to determine if a valid preimage + // was provided. + id, err := lsat.DecodeIdentifier(bytes.NewReader(params.Macaroon.Id())) + if err != nil { + return err + } + if params.Preimage.Hash() != id.PaymentHash { + return fmt.Errorf("invalid preimage %v for %v", params.Preimage, + id.PaymentHash) + } + + // If there was, then we'll ensure the LSAT was minted by us. + secret, err := m.cfg.Secrets.GetSecret( + ctx, sha256.Sum256(params.Macaroon.Id()), + ) + if err != nil { + return err + } + rawCaveats, err := params.Macaroon.VerifySignature(secret[:], nil) + if err != nil { + return err + } + + // With the LSAT verified, we'll now inspect its caveats to ensure the + // target service is authorized. + var caveats []lsat.Caveat + for _, rawCaveat := range rawCaveats { + // LSATs can contain third-party caveats that we're not aware + // of, so just skip those. + caveat, err := lsat.DecodeCaveat(rawCaveat) + if err != nil { + continue + } + caveats = append(caveats, caveat) + } + return lsat.VerifyCaveats( + caveats, lsat.NewServicesSatisfier(params.TargetService), + ) +} diff --git a/mint/mint_test.go b/mint/mint_test.go new file mode 100644 index 0000000..dd0b9af --- /dev/null +++ b/mint/mint_test.go @@ -0,0 +1,227 @@ +package mint + +import ( + "context" + "crypto/sha256" + "strings" + "testing" + + "github.com/lightninglabs/loop/lsat" + "gopkg.in/macaroon.v2" +) + +var ( + testService = lsat.Service{ + Name: "lightning_loop", + Tier: lsat.BaseTier, + } +) + +// TestBasicLSAT ensures that an LSAT can only access the services it's +// authorized to. +func TestBasicLSAT(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + }) + + // Mint a basic LSAT which is only able to access the given service. + macaroon, _, err := mint.MintLSAT(ctx, testService) + if err != nil { + t.Fatalf("unable to mint LSAT: %v", err) + } + + params := VerificationParams{ + Macaroon: macaroon, + Preimage: testPreimage, + TargetService: testService.Name, + } + if err := mint.VerifyLSAT(ctx, ¶ms); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } + + // It should not be able to access an unknown service. + unknownParams := params + unknownParams.TargetService = "uknown" + err = mint.VerifyLSAT(ctx, &unknownParams) + if !strings.Contains(err.Error(), "not authorized") { + t.Fatal("expected LSAT to not be authorized") + } +} + +// TestAdminLSAT ensures that an admin LSAT (one without a services caveat) is +// authorized to access any service. +func TestAdminLSAT(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + }) + + // Mint an admin LSAT by not including any services. + macaroon, _, err := mint.MintLSAT(ctx) + if err != nil { + t.Fatalf("unable to mint LSAT: %v", err) + } + + // It should be able to access any service as it doesn't have a services + // caveat. + params := &VerificationParams{ + Macaroon: macaroon, + Preimage: testPreimage, + TargetService: testService.Name, + } + if err := mint.VerifyLSAT(ctx, params); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } +} + +// TestRevokedLSAT ensures that we can no longer verify a revoked LSAT. +func TestRevokedLSAT(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + }) + + // Mint an LSAT and verify it. + lsat, _, err := mint.MintLSAT(ctx) + if err != nil { + t.Fatalf("unable to mint LSAT: %v", err) + } + params := &VerificationParams{ + Macaroon: lsat, + Preimage: testPreimage, + TargetService: testService.Name, + } + if err := mint.VerifyLSAT(ctx, params); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } + + // Proceed to revoke it. We should no longer be able to verify it after. + idHash := sha256.Sum256(lsat.Id()) + if err := mint.cfg.Secrets.RevokeSecret(ctx, idHash); err != nil { + t.Fatalf("unable to revoke LSAT: %v", err) + } + if err := mint.VerifyLSAT(ctx, params); err != ErrSecretNotFound { + t.Fatalf("expected ErrSecretNotFound, got %v", err) + } +} + +// TestTamperedLSAT ensures that an LSAT that has been tampered with by +// modifying its signature results in its verification failing. +func TestTamperedLSAT(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + }) + + // Mint a new LSAT and verify it is valid. + mac, _, err := mint.MintLSAT(ctx, testService) + if err != nil { + t.Fatalf("unable to mint LSAT: %v", err) + } + params := VerificationParams{ + Macaroon: mac, + Preimage: testPreimage, + TargetService: testService.Name, + } + if err := mint.VerifyLSAT(ctx, ¶ms); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } + + // Create a tampered LSAT from the valid one. + macBytes, err := mac.MarshalBinary() + if err != nil { + t.Fatalf("unable to serialize macaroon: %v", err) + } + macBytes[len(macBytes)-1] = 0x00 + var tampered macaroon.Macaroon + if err := tampered.UnmarshalBinary(macBytes); err != nil { + t.Fatalf("unable to deserialize macaroon: %v", err) + } + + // Attempting to verify the tampered LSAT should fail. + tamperedParams := params + tamperedParams.Macaroon = &tampered + err = mint.VerifyLSAT(ctx, &tamperedParams) + if !strings.Contains(err.Error(), "signature mismatch") { + t.Fatal("expected tampered LSAT to be invalid") + } +} + +// TestDemotedServicesLSAT ensures that an LSAT which originally was authorized +// to access a service, but was then demoted to no longer be the case, is no +// longer authorized. +func TestDemotedServicesLSAT(t *testing.T) { + t.Parallel() + + ctx := context.Background() + mint := New(&Config{ + Secrets: newMockSecretStore(), + Challenger: newMockChallenger(), + ServiceLimiter: newMockServiceLimiter(), + }) + + unauthorizedService := testService + unauthorizedService.Name = "unauthorized" + + // Mint an LSAT that is able to access two services, one of which will + // be denied later on. + mac, _, err := mint.MintLSAT(ctx, testService, unauthorizedService) + if err != nil { + t.Fatalf("unable to mint LSAT: %v", err) + } + + // It should be able to access both services. + authorizedParams := VerificationParams{ + Macaroon: mac, + Preimage: testPreimage, + TargetService: testService.Name, + } + if err := mint.VerifyLSAT(ctx, &authorizedParams); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } + unauthorizedParams := VerificationParams{ + Macaroon: mac, + Preimage: testPreimage, + TargetService: unauthorizedService.Name, + } + if err := mint.VerifyLSAT(ctx, &unauthorizedParams); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } + + // Demote the second service by including an additional services caveat + // that only includes the first service. + services, err := lsat.NewServicesCaveat(testService) + if err != nil { + t.Fatalf("unable to create services caveat: %v", err) + } + err = lsat.AddFirstPartyCaveats(mac, services) + if err != nil { + t.Fatalf("unable to demote LSAT: %v", err) + } + + // It should now only be able to access the first, but not the second. + if err := mint.VerifyLSAT(ctx, &authorizedParams); err != nil { + t.Fatalf("unable to verify LSAT: %v", err) + } + err = mint.VerifyLSAT(ctx, &unauthorizedParams) + if !strings.Contains(err.Error(), "not authorized") { + t.Fatal("expected macaroon to be invalid") + } +} diff --git a/mint/mock_test.go b/mint/mock_test.go new file mode 100644 index 0000000..7dddc18 --- /dev/null +++ b/mint/mock_test.go @@ -0,0 +1,113 @@ +package mint + +import ( + "context" + "crypto/sha256" + "math/rand" + + "github.com/lightninglabs/loop/lsat" + "github.com/lightningnetwork/lnd/lntypes" +) + +var ( + testPreimage = lntypes.Preimage{ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, + 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + } + testHash = testPreimage.Hash() + testPayReq = "lnsb1..." +) + +type mockChallenger struct{} + +var _ Challenger = (*mockChallenger)(nil) + +func newMockChallenger() *mockChallenger { + return &mockChallenger{} +} + +func (d *mockChallenger) NewChallenge() (string, lntypes.Hash, error) { + return testPayReq, testHash, nil +} + +type mockSecretStore struct { + secrets map[[sha256.Size]byte][lsat.SecretSize]byte +} + +var _ SecretStore = (*mockSecretStore)(nil) + +func (s *mockSecretStore) NewSecret(ctx context.Context, + id [sha256.Size]byte) ([lsat.SecretSize]byte, error) { + + var secret [lsat.SecretSize]byte + if _, err := rand.Read(secret[:]); err != nil { + return secret, err + } + s.secrets[id] = secret + return secret, nil +} + +func (s *mockSecretStore) GetSecret(ctx context.Context, + id [sha256.Size]byte) ([lsat.SecretSize]byte, error) { + + secret, ok := s.secrets[id] + if !ok { + return secret, ErrSecretNotFound + } + return secret, nil +} + +func (s *mockSecretStore) RevokeSecret(ctx context.Context, + id [sha256.Size]byte) error { + + delete(s.secrets, id) + return nil +} + +func newMockSecretStore() *mockSecretStore { + return &mockSecretStore{ + secrets: make(map[[sha256.Size]byte][lsat.SecretSize]byte), + } +} + +type mockServiceLimiter struct { + capabilities map[lsat.Service]lsat.Caveat + constraints map[lsat.Service][]lsat.Caveat +} + +var _ ServiceLimiter = (*mockServiceLimiter)(nil) + +func newMockServiceLimiter() *mockServiceLimiter { + return &mockServiceLimiter{ + capabilities: make(map[lsat.Service]lsat.Caveat), + constraints: make(map[lsat.Service][]lsat.Caveat), + } +} + +func (l *mockServiceLimiter) ServiceCapabilities(ctx context.Context, + services ...lsat.Service) ([]lsat.Caveat, error) { + + var res []lsat.Caveat + for _, service := range services { + capabilities, ok := l.capabilities[service] + if !ok { + continue + } + res = append(res, capabilities) + } + return res, nil +} + +func (l *mockServiceLimiter) ServiceConstraints(ctx context.Context, + services ...lsat.Service) ([]lsat.Caveat, error) { + + var res []lsat.Caveat + for _, service := range services { + constraints, ok := l.constraints[service] + if !ok { + continue + } + res = append(res, constraints...) + } + return res, nil +}