From 1eb8ed3da5cf7fdc03cb4a7ac64d48044e1eaa67 Mon Sep 17 00:00:00 2001 From: Wilmer Paulino Date: Thu, 14 Nov 2019 18:16:48 -0800 Subject: [PATCH] lsat: introduce LSAT related utilities We introduce a new package: `lsat`, which aims to provide utilities that will serve useful in the context of LSAT creation and verification for LSAT-enabled services. --- lsat/caveat.go | 142 ++++++++++++++++++++++++++++ lsat/caveat_test.go | 202 ++++++++++++++++++++++++++++++++++++++++ lsat/identifier.go | 102 ++++++++++++++++++++ lsat/identifier_test.go | 70 ++++++++++++++ lsat/satisfier.go | 117 +++++++++++++++++++++++ lsat/service.go | 128 +++++++++++++++++++++++++ lsat/service_test.go | 83 +++++++++++++++++ 7 files changed, 844 insertions(+) create mode 100644 lsat/caveat.go create mode 100644 lsat/caveat_test.go create mode 100644 lsat/identifier.go create mode 100644 lsat/identifier_test.go create mode 100644 lsat/satisfier.go create mode 100644 lsat/service.go create mode 100644 lsat/service_test.go diff --git a/lsat/caveat.go b/lsat/caveat.go new file mode 100644 index 0000000..80aa6e0 --- /dev/null +++ b/lsat/caveat.go @@ -0,0 +1,142 @@ +package lsat + +import ( + "errors" + "fmt" + "strings" + + "gopkg.in/macaroon.v2" +) + +const ( + // PreimageKey is the key used for a payment preimage caveat. + PreimageKey = "preimage" +) + +var ( + // ErrInvalidCaveat is an error returned when we attempt to decode a + // caveat with an invalid format. + ErrInvalidCaveat = errors.New("caveat must be of the form " + + "\"condition=value\"") +) + +// Caveat is a predicate that can be applied to an LSAT in order to restrict its +// use in some form. Caveats are evaluated during LSAT verification after the +// LSAT's signature is verified. The predicate of each caveat must hold true in +// order to successfully validate an LSAT. +type Caveat struct { + // Condition serves as a way to identify a caveat and how to satisfy it. + Condition string + + // Value is what will be used to satisfy a caveat. This can be as + // flexible as needed, as long as it can be encoded into a string. + Value string +} + +// NewCaveat construct a new caveat with the given condition and value. +func NewCaveat(condition string, value string) Caveat { + return Caveat{Condition: condition, Value: value} +} + +// String returns a user-friendly view of a caveat. +func (c Caveat) String() string { + return EncodeCaveat(c) +} + +// EncodeCaveat encodes a caveat into its string representation. +func EncodeCaveat(c Caveat) string { + return fmt.Sprintf("%v=%v", c.Condition, c.Value) +} + +// DecodeCaveat decodes a caveat from its string representation. +func DecodeCaveat(s string) (Caveat, error) { + parts := strings.SplitN(s, "=", 2) + if len(parts) != 2 { + return Caveat{}, ErrInvalidCaveat + } + return Caveat{Condition: parts[0], Value: parts[1]}, nil +} + +// AddFirstPartyCaveats adds a set of caveats as first-party caveats to a +// macaroon. +func AddFirstPartyCaveats(m *macaroon.Macaroon, caveats ...Caveat) error { + for _, c := range caveats { + rawCaveat := []byte(EncodeCaveat(c)) + if err := m.AddFirstPartyCaveat(rawCaveat); err != nil { + return err + } + } + + return nil +} + +// HasCaveat checks whether the given macaroon has a caveat with the given +// condition, and if so, returns its value. If multiple caveats with the same +// condition exist, then the value of the last one is returned. +func HasCaveat(m *macaroon.Macaroon, cond string) (string, bool) { + var value *string + for _, rawCaveat := range m.Caveats() { + caveat, err := DecodeCaveat(string(rawCaveat.Id)) + if err != nil { + // Ignore any unknown caveats as we can't decode them. + continue + } + if caveat.Condition == cond { + value = &caveat.Value + } + } + + if value == nil { + return "", false + } + return *value, true +} + +// VerifyCaveats determines whether every relevant caveat of an LSAT holds true. +// A caveat is considered relevant if a satisfier is provided for it, which is +// what we'll use as their evaluation. +// +// NOTE: The caveats provided should be in the same order as in the LSAT to +// ensure the correctness of each satisfier's SatisfyPrevious. +func VerifyCaveats(caveats []Caveat, satisfiers ...Satisfier) error { + // Construct a set of our satisfiers to determine which caveats we know + // how to satisfy. + caveatSatisfiers := make(map[string]Satisfier, len(satisfiers)) + for _, satisfier := range satisfiers { + caveatSatisfiers[satisfier.Condition] = satisfier + } + relevantCaveats := make(map[string][]Caveat) + for _, caveat := range caveats { + if _, ok := caveatSatisfiers[caveat.Condition]; !ok { + continue + } + relevantCaveats[caveat.Condition] = append( + relevantCaveats[caveat.Condition], caveat, + ) + } + + for condition, caveats := range relevantCaveats { + satisfier := caveatSatisfiers[condition] + + // Since it's possible for a chain of caveat to exist for the + // same condition as a way to demote privileges, we'll ensure + // each one satisfies its previous. + for i, j := 0, 1; j < len(caveats); i, j = i+1, j+1 { + prevCaveat := caveats[i] + curCaveat := caveats[j] + err := satisfier.SatisfyPrevious(prevCaveat, curCaveat) + if err != nil { + return err + } + } + + // Once we verify the previous ones, if any, we can proceed to + // verify the final one, which is the decision maker. + err := satisfier.SatisfyFinal(caveats[len(caveats)-1]) + if err != nil { + return err + } + } + + return nil +} diff --git a/lsat/caveat_test.go b/lsat/caveat_test.go new file mode 100644 index 0000000..818a86c --- /dev/null +++ b/lsat/caveat_test.go @@ -0,0 +1,202 @@ +package lsat + +import ( + "errors" + "testing" + + "gopkg.in/macaroon.v2" +) + +var ( + testMacaroon, _ = macaroon.New(nil, nil, "", macaroon.LatestVersion) +) + +// TestCaveatSerialization ensures that we can properly encode/decode valid +// caveats and cannot do so for invalid ones. +func TestCaveatSerialization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + caveatStr string + err error + }{ + { + name: "valid caveat", + caveatStr: "expiration=1337", + err: nil, + }, + { + name: "valid caveat with separator in value", + caveatStr: "expiration=1337=", + err: nil, + }, + { + name: "invalid caveat", + caveatStr: "expiration:1337", + err: ErrInvalidCaveat, + }, + } + + for _, test := range tests { + test := test + success := t.Run(test.name, func(t *testing.T) { + caveat, err := DecodeCaveat(test.caveatStr) + if !errors.Is(err, test.err) { + t.Fatalf("expected err \"%v\", got \"%v\"", + test.err, err) + } + + if test.err != nil { + return + } + + caveatStr := EncodeCaveat(caveat) + if caveatStr != test.caveatStr { + t.Fatalf("expected encoded caveat \"%v\", "+ + "got \"%v\"", test.caveatStr, caveatStr) + } + }) + if !success { + return + } + } +} + +// TestHasCaveat ensures we can determine whether a macaroon contains a caveat +// with a specific condition. +func TestHasCaveat(t *testing.T) { + t.Parallel() + + const ( + cond = "cond" + value = "value" + ) + m := testMacaroon.Clone() + + // The macaroon doesn't have any caveats, so we shouldn't find any. + if _, ok := HasCaveat(m, cond); ok { + t.Fatal("found unexpected caveat with unknown condition") + } + + // Add two caveats, one in a valid LSAT format and another invalid. + // We'll test that we're still able to determine the macaroon contains + // the valid caveat even though there is one that is invalid. + invalidCaveat := []byte("invalid") + if err := m.AddFirstPartyCaveat(invalidCaveat); err != nil { + t.Fatalf("unable to add macaroon caveat: %v", err) + } + validCaveat1 := Caveat{Condition: cond, Value: value} + if err := AddFirstPartyCaveats(m, validCaveat1); err != nil { + t.Fatalf("unable to add macaroon caveat: %v", err) + } + + caveatValue, ok := HasCaveat(m, cond) + if !ok { + t.Fatal("expected macaroon to contain caveat") + } + if caveatValue != validCaveat1.Value { + t.Fatalf("expected caveat value \"%v\", got \"%v\"", + validCaveat1.Value, caveatValue) + } + + // If we add another caveat with the same condition, the value of the + // most recently added caveat should be returned instead. + validCaveat2 := validCaveat1 + validCaveat2.Value += value + if err := AddFirstPartyCaveats(m, validCaveat2); err != nil { + t.Fatalf("unable to add macaroon caveat: %v", err) + } + + caveatValue, ok = HasCaveat(m, cond) + if !ok { + t.Fatal("expected macaroon to contain caveat") + } + if caveatValue != validCaveat2.Value { + t.Fatalf("expected caveat value \"%v\", got \"%v\"", + validCaveat2.Value, caveatValue) + } +} + +// TestVerifyCaveats ensures caveat verification only holds true for known +// caveats. +func TestVerifyCaveats(t *testing.T) { + t.Parallel() + + caveat1 := Caveat{Condition: "1", Value: "test"} + caveat2 := Caveat{Condition: "2", Value: "test"} + satisfier := Satisfier{ + Condition: caveat1.Condition, + SatisfyPrevious: func(c Caveat, prev Caveat) error { + return nil + }, + SatisfyFinal: func(c Caveat) error { + return nil + }, + } + invalidSatisfyPrevious := func(c Caveat, prev Caveat) error { + return errors.New("no") + } + invalidSatisfyFinal := func(c Caveat) error { + return errors.New("no") + } + + tests := []struct { + name string + caveats []Caveat + satisfiers []Satisfier + shouldFail bool + }{ + { + name: "simple verification", + caveats: []Caveat{caveat1}, + satisfiers: []Satisfier{satisfier}, + shouldFail: false, + }, + { + name: "unknown caveat", + caveats: []Caveat{caveat1, caveat2}, + satisfiers: []Satisfier{satisfier}, + shouldFail: false, + }, + { + name: "one invalid", + caveats: []Caveat{caveat1, caveat2}, + satisfiers: []Satisfier{ + satisfier, + { + Condition: caveat2.Condition, + SatisfyFinal: invalidSatisfyFinal, + }, + }, + shouldFail: true, + }, + { + name: "prev invalid", + caveats: []Caveat{caveat1, caveat1}, + satisfiers: []Satisfier{ + { + Condition: caveat1.Condition, + SatisfyPrevious: invalidSatisfyPrevious, + }, + }, + shouldFail: true, + }, + } + + for _, test := range tests { + test := test + success := t.Run(test.name, func(t *testing.T) { + err := VerifyCaveats(test.caveats, test.satisfiers...) + if test.shouldFail && err == nil { + t.Fatal("expected caveat verification to fail") + } + if !test.shouldFail && err != nil { + t.Fatal("unexpected caveat verification failure") + } + }) + if !success { + return + } + } +} diff --git a/lsat/identifier.go b/lsat/identifier.go new file mode 100644 index 0000000..99b0654 --- /dev/null +++ b/lsat/identifier.go @@ -0,0 +1,102 @@ +package lsat + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/lightningnetwork/lnd/lntypes" +) + +const ( + // LatestVersion is the latest version used for minting new LSATs. + LatestVersion = 0 + + // SecretSize is the size in bytes of a LSAT's secret, also known as + // the root key of the macaroon. + SecretSize = 32 + + // TokenIDSize is the size in bytes of an LSAT's ID encoded in its + // macaroon identifier. + TokenIDSize = 32 +) + +var ( + // byteOrder is the byte order used to encode/decode a macaroon's raw + // identifier. + byteOrder = binary.BigEndian + + // ErrUnknownVersion is an error returned when attempting to decode an + // LSAT identifier with an unknown version. + ErrUnknownVersion = errors.New("unknown LSAT version") +) + +// Identifier contains the static identifying details of an LSAT. This is +// intended to be used as the identifier of the macaroon within an LSAT. +type Identifier struct { + // Version is the version of an LSAT. Having a version allows us to + // introduce new fields to the identifier in a backwards-compatible + // manner. + Version uint16 + + // PaymentHash is the payment hash linked to an LSAT. Verification of + // an LSAT depends on a valid payment, which is enforced by ensuring a + // preimage is provided that hashes to our payment hash. + PaymentHash lntypes.Hash + + // TokenID is the unique identifier of an LSAT. + TokenID [TokenIDSize]byte +} + +// EncodeIdentifier encodes an LSAT's identifier according to its version. +func EncodeIdentifier(w io.Writer, id *Identifier) error { + if err := binary.Write(w, byteOrder, id.Version); err != nil { + return err + } + + switch id.Version { + // A version 0 identifier consists of its linked payment hash, followed + // by the token ID. + case 0: + if _, err := w.Write(id.PaymentHash[:]); err != nil { + return err + } + _, err := w.Write(id.TokenID[:]) + return err + + default: + return fmt.Errorf("%w: %v", ErrUnknownVersion, id.Version) + } +} + +// DecodeIdentifier decodes an LSAT's identifier according to its version. +func DecodeIdentifier(r io.Reader) (*Identifier, error) { + var version uint16 + if err := binary.Read(r, byteOrder, &version); err != nil { + return nil, err + } + + switch version { + // A version 0 identifier consists of its linked payment hash, followed + // by the token ID. + case 0: + var paymentHash lntypes.Hash + if _, err := r.Read(paymentHash[:]); err != nil { + return nil, err + } + var tokenID [TokenIDSize]byte + if _, err := r.Read(tokenID[:]); err != nil { + return nil, err + } + + return &Identifier{ + Version: version, + PaymentHash: paymentHash, + TokenID: tokenID, + }, nil + + default: + return nil, fmt.Errorf("%w: %v", ErrUnknownVersion, version) + } +} diff --git a/lsat/identifier_test.go b/lsat/identifier_test.go new file mode 100644 index 0000000..abda64c --- /dev/null +++ b/lsat/identifier_test.go @@ -0,0 +1,70 @@ +package lsat + +import ( + "bytes" + "errors" + "testing" + + "github.com/lightningnetwork/lnd/lntypes" +) + +var ( + testPaymentHash lntypes.Hash + testTokenID [TokenIDSize]byte +) + +// TestIdentifierSerialization ensures proper serialization of known identifier +// versions and failures for unknown versions. +func TestIdentifierSerialization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id Identifier + err error + }{ + { + name: "valid identifier", + id: Identifier{ + Version: LatestVersion, + PaymentHash: testPaymentHash, + TokenID: testTokenID, + }, + err: nil, + }, + { + name: "unknown version", + id: Identifier{ + Version: LatestVersion + 1, + PaymentHash: testPaymentHash, + TokenID: testTokenID, + }, + err: ErrUnknownVersion, + }, + } + + for _, test := range tests { + test := test + success := t.Run(test.name, func(t *testing.T) { + var buf bytes.Buffer + err := EncodeIdentifier(&buf, &test.id) + if !errors.Is(err, test.err) { + t.Fatalf("expected err \"%v\", got \"%v\"", + test.err, err) + } + if test.err != nil { + return + } + id, err := DecodeIdentifier(&buf) + if err != nil { + t.Fatalf("unable to decode identifier: %v", err) + } + if *id != test.id { + t.Fatalf("expected id %v, got %v", test.id, *id) + } + }) + if !success { + return + } + } +} diff --git a/lsat/satisfier.go b/lsat/satisfier.go new file mode 100644 index 0000000..5f7f7b5 --- /dev/null +++ b/lsat/satisfier.go @@ -0,0 +1,117 @@ +package lsat + +import ( + "fmt" + "strings" +) + +// Satisfier provides a generic interface to satisfy a caveat based on its +// condition. +type Satisfier struct { + // Condition is the condition of the caveat we'll attempt to satisfy. + Condition string + + // SatisfyPrevious ensures a caveat is in accordance with a previous one + // with the same condition. This is needed since caveats of the same + // condition can be used multiple times as long as they enforce more + // permissions than the previous. + // + // For example, we have a caveat that only allows us to use an LSAT for + // 7 more days. We can add another caveat that only allows for 3 more + // days of use and lend it to another party. + SatisfyPrevious func(previous Caveat, current Caveat) error + + // SatisfyFinal satisfies the final caveat of an LSAT. If multiple + // caveats with the same condition exist, this will only be executed + // once all previous caveats are also satisfied. + SatisfyFinal func(Caveat) error +} + +// NewServicesSatisfier implements a satisfier to determine whether the target +// service is authorized for a given LSAT. +// +// TODO(wilmer): Add tier verification? +func NewServicesSatisfier(targetService string) Satisfier { + return Satisfier{ + Condition: CondServices, + SatisfyPrevious: func(prev, cur Caveat) error { + // Construct a set of the services we were previously + // allowed to access. + prevServices, err := decodeServicesCaveatValue(prev.Value) + if err != nil { + return err + } + prevAllowed := make(map[string]struct{}, len(prevServices)) + for _, service := range prevServices { + prevAllowed[service.Name] = struct{}{} + } + + // The caveat should not include any new services that + // weren't previously allowed. + currentServices, err := decodeServicesCaveatValue(cur.Value) + if err != nil { + return err + } + for _, service := range currentServices { + if _, ok := prevAllowed[service.Name]; !ok { + return fmt.Errorf("service %v not "+ + "previously allowed", service) + } + } + + return nil + }, + SatisfyFinal: func(c Caveat) error { + services, err := decodeServicesCaveatValue(c.Value) + if err != nil { + return err + } + for _, service := range services { + if service.Name == targetService { + return nil + } + } + return fmt.Errorf("target service %v not authorized", + targetService) + }, + } +} + +// 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 { + return Satisfier{ + Condition: service + CondCapabilitiesSuffix, + SatisfyPrevious: func(prev, cur Caveat) error { + // Construct a set of the service's capabilities we were + // previously allowed to access. + prevCapabilities := strings.Split(prev.Value, ",") + allowed := make(map[string]struct{}, len(prevCapabilities)) + for _, capability := range prevCapabilities { + allowed[capability] = struct{}{} + } + + // The caveat should not include any new service + // capabilities that weren't previously allowed. + currentCapabilities := strings.Split(cur.Value, ",") + for _, capability := range currentCapabilities { + if _, ok := allowed[capability]; !ok { + return fmt.Errorf("capability %v not "+ + "previously allowed", capability) + } + } + + return nil + }, + SatisfyFinal: func(c Caveat) error { + capabilities := strings.Split(c.Value, ",") + for _, capability := range capabilities { + if capability == targetCapability { + return nil + } + } + return fmt.Errorf("target capability %v not authorized", + targetCapability) + }, + } +} diff --git a/lsat/service.go b/lsat/service.go new file mode 100644 index 0000000..5f10b5e --- /dev/null +++ b/lsat/service.go @@ -0,0 +1,128 @@ +package lsat + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + // CondServices is the condition used for a services caveat. + CondServices = "services" + + // CondCapabilitiesSuffix is the condition suffix used for a service's + // capabilities caveat. For example, the condition of a capabilities + // caveat for a service named `loop` would be `loop_capabilities`. + CondCapabilitiesSuffix = "_capabilities" +) + +var ( + // ErrNoServices is an error returned when we attempt to decode the + // services included in a caveat. + ErrNoServices = errors.New("no services found") + + // ErrInvalidService is an error returned when we attempt to decode a + // service with an invalid format. + ErrInvalidService = errors.New("service must be of the form " + + "\"name:tier\"") +) + +// ServiceTier represents the different possible tiers of an LSAT-enabled +// service. +type ServiceTier uint8 + +const ( + // BaseTier is the base tier of an LSAT-enabled service. This tier + // should be used for any new LSATs that are not part of a service tier + // upgrade. + BaseTier ServiceTier = iota +) + +// Service contains the details of an LSAT-enabled service. +type Service struct { + // Name is the name of the LSAT-enabled service. + Name string + + // Tier is the tier of the LSAT-enabled service. + Tier ServiceTier +} + +// NewServicesCaveat creates a new services caveat with the provided caveats. +func NewServicesCaveat(services ...Service) (Caveat, error) { + value, err := encodeServicesCaveatValue(services...) + if err != nil { + return Caveat{}, err + } + return Caveat{ + Condition: CondServices, + Value: value, + }, nil +} + +// encodeServicesCaveatValue encodes a list of services into the expected format +// of a services caveat's value. +func encodeServicesCaveatValue(services ...Service) (string, error) { + if len(services) == 0 { + return "", ErrNoServices + } + + var s strings.Builder + for i, service := range services { + if service.Name == "" { + return "", errors.New("missing service name") + } + + fmtStr := "%v:%v" + if i < len(services)-1 { + fmtStr += "," + } + + fmt.Fprintf(&s, fmtStr, service.Name, uint8(service.Tier)) + } + + return s.String(), nil +} + +// decodeServicesCaveatValue decodes a list of services from the expected format +// of a services caveat's value. +func decodeServicesCaveatValue(s string) ([]Service, error) { + if s == "" { + return nil, ErrNoServices + } + + rawServices := strings.Split(s, ",") + services := make([]Service, 0, len(rawServices)) + for _, rawService := range rawServices { + serviceInfo := strings.Split(rawService, ":") + if len(serviceInfo) != 2 { + return nil, ErrInvalidService + } + + name, tierStr := serviceInfo[0], serviceInfo[1] + if name == "" { + return nil, fmt.Errorf("%w: %v", ErrInvalidService, + "empty name") + } + tier, err := strconv.Atoi(tierStr) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidService, err) + } + + services = append(services, Service{ + Name: name, + Tier: ServiceTier(tier), + }) + } + + return services, nil +} + +// NewCapabilitiesCaveat creates a new capabilities caveat for the given +// service. +func NewCapabilitiesCaveat(serviceName string, capabilities string) Caveat { + return Caveat{ + Condition: serviceName + CondCapabilitiesSuffix, + Value: capabilities, + } +} diff --git a/lsat/service_test.go b/lsat/service_test.go new file mode 100644 index 0000000..8564f5b --- /dev/null +++ b/lsat/service_test.go @@ -0,0 +1,83 @@ +package lsat + +import ( + "errors" + "testing" +) + +// TestServicesCaveatSerialization ensures that we can properly encode/decode +// valid services from a caveat and cannot do so for invalid ones. +func TestServicesCaveatSerialization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + value string + err error + }{ + { + name: "single service", + value: "a:0", + err: nil, + }, + { + name: "multiple services", + value: "a:0,b:1,c:0", + err: nil, + }, + { + name: "no services", + value: "", + err: ErrNoServices, + }, + { + name: "service missing name", + value: ":0", + err: ErrInvalidService, + }, + { + name: "service missing tier", + value: "a", + err: ErrInvalidService, + }, + { + name: "service empty tier", + value: "a:", + err: ErrInvalidService, + }, + { + name: "service non-numeric tier", + value: "a:b", + err: ErrInvalidService, + }, + { + name: "empty services", + value: ",,", + err: ErrInvalidService, + }, + } + + for _, test := range tests { + test := test + success := t.Run(test.name, func(t *testing.T) { + services, err := decodeServicesCaveatValue(test.value) + if !errors.Is(err, test.err) { + t.Fatalf("expected err \"%v\", got \"%v\"", + test.err, err) + } + + if test.err != nil { + return + } + + value, _ := encodeServicesCaveatValue(services...) + if value != test.value { + t.Fatalf("expected encoded services \"%v\", "+ + "got \"%v\"", test.value, value) + } + }) + if !success { + return + } + } +}