From 70e1ebecab983d67ed3aed380e838bddec1c775b Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 26 Jul 2016 17:23:45 -0700 Subject: [PATCH] Implementation of a file-backed persistence store. This is a rather large change. It consists of the following changes: + Direct access to the keycache has been removed from the core package. This forces all interaction with the cache to go through the Cryptor, which is required for persistence. The Cryptor needs to know when the cache has changed, and the only way to do this effectively is to make the Cryptor responsible for managing the keycache. + A new persist package has been added. This provides a Store interface, for which two implementations are provided. The first is a null persister: this is used when no persistence is configured. The second is a file-backed persistence store. + The Cryptor now persists the cache every time it changes. Additionally, a number of missing returns in a function in the core package have been added. --- config/config.go | 14 ++- core/core.go | 52 ++++++----- core/core_test.go | 37 +++++--- cryptor/cryptor.go | 134 ++++++++++++++++++++++++++- cryptor/cryptor_test.go | 186 +++++++++++++++++++++++++++++++++++++- keycache/keycache.go | 41 ++++++++- keycache/keycache_test.go | 143 ++++++++++++++++++++++++++++- persist/file.go | 122 +++++++++++++++++++++++++ persist/file_test.go | 137 ++++++++++++++++++++++++++++ persist/null.go | 59 ++++++++++++ persist/null_test.go | 60 ++++++++++++ persist/persist.go | 81 +++++++++++++++++ persist/persist_test.go | 26 ++++++ 13 files changed, 1044 insertions(+), 48 deletions(-) create mode 100644 persist/file.go create mode 100644 persist/file_test.go create mode 100644 persist/null.go create mode 100644 persist/null_test.go create mode 100644 persist/persist.go create mode 100644 persist/persist_test.go diff --git a/config/config.go b/config/config.go index e38f7e5..4ae6ad7 100644 --- a/config/config.go +++ b/config/config.go @@ -77,8 +77,18 @@ type Delegations struct { Persist bool `json:"persist"` // Policy contains the MSP predicate for delegation - // persistence. - Policy string `json:"policy"` + // persistence, and users contains the users allowed + // to delegate. + Policy string `json:"policy"` + Users []string `json:"users"` + + // Mechanism specifies the persistence mechanism to use. + Mechanism string `json:"mechanism"` + + // Location contains location information for the persistence + // mechanism, such as a file path or database connection + // string. + Location string `json:"location"` } // Config contains all the configuration options for a redoctober diff --git a/core/core.go b/core/core.go index 7e71c4e..3125cdf 100644 --- a/core/core.go +++ b/core/core.go @@ -18,12 +18,12 @@ import ( "github.com/cloudflare/redoctober/keycache" "github.com/cloudflare/redoctober/order" "github.com/cloudflare/redoctober/passvault" + "github.com/cloudflare/redoctober/persist" ) var ( - crypt cryptor.Cryptor + crypt *cryptor.Cryptor records passvault.Records - cache keycache.Cache orders order.Orderer ) @@ -177,14 +177,6 @@ type StatusData struct { Status string } -// Delegation restoration and persistance configuration follows. - -const ( - PDStateNeverPersist = "disabled" - PDStateNotPersisting = "inactive" - PDStateNowPersisting = "active" -) - var restore struct { Config *config.Delegations State string @@ -199,7 +191,7 @@ func jsonStatusError(err error) ([]byte, error) { return json.Marshal(ResponseData{Status: err.Error()}) } func jsonSummary() ([]byte, error) { - return json.Marshal(SummaryData{Status: "ok", Live: cache.GetSummary(), All: records.GetSummary()}) + return json.Marshal(SummaryData{Status: "ok", Live: crypt.LiveSummary(), All: records.GetSummary()}) } func jsonResponse(resp []byte) ([]byte, error) { return json.Marshal(ResponseData{Status: "ok", Response: resp}) @@ -273,11 +265,10 @@ func Init(path string, config *config.Config) error { } restore.Config = config.Delegations - restore.State = PDStateNeverPersist + restore.State = persist.Disabled orders = order.NewOrderer(hipchatClient) - cache = keycache.Cache{UserKeys: make(map[keycache.DelegateIndex]keycache.ActiveUser)} - crypt = cryptor.New(&records, &cache) + crypt, err = cryptor.New(&records, nil, config) return err } @@ -320,7 +311,6 @@ func Create(jsonIn []byte) ([]byte, error) { func Summary(jsonIn []byte) ([]byte, error) { var s SummaryRequest var err error - cache.Refresh() defer func() { if err != nil { @@ -330,6 +320,11 @@ func Summary(jsonIn []byte) ([]byte, error) { } }() + err = crypt.Refresh() + if err != nil { + return jsonStatusError(err) + } + if err := json.Unmarshal(jsonIn, &s); err != nil { return jsonStatusError(err) } @@ -373,7 +368,11 @@ func Purge(jsonIn []byte) ([]byte, error) { return jsonStatusError(err) } - cache.FlushCache() + err = crypt.Flush() + if err != nil { + return jsonStatusError(err) + } + return jsonStatusOk() } @@ -426,7 +425,7 @@ func Delegate(jsonIn []byte) ([]byte, error) { } // add signed-in record to active set - if err = cache.AddKeyFromRecord(pr, s.Name, s.Password, s.Users, s.Labels, s.Uses, s.Slot, s.Time); err != nil { + if err = crypt.Delegate(pr, s.Name, s.Password, s.Users, s.Labels, s.Uses, s.Slot, s.Time); err != nil { return jsonStatusError(err) } @@ -798,27 +797,32 @@ func Order(jsonIn []byte) (out []byte, err error) { // Get the owners of the ciphertext. owners, _, err := crypt.GetOwners(o.EncryptedData) if err != nil { - jsonStatusError(err) + return jsonStatusError(err) } if o.Duration == "" { err = errors.New("Duration required when placing an order.") - jsonStatusError(err) + return jsonStatusError(err) } if o.Uses == 0 { err = errors.New("Number of required uses necessary when placing an order.") - jsonStatusError(err) + return jsonStatusError(err) + } + + err = crypt.Refresh() + if err != nil { + return jsonStatusError(err) } - cache.Refresh() + orderNum := order.GenerateNum() if len(o.Users) == 0 { err = errors.New("Must specify at least one user per order.") - jsonStatusError(err) + return jsonStatusError(err) } - adminsDelegated, numDelegated := cache.DelegateStatus(o.Users[0], o.Labels, owners) + adminsDelegated, numDelegated := crypt.DelegateStatus(o.Users[0], o.Labels, owners) duration, err := time.ParseDuration(o.Duration) if err != nil { - jsonStatusError(err) + return jsonStatusError(err) } currentTime := time.Now() ord := order.CreateOrder(o.Name, diff --git a/core/core_test.go b/core/core_test.go index 1af8b99..ed6fb21 100644 --- a/core/core_test.go +++ b/core/core_test.go @@ -14,6 +14,7 @@ import ( "github.com/cloudflare/redoctober/config" "github.com/cloudflare/redoctober/passvault" + "github.com/cloudflare/redoctober/persist" ) func TestCreate(t *testing.T) { @@ -97,9 +98,9 @@ func TestSummary(t *testing.T) { if err != nil { t.Fatalf("Error getting status, %v", err) } - if st.Status != PDStateNeverPersist { + if st.Status != persist.Disabled { t.Fatalf("Persistent delegations should be '%s' but are '%s'", - PDStateNeverPersist, st.Status) + persist.Disabled, st.Status) } respJson, err = Summary(createJson) @@ -174,7 +175,7 @@ func TestSummary(t *testing.T) { dataLive, ok := s.Live["Bob"] if !ok { - t.Fatalf("Error in summary of account, record missing, %v", cache.UserKeys) + t.Fatalf("Error in summary of account, record missing, %v", crypt.LiveSummary()) } if dataLive.Admin != false { t.Fatalf("Error in summary of account, record missing") @@ -184,7 +185,7 @@ func TestSummary(t *testing.T) { } var s1 SummaryData - delegations := cache.GetSummary() + delegations := crypt.LiveSummary() if len(delegations) == 0 { t.Fatal("no delegations active") } @@ -230,7 +231,7 @@ func TestSummary(t *testing.T) { t.Fatal("Bob was removed from the list of users") } - delegations = cache.GetSummary() + delegations = crypt.LiveSummary() if len(delegations) != 0 { t.Fatalf("purge failed to clear delegations (%d delegations remain)", len(delegations)) } @@ -470,7 +471,11 @@ func TestEncryptDecrypt(t *testing.T) { } // check summary to see if none are delegated - cache.Refresh() + err = crypt.Refresh() + if err != nil { + t.Fatalf("Error in summary: %s", err) + } + respJson, err = Summary(summaryJson) if err != nil { t.Fatalf("Error in summary, %v", err) @@ -557,7 +562,11 @@ func TestEncryptDecrypt(t *testing.T) { } // verify the presence of the two delgations - cache.Refresh() + err = crypt.Refresh() + if err != nil { + t.Fatalf("Error in summary: %s", err) + } + var sum2 SummaryData respJson, err = Summary(summaryJson) if err != nil { @@ -936,7 +945,11 @@ func TestModify(t *testing.T) { } // check summary to see if none are delegated - cache.Refresh() + err = crypt.Refresh() + if err != nil { + t.Fatalf("Error refreshing: %s", err) + } + respJson, err = Summary(summaryJson) if err != nil { t.Fatalf("Error in summary, %v", err) @@ -1078,6 +1091,7 @@ func TestStatic(t *testing.T) { if err != nil { t.Fatalf("Error opening file, %v", err) } + defer os.Remove("/tmp/db1.json") _, err = file.Write(diskVault) if err != nil { @@ -1139,9 +1153,10 @@ func TestStatic(t *testing.T) { t.Fatalf("Error in summary, %v, %v", expected, r.Response) } - cache.FlushCache() - - os.Remove("/tmp/db1.json") + err = crypt.Flush() + if err != nil { + t.Fatalf("Error flushing cache: %s", err) + } } func TestValidateName(t *testing.T) { diff --git a/cryptor/cryptor.go b/cryptor/cryptor.go index f598069..ce59cff 100644 --- a/cryptor/cryptor.go +++ b/cryptor/cryptor.go @@ -15,10 +15,12 @@ import ( "sort" "strconv" + "github.com/cloudflare/redoctober/config" "github.com/cloudflare/redoctober/keycache" "github.com/cloudflare/redoctober/msp" "github.com/cloudflare/redoctober/padding" "github.com/cloudflare/redoctober/passvault" + "github.com/cloudflare/redoctober/persist" "github.com/cloudflare/redoctober/symcrypt" ) @@ -29,10 +31,25 @@ const ( type Cryptor struct { records *passvault.Records cache *keycache.Cache + persist persist.Store } -func New(records *passvault.Records, cache *keycache.Cache) Cryptor { - return Cryptor{records, cache} +func New(records *passvault.Records, cache *keycache.Cache, config *config.Config) (*Cryptor, error) { + if cache == nil { + cache = &keycache.Cache{UserKeys: make(map[keycache.DelegateIndex]keycache.ActiveUser)} + } + + store, err := persist.New(config.Delegations) + if err != nil { + return nil, err + } + + c := &Cryptor{ + records: records, + cache: cache, + persist: store, + } + return c, nil } // AccessStructure represents different possible access structures for @@ -525,6 +542,10 @@ func (c *Cryptor) Encrypt(in []byte, labels []string, access AccessStructure) (r // Decrypt decrypts a file using the keys in the key cache. func (c *Cryptor) Decrypt(in []byte, user string) (resp []byte, labels, names []string, secure bool, err error) { + return c.decrypt(c.cache, in, user) +} + +func (c *Cryptor) decrypt(cache *keycache.Cache, in []byte, user string) (resp []byte, labels, names []string, secure bool, err error) { // unwrap encrypted file var encrypted EncryptedData if err = json.Unmarshal(in, &encrypted); err != nil { @@ -563,7 +584,7 @@ func (c *Cryptor) Decrypt(in []byte, user string) (resp []byte, labels, names [] // decrypt file key with delegate keys var unwrappedKey = make([]byte, 16) - unwrappedKey, names, err = encrypted.unwrapKey(c.cache, user) + unwrappedKey, names, err = encrypted.unwrapKey(cache, user) if err != nil { return } @@ -642,3 +663,110 @@ func (c *Cryptor) GetOwners(in []byte) (names []string, predicate string, err er return } + +// LiveSummary returns a list of the users currently delegated. +func (c *Cryptor) LiveSummary() map[string]keycache.ActiveUser { + return c.cache.GetSummary() +} + +// Refresh purges all expired or fully-used delegations in the +// crypto's key cache. It returns an error if the delegations +// should have been stored, but couldn't be. +func (c *Cryptor) Refresh() error { + n := c.cache.Refresh() + if n != 0 { + return c.store() + } + return nil +} + +// Flush removes all delegations. +func (c *Cryptor) Flush() error { + if c.cache.Flush() { + return c.store() + } + return nil +} + +// Delegate attempts to decrypt a key for the specified user and add +// the key to the key cache. +func (c *Cryptor) Delegate(record passvault.PasswordRecord, name, password string, users, labels []string, uses int, slot, durationString string) (err error) { + err = c.cache.AddKeyFromRecord(record, name, password, users, labels, uses, slot, durationString) + if err != nil { + return err + } + + return c.store() +} + +// DelegateStatus will return a list of admins who have delegated to a particular user, for a particular label. +// This is useful information to have when determining the status of an order and conveying order progress. +func (c *Cryptor) DelegateStatus(name string, labels, admins []string) (adminsDelegated []string, hasDelegated int) { + return c.cache.DelegateStatus(name, labels, admins) +} + +var ( + persistLabels = []string{"restore"} + persistUsers = []string{"restore"} +) + +// store serialises the key cache, encrypts it, and writes it to disk. +func (c *Cryptor) store() error { + // If the store isn't currently active, we shouldn't attempt + // to persist the store. + st := c.persist.Status() + if st.State != persist.Active { + return nil + } + + cache, err := json.Marshal(c.cache.GetSummary()) + if err != nil { + return err + } + + access := AccessStructure{ + Names: persistUsers, + Predicate: c.persist.Policy(), + } + + cache, err = c.Encrypt(cache, persistLabels, access) + if err != nil { + return err + } + + return c.persist.Store(cache) +} + +// ErrRestoreDelegations is a sentinal value returned when more +// delegations are needed for the restore to continue. +var ErrRestoreDelegations = errors.New("cryptor: need more delegations") + +func (c *Cryptor) Restore(name, password string, uses int, slot, durationString string) error { + record, ok := c.records.GetRecord(name) + if !ok { + return errors.New("Missing user on disk") + } + + err := c.persist.Delegate(record, name, password, c.persist.Users(), persistLabels, uses, slot, durationString) + if err != nil { + return err + } + + // A failure to decrypt isn't an error, it just means there + // aren't enough delegations yet; the sentinal value + // ErrRestoreDelegations is returned to indicate this. + cache, _, _, _, err := c.decrypt(c.persist.Cache(), c.persist.Blob(), name) + if err != nil { + return ErrRestoreDelegations + } + + var uk map[string]keycache.ActiveUser + err = json.Unmarshal(cache, &uk) + if err != nil { + return err + } + + c.cache = keycache.NewFrom(uk) + c.persist.Persist() + return nil +} diff --git a/cryptor/cryptor_test.go b/cryptor/cryptor_test.go index fa4727c..8adf05c 100644 --- a/cryptor/cryptor_test.go +++ b/cryptor/cryptor_test.go @@ -8,10 +8,14 @@ import ( "bytes" "encoding/base64" "encoding/json" + "io/ioutil" + "os" "testing" + "github.com/cloudflare/redoctober/config" "github.com/cloudflare/redoctober/keycache" "github.com/cloudflare/redoctober/passvault" + "github.com/cloudflare/redoctober/persist" ) func TestHash(t *testing.T) { @@ -83,7 +87,14 @@ func TestDuplicates(t *testing.T) { if err != nil { t.Fatalf("%v", err) } - c := Cryptor{&records, &cache} + + cfg := &config.Delegations{Persist: false} + store, err := persist.New(cfg) + if err != nil { + t.Fatal(err.Error()) + } + + c := Cryptor{&records, &cache, store} for _, name := range names { pr, err := records.AddNewRecord(name, "weakpassword", true, passvault.DefaultRecordType) @@ -117,6 +128,177 @@ func TestDuplicates(t *testing.T) { t.Fatalf("That shouldn't have worked!") } - cache.FlushCache() + cache.Flush() + } +} + +func TestEncryptDecrypt(t *testing.T) { + // Setup total names and partitions. + names := []string{"Alice", "Bob", "Carl"} + recs := make(map[string]passvault.PasswordRecord, 0) + left := []string{"Alice", "Bob"} + right := []string{"Bob", "Carl"} + + // Add each user to the keycache. + cache := keycache.NewCache() + records, err := passvault.InitFrom("memory") + if err != nil { + t.Fatalf("%v", err) + } + + cfg := &config.Delegations{Persist: false} + store, err := persist.New(cfg) + if err != nil { + t.Fatal(err.Error()) + } + + c := Cryptor{&records, &cache, store} + + for _, name := range names { + pr, err := records.AddNewRecord(name, "weakpassword", true, passvault.DefaultRecordType) + if err != nil { + t.Fatalf("%v", err) + } + + recs[name] = pr + } + + // Create candidate encryption of message. + ac := AccessStructure{ + LeftNames: left, + RightNames: right, + } + + resp, err := c.Encrypt([]byte("Hello World!"), []string{}, ac) + if err != nil { + t.Fatalf("Error: %s", err) + } + + // Delegate all the things. + for name, pr := range recs { + err = cache.AddKeyFromRecord(pr, name, "weakpassword", nil, nil, 2, "", "1h") + if err != nil { + t.Fatalf("%v", err) + } + } + + // (resp []byte, labels, names []string, secure bool, err error) + _, _, _, _, err = c.Decrypt(resp, "alice") + if err != nil { + t.Fatalf("%v", err) + } +} + +func tempName() (string, error) { + tmpf, err := ioutil.TempFile("", "transport_cachedkp_") + if err != nil { + return "", err + } + + name := tmpf.Name() + tmpf.Close() + return name, nil +} + +func TestRestore(t *testing.T) { + // Get the temporary persisted file. + temp, err := tempName() + if err != nil { + t.Fatal(err) + } + defer os.Remove(temp) + + // Setup total names and partitions. + names := []string{"Alice", "Bob", "Carl"} + recs := make(map[string]passvault.PasswordRecord, 0) + + // Add each user to the keycache. + cache := keycache.NewCache() + records, err := passvault.InitFrom("memory") + if err != nil { + t.Fatalf("%v", err) + } + + for _, name := range names { + pr, err := records.AddNewRecord(name, "weakpassword", true, passvault.DefaultRecordType) + if err != nil { + t.Fatalf("%v", err) + } + + recs[name] = pr + } + + alice, ok := records.GetRecord("Alice") + if !ok { + t.Fatal("Alice not found in password vault.") + } + + carl, ok := records.GetRecord("Carl") + if !ok { + t.Fatal("Carl not found in password vault.") + } + + // First, simulate a running Red October with persistence. + cfg := &config.Delegations{ + Persist: true, + Mechanism: persist.FileMechanism, + Location: temp, + Policy: "(Alice & Bob) | (Bob & Carl)", + Users: []string{"Alice", "Bob", "Carl"}, + } + + store, err := persist.New(cfg) + if err != nil { + t.Fatal(err.Error()) + } + + c := Cryptor{&records, &cache, store} + c.persist.Persist() + + err = c.Delegate(alice, "Alice", "weakpassword", []string{"Bob"}, []string{}, + 5, "", "1h") + if err != nil { + t.Fatal(err) + } + + err = c.Delegate(carl, "Carl", "weakpassword", []string{"Bob"}, []string{}, + 5, "", "1h") + + // Next, simulate restarting that server. + store, err = persist.New(cfg) + if err != nil { + t.Fatal(err.Error()) + } + + c = Cryptor{&records, &cache, store} + if _, err := os.Stat(temp); err != nil { + t.Fatalf("Not persisting: %v", err) + } + + err = c.Restore("Alice", "weakpassword", 5, "", "1h") + if err != ErrRestoreDelegations { + t.Fatal(err) + } + + err = c.Restore("Carl", "weakpassword", 5, "", "1h") + if err != ErrRestoreDelegations { + t.Fatal(err) + } + + status := c.persist.Status() + if status.State != persist.Inactive { + t.Fatalf("The persistent delegations should be %s, not %s", + persist.Inactive, status.State) + } + + err = c.Restore("Bob", "weakpassword", 5, "", "1h") + if err != nil { + t.Fatal(err) + } + + status = c.persist.Status() + if status.State != persist.Active { + t.Fatalf("The persistent delegations should be %s, not %s", + persist.Active, status.State) } } diff --git a/keycache/keycache.go b/keycache/keycache.go index 77d2218..9268964 100644 --- a/keycache/keycache.go +++ b/keycache/keycache.go @@ -14,6 +14,7 @@ import ( "errors" "fmt" "log" + "strings" "time" "github.com/cloudflare/redoctober/ecdh" @@ -94,6 +95,24 @@ func NewCache() Cache { return Cache{make(map[DelegateIndex]ActiveUser)} } +// NewFrom takes the output of GetSummary and returns a new keycache. +func NewFrom(summary map[string]ActiveUser) *Cache { + cache := &Cache{make(map[DelegateIndex]ActiveUser)} + for di, user := range summary { + diSplit := strings.SplitN(di, "-", 2) + index := DelegateIndex{ + Name: diSplit[0], + } + + if len(diSplit) == 2 { + index.Slot = diSplit[1] + } + cache.UserKeys[index] = user + } + + return cache +} + // setUser takes an ActiveUser and adds it to the cache. func (cache *Cache) setUser(in ActiveUser, name, slot string) { cache.UserKeys[DelegateIndex{Name: name, Slot: slot}] = in @@ -155,21 +174,35 @@ func (cache *Cache) GetSummary() map[string]ActiveUser { return summaryData } -// FlushCache removes all delegated keys. -func (cache *Cache) FlushCache() { +// FlushCache removes all delegated keys. It returns true if the cache +// wasn't empty (i.e. there were active users removed), and false if +// the cache was empty. +func (cache *Cache) Flush() bool { + if len(cache.UserKeys) == 0 { + return false + } + for d := range cache.UserKeys { delete(cache.UserKeys, d) } + + return true } -// Refresh purges all expired or used up keys. -func (cache *Cache) Refresh() { +// Refresh purges all expired keys. It returns the number of +// delegations that were removed. +func (cache *Cache) Refresh() int { + var removed int + for d, active := range cache.UserKeys { if active.Usage.Expiry.Before(time.Now()) { log.Println("Record expired", d.Name, d.Slot, active.Usage.Users, active.Usage.Labels, active.Usage.Expiry) + removed++ delete(cache.UserKeys, d) } } + + return removed } // AddKeyFromRecord decrypts a key for a given record and adds it to the cache. diff --git a/keycache/keycache_test.go b/keycache/keycache_test.go index d4049aa..26a0f2e 100644 --- a/keycache/keycache_test.go +++ b/keycache/keycache_test.go @@ -32,7 +32,11 @@ func TestUsesFlush(t *testing.T) { t.Fatalf("%v", err) } - cache.Refresh() + removed := cache.Refresh() + if removed != 0 { + t.Fatalf("No active users should have been removed") + } + if len(cache.UserKeys) != 1 { t.Fatalf("Error in number of live keys") } @@ -150,7 +154,6 @@ func TestGoodLabel(t *testing.T) { t.Fatalf("%v", err) } - cache.Refresh() if len(cache.UserKeys) != 0 { t.Fatalf("Error in number of live keys %v", cache.UserKeys) } @@ -291,3 +294,139 @@ func TestBadUser(t *testing.T) { t.Fatalf("Error in number of live keys %v", cache.UserKeys) } } + +func TestRefresh(t *testing.T) { + records, err := passvault.InitFrom("memory") + if err != nil { + t.Fatalf("%v", err) + } + + pr, err := records.AddNewRecord("user", "weakpassword", true, passvault.DefaultRecordType) + if err != nil { + t.Fatalf("%v", err) + } + + cache := NewCache() + + err = cache.AddKeyFromRecord( + pr, "user", "weakpassword", + []string{"ci", "buildeng", "user"}, + []string{"red", "blue"}, + 1, "", "1s", + ) + if err != nil { + t.Fatalf("%v", err) + } + + removed := cache.Refresh() + if removed != 0 { + t.Fatalf("Refresh should not have removed any active users.") + } + + time.Sleep(2 * time.Second) + + removed = cache.Refresh() + if removed != 1 { + t.Fatalf("Refresh should have removed an active user, removed %d", removed) + } + + if len(cache.GetSummary()) != 0 { + t.Fatalf("There should be no active users in the cache, but there are %d", len(cache.GetSummary())) + } +} + +func cmpEntry(c, d ActiveUser) bool { + if c.Uses != d.Uses { + return false + } + + if len(c.Labels) != len(d.Labels) { + return false + } + + for i := range c.Labels { + if c.Labels[i] != d.Labels[i] { + return false + } + } + + if len(c.Users) != len(d.Users) { + return false + } + + for i := range c.Users { + if c.Users[i] != d.Users[i] { + return false + } + } + + if c.Expiry != d.Expiry { + return false + } + + return true +} + +func cmpCache(a, b Cache) bool { + if len(a.UserKeys) != len(b.UserKeys) { + return false + } + + for aIndex, aUser := range a.UserKeys { + bUser, ok := b.UserKeys[aIndex] + if !ok { + return false + } + + if !cmpEntry(aUser, bUser) { + return false + } + } + + return true +} + +func TestNewFrom(t *testing.T) { + records, err := passvault.InitFrom("memory") + if err != nil { + t.Fatalf("%v", err) + } + + cache := NewCache() + + users := []string{"alice", "bob", "carol"} + for _, user := range users { + pr, err := records.AddNewRecord(user, "weakpassword", true, passvault.DefaultRecordType) + if err != nil { + t.Fatalf("%v", err) + } + + err = cache.AddKeyFromRecord( + pr, user, "weakpassword", + []string{"ci", "buildeng", "user"}, + []string{"red", "blue"}, + 1, "", "1h") + if err != nil { + t.Fatalf("%v", err) + } + } + + pr, ok := records.GetRecord("alice") + if !ok { + t.Fatal("Couldn't retrieve 'alice' record.") + } + + err = cache.AddKeyFromRecord(pr, "alice", "weakpassword", + []string{"ci", "alice"}, []string{"blue", "yellow"}, + 2, "slotname", "1h") + if err != nil { + t.Fatal(err) + } + + summary := cache.GetSummary() + cache2 := NewFrom(summary) + if !cmpCache(cache, *cache2) { + t.Fatal("caches don't match") + } + +} diff --git a/persist/file.go b/persist/file.go new file mode 100644 index 0000000..b2f1cf2 --- /dev/null +++ b/persist/file.go @@ -0,0 +1,122 @@ +package persist + +import ( + "io/ioutil" + "os" + + "github.com/cloudflare/redoctober/config" + "github.com/cloudflare/redoctober/keycache" + "github.com/cloudflare/redoctober/passvault" +) + +// File implements a file-backed persistence store. +type File struct { + config *config.Delegations + cache *keycache.Cache + state string + blob []byte +} + +// Valid ensures the configuration is valid for a file store. Note +// that it won't validate the policy, it will just ensure that one +// is present. +func (f *File) Valid() bool { + if f.config.Persist == false { + return false + } + + if f.config.Policy == "" { + return false + } + + if len(f.config.Users) == 0 { + return false + } + + if f.config.Mechanism != FileMechanism { + return false + } + + if f.config.Location == "" { + return false + } + + return true +} + +// newFile returns a new file-backed persistence store. +func newFile(config *config.Delegations) (Store, error) { + cache := keycache.NewCache() + file := &File{ + config: config, + cache: &cache, + state: Inactive, + } + + if !file.Valid() { + return nil, ErrInvalidConfig + } + + err := file.Load() + if err != nil { + return nil, err + } + return file, nil +} + +func (f *File) Blob() []byte { + return f.blob +} + +func (f *File) Policy() string { + return f.config.Policy +} + +func (f *File) Users() []string { + return f.config.Users +} + +func (f *File) Store(blob []byte) error { + if f.state == Active { + f.blob = blob + return ioutil.WriteFile(f.config.Location, blob, 0644) + } + return nil +} + +func (f *File) Load() error { + in, err := ioutil.ReadFile(f.config.Location) + if err != nil { + // If the file doesn't exist, it can be persisted + // immediately. + if os.IsNotExist(err) { + f.state = Active + return nil + } + + return err + } + + f.state = Inactive + f.blob = in + return nil +} + +func (f *File) Persist() { + f.state = Active +} + +func (f *File) Cache() *keycache.Cache { + return f.cache +} + +func (f *File) Delegate(record passvault.PasswordRecord, name, password string, users, labels []string, uses int, slot, durationString string) error { + return f.cache.AddKeyFromRecord(record, name, password, users, labels, uses, slot, durationString) +} + +func (f *File) Status() *Status { + return &Status{ + State: f.state, + Summary: f.cache.GetSummary(), + } +} diff --git a/persist/file_test.go b/persist/file_test.go new file mode 100644 index 0000000..1bea127 --- /dev/null +++ b/persist/file_test.go @@ -0,0 +1,137 @@ +package persist + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/cloudflare/redoctober/config" +) + +func TestFileConfig(t *testing.T) { + cfg := &config.Delegations{ + Persist: false, + } + f := &File{config: cfg} + + if f.Valid() { + t.Fatal("persist: File config should persist") + } + + cfg.Persist = true + if f.Valid() { + t.Fatal("persist: File config should require policy") + } + + cfg.Policy = "some policy" + if f.Valid() { + t.Fatal("persist: File config should require mechanism") + } + + cfg.Users = []string{"alice", "bob"} + + cfg.Mechanism = "db" + if f.Valid() { + t.Fatalf("persist: File config should require the '%s' mechanism", FileMechanism) + } + + cfg.Mechanism = FileMechanism + if f.Valid() { + t.Fatal("persist: File config should require a location") + } + + cfg.Location = "testdata/store.bin" + if !f.Valid() { + t.Fatal("persist: valid File config marked as invalid") + } + + cfg.Location = "" + _, err := New(cfg) + if err != ErrInvalidConfig { + t.Fatalf("persist: expected err='%s', have err='%s'", + ErrInvalidConfig, err) + } +} + +func tempName() (string, error) { + tmpf, err := ioutil.TempFile("", "transport_cachedkp_") + if err != nil { + return "", err + } + + name := tmpf.Name() + tmpf.Close() + return name, nil +} + +func TestFileSanity(t *testing.T) { + sf, err := tempName() + if err != nil { + t.Fatal(err) + } + defer os.Remove(sf) + + const expected = "testdata" + err = ioutil.WriteFile(sf, []byte(expected), 0644) + if err != nil { + t.Fatal(err) + } + + cfg := &config.Delegations{ + Persist: true, + Mechanism: FileMechanism, + Policy: "alice & bob", + Users: []string{"alice", "bob"}, + Location: sf, + } + + f, err := New(cfg) + if err != nil { + t.Fatal(err) + } + + if string(f.Blob()) != expected { + t.Fatalf("persist: expected blob data '%s' but have '%s'", expected, f.Blob()) + } + + if f.Policy() != cfg.Policy { + t.Fatalf("persist: policy mismatch - should have '%s' but have '%s'", + cfg.Policy, f.Policy()) + } + + if len(f.Users()) != 2 { + t.Fatalf("persist: expected 2 users, have %d", len(f.Users())) + } + + const expected2 = "test data" + if err = f.Store([]byte(expected2)); err != nil { + t.Fatal(err) + } + + if string(f.Blob()) == expected2 { + t.Fatal("persist: should not have begun persisting yet") + } + + f.Persist() + if err = f.Store([]byte(expected2)); err != nil { + t.Fatal(err) + } + + if string(f.Blob()) != expected2 { + t.Fatalf("persist: expected blob data '%s' but have '%s'", expected2, f.Blob()) + } + + err = ioutil.WriteFile(sf, []byte(expected), 0644) + if err != nil { + t.Fatal(err) + } + + if err = f.Load(); err != nil { + t.Fatal(err) + } + + if string(f.Blob()) != expected { + t.Fatalf("persist: expected blob data '%s' but have '%s'", expected2, f.Blob()) + } + +} diff --git a/persist/null.go b/persist/null.go new file mode 100644 index 0000000..8d9d4fd --- /dev/null +++ b/persist/null.go @@ -0,0 +1,59 @@ +package persist + +import ( + "errors" + + "github.com/cloudflare/redoctober/config" + "github.com/cloudflare/redoctober/keycache" + "github.com/cloudflare/redoctober/passvault" +) + +// Null is a non-persisting store. It is used when persistence is not +// activated. +type Null struct { + config *config.Delegations +} + +func newNull(config *config.Delegations) (Store, error) { + return &Null{config: config}, nil +} + +func (n *Null) Blob() []byte { + return nil +} + +func (n *Null) Policy() string { + return n.config.Policy +} + +func (n *Null) Users() []string { + return n.config.Users +} + +func (n *Null) Store(bs []byte) error { + return nil +} + +func (n *Null) Load() error { + return nil +} + +func (n *Null) Persist() { + return +} + +func (n *Null) Status() *Status { + return &Status{ + State: Disabled, + Summary: nil, + } +} + +func (n *Null) Delegate(record passvault.PasswordRecord, name, password string, users, labels []string, uses int, slot, durationString string) error { + return errors.New("persist: null store does not support delegations") +} + +func (n *Null) Cache() *keycache.Cache { + cache := keycache.NewCache() + return &cache +} diff --git a/persist/null_test.go b/persist/null_test.go new file mode 100644 index 0000000..af972cc --- /dev/null +++ b/persist/null_test.go @@ -0,0 +1,60 @@ +package persist + +import ( + "testing" + + "github.com/cloudflare/redoctober/config" + "github.com/cloudflare/redoctober/passvault" +) + +func TestNewNull(t *testing.T) { + cfg := &config.Delegations{ + Persist: false, + Mechanism: FileMechanism, + Location: "testdata/store.bin", + Policy: "policy", + } + + store, err := New(cfg) + if err != nil { + t.Fatalf("persist: failed to create a new store: %s", err) + } + + if _, ok := store.(*Null); !ok { + t.Fatalf("persist: expected a Null store, but have %T", store) + } + + if store.Blob() != nil { + t.Fatalf("persist: Null store should return an empty blob") + } + + if store.Policy() != cfg.Policy { + t.Fatalf("persist: expected a consistent policy") + } + + if err := store.Store([]byte("test data")); err != nil { + t.Fatalf("persist: Null.Store failed with %s", err) + } + + if err := store.Load(); err != nil { + t.Fatalf("persist: Null.Load failed with %s", err) + } + + status := store.Status() + if status.State != Disabled { + t.Fatalf("persist: Null store should never persist") + } + + if len(status.Summary) != 0 { + t.Fatal("persist: Null summary should have zero entries") + } + + err = store.Delegate(passvault.PasswordRecord{}, "name", "password", []string{}, []string{}, 1, "", "1h") + if err == nil { + t.Fatal("persist: expected delegation to fail") + } + + if cache := store.Cache(); len(cache.UserKeys) != 0 { + t.Fatal("persist: Null Cache should return an empty cache") + } +} diff --git a/persist/persist.go b/persist/persist.go new file mode 100644 index 0000000..07384ae --- /dev/null +++ b/persist/persist.go @@ -0,0 +1,81 @@ +// Package persist implements delegation persistence. It is primarily +// concerned with configuration and serialisation; encryption and +// decryption is done by the cryptor package. +package persist + +import ( + "errors" + + "github.com/cloudflare/redoctober/config" + "github.com/cloudflare/redoctober/keycache" + "github.com/cloudflare/redoctober/passvault" +) + +var defaultStore Store = &File{} + +const ( + // NeverPersist indicates that the persistence store will + // never persist active delegations. + Disabled = "disabled" + + // Inactive indicates that the persistence store requires + // more delegations to unlock, and isn't currently persisting + // the store. + Inactive = "inactive" + + // Active indicates that the persistence store is + // actively persisting delegations. + Active = "active" +) + +// Status contains information on the current status of a persistence +// store. +type Status struct { + State string `json:"state"` + Summary map[string]keycache.ActiveUser +} + +// Store is a persistence store interface that handles delegations, +// serialising the persistence store, and writing the store to disk. +type Store interface { + Blob() []byte + Policy() string + Users() []string + Store([]byte) error + Load() error + Status() *Status + // Persist tells the Store to start actively persisting. + Persist() + Delegate(record passvault.PasswordRecord, name, password string, users, labels []string, uses int, slot, durationString string) error + // This is not the main keycache. This is the keycache for + // users that can decrypt the store. + Cache() *keycache.Cache +} + +const FileMechanism = "file" + +type mechanism func(*config.Delegations) (Store, error) + +var stores = map[string]mechanism{ + "": newNull, + FileMechanism: newFile, +} + +func New(config *config.Delegations) (Store, error) { + if config == nil { + return nil, errors.New("persist: nil configuration") + } + + if !config.Persist { + return newNull(config) + } + + constructor, ok := stores[config.Mechanism] + if !ok { + return nil, errors.New("persist: invalid persistence mechanism") + } + + return constructor(config) +} + +var ErrInvalidConfig = errors.New("persist: invalid configuration") diff --git a/persist/persist_test.go b/persist/persist_test.go new file mode 100644 index 0000000..dff35c8 --- /dev/null +++ b/persist/persist_test.go @@ -0,0 +1,26 @@ +package persist + +import ( + "testing" + + "github.com/cloudflare/redoctober/config" +) + +func TestNew(t *testing.T) { + cfg := &config.Delegations{ + Persist: true, + Policy: "policy", + Users: []string{"alice"}, + Mechanism: FileMechanism, + Location: "testdata/store.bin", + } + + store, err := New(cfg) + if err != nil { + t.Fatalf("persist: failed to create a new store: %s", err) + } + + if _, ok := store.(*File); !ok { + t.Fatalf("persist: New should return a *File, but returned a %T", store) + } +}