diff --git a/app/app.go b/app/app.go index 8d916a3..8287167 100644 --- a/app/app.go +++ b/app/app.go @@ -29,7 +29,6 @@ type application struct { // Application is a full cwtch peer application. It allows management, usage and storage of multiple peers type Application interface { - SaveProfile(cwtchPeer peer.CwtchPeer) LoadProfiles(password string) error CreatePeer(name string, password string) (peer.CwtchPeer, error) @@ -59,21 +58,16 @@ func generateRandomFilename() string { return filepath.Join(hex.EncodeToString(randBytes)) } -func (app *application) SaveProfile(p peer.CwtchPeer) { - app.mutex.Lock() - defer app.mutex.Unlock() - app.peers[p.GetProfile().Onion] = p - app.storage[p.GetProfile().Onion].Save(p) -} - // NewProfile creates a new cwtchPeer with a given name. func (app *application) CreatePeer(name string, password string) (peer.CwtchPeer, error) { log.Debugf("CreatePeer(%v)\n", name) randomFileName := generateRandomFilename() - fileStore := storage.CreateFileProfileStore(path.Join(app.directory, "profiles", randomFileName), password) + // TODO: eventBus per profile + profileStore, err := storage.NewProfileStore(app.eventBus, path.Join(app.directory, "profiles", randomFileName), password) + profileStore.Init(name) p := peer.NewCwtchPeer(name) - err := fileStore.Save(p) + app.eventBus.Publish(event.NewEvent(event.SetProfileName, map[string]string{"Name": name})) if err != nil { return nil, err } @@ -85,7 +79,7 @@ func (app *application) CreatePeer(name string, password string) (peer.CwtchPeer } app.mutex.Lock() app.peers[p.GetProfile().Onion] = p - app.storage[p.GetProfile().Onion] = fileStore + app.storage[p.GetProfile().Onion] = profileStore app.mutex.Unlock() return p, nil @@ -99,27 +93,30 @@ func (app *application) LoadProfiles(password string) error { for _, file := range files { - fileStore := storage.CreateFileProfileStore(path.Join(app.directory, "profiles", file.Name()), password) + // TODO: Per profile eventBus + profileStore, err := storage.NewProfileStore(app.eventBus, path.Join(app.directory, "profiles", file.Name()), password) - p, err := fileStore.Load() + err = profileStore.Load() if err != nil { continue } - _, exists := app.peers[p.GetProfile().Onion] + profile := profileStore.GetProfileCopy() + _, exists := app.peers[profile.Onion] if exists { - p.Shutdown() - log.Errorf("profile for onion %v already exists", p.GetProfile().Onion) + profileStore.Shutdown() + log.Errorf("profile for onion %v already exists", profile.Onion) continue } - p.Init(app.acn, app.eventBus) + peer := peer.FromProfile(profile) + peer.Init(app.acn, app.eventBus) app.mutex.Lock() - app.peers[p.GetProfile().Onion] = p - app.storage[p.GetProfile().Onion] = fileStore + app.peers[profile.Onion] = peer + app.storage[profile.Onion] = profileStore if app.primaryonion == "" { - app.primaryonion = p.GetProfile().Onion + app.primaryonion = profile.Onion } app.mutex.Unlock() } diff --git a/app/cli/main.go b/app/cli/main.go index 14df948..29294c9 100644 --- a/app/cli/main.go +++ b/app/cli/main.go @@ -548,8 +548,6 @@ func main() { fmt.Printf("%v", commands) fmt.Printf("Error importing group, usage: %s\n", usages[commands[0]]) } - case "/save": - app.SaveProfile(peer) case "/help": for _, command := range suggestions { fmt.Printf("%-18s%-56s%s\n", command.Text, command.Description, usages[command.Text]) @@ -599,13 +597,6 @@ func main() { } } } - if peer != nil { - app.SaveProfile(peer) - } - } - - if peer != nil { - app.SaveProfile(peer) } app.Shutdown() diff --git a/app/cwtchutil/main.go b/app/cwtchutil/main.go index ca59f9e..f4aab6f 100644 --- a/app/cwtchutil/main.go +++ b/app/cwtchutil/main.go @@ -1,54 +1,14 @@ package main import ( - "bufio" - "crypto/rand" - libpeer "cwtch.im/cwtch/peer" - storage2 "cwtch.im/cwtch/storage" - "fmt" - "git.openprivacy.ca/openprivacy/libricochet-go/utils" - "errors" + "fmt" "git.openprivacy.ca/openprivacy/libricochet-go/log" - "golang.org/x/crypto/ed25519" - "io/ioutil" "os" - "strconv" - "strings" - "time" + //"bufio" + //"cwtch.im/cwtch/storage" ) -func convertCwtchFile(filename string, password string) error { - fileStore := storage2.CreateFileProfileStore(filename, password) - peer, err := fileStore.Load() - if err != nil { - return err - } - - b := []byte("== ed25519v1-secret: type0 ==") - b = append(b, peer.GetProfile().Ed25519PrivateKey...) - err = ioutil.WriteFile("hs_ed25519_secret_key", b, 0600) - if err != nil { - return err - } - - b = []byte("== ed25519v1-public: type0 ==") - b = append(b, peer.GetProfile().Ed25519PublicKey...) - err = ioutil.WriteFile("hs_ed25519_public_key", b, 0600) - if err != nil { - return err - } - - b = []byte(peer.GetProfile().Onion + ".onion\n") - err = ioutil.WriteFile("hostname", b, 0600) - if err != nil { - return err - } - - log.Infoln("success!") - return nil -} - func convertTorFile(filename string, password string) error { return errors.New("this code doesn't work and can never work :( it's a math thing") @@ -77,7 +37,7 @@ func convertTorFile(filename string, password string) error { peer.GetProfile().Ed25519PrivateKey = sk peer.GetProfile().Ed25519PublicKey = pk peer.GetProfile().Onion = string(onion) - fileStore := storage2.CreateFileProfileStore(filename, password) + fileStore := storage2.NewFileStore(filename, password) err = fileStore.Save(peer) if err != nil { return err @@ -87,6 +47,7 @@ func convertTorFile(filename string, password string) error { return nil*/ } +/* func vanity() error { for { pk, sk, err := ed25519.GenerateKey(rand.Reader) @@ -100,16 +61,14 @@ func vanity() error { peer.GetProfile().Ed25519PrivateKey = sk peer.GetProfile().Ed25519PublicKey = pk peer.GetProfile().Onion = onion - fileStore := storage2.CreateFileProfileStore(os.Args[3], onion+".cwtch") - err := fileStore.Save(peer) - if err != nil { - return err - } + profileStore, _ := storage2.NewProfileStore(nil, os.Args[3], onion+".cwtch") + profileStore.Init("") + // need to signal new onion? impossible log.Infof("found %s.onion\n", onion) } } } -} +}*/ func printHelp() { log.Infoln("usage: cwtchutil {help, convert-cwtch-file, convert-tor-file, changepw, vanity}") @@ -127,15 +86,6 @@ func main() { printHelp() case "help": printHelp() - case "convert-cwtch-file": - if len(os.Args) != 4 { - fmt.Println("example: cwtchutil convert-cwtch-file ~/.cwtch/profiles/11ddd78a9918c064e742d5e36a8b8fd4 passw0rd") - os.Exit(1) - } - err := convertCwtchFile(os.Args[2], os.Args[3]) - if err != nil { - log.Errorln(err) - } case "convert-tor-file": if len(os.Args) != 4 { fmt.Println("example: cwtchutil convert-tor-file /var/lib/tor/hs1 passw0rd") @@ -145,7 +95,7 @@ func main() { if err != nil { log.Errorln(err) } - case "vanity": + /*case "vanity": if len(os.Args) < 5 { fmt.Println("example: cwtchutil vanity 4 passw0rd erinn openpriv") os.Exit(1) @@ -163,8 +113,8 @@ func main() { for { // run until ctrl+c time.Sleep(time.Hour * 24) - } - case "changepw": + }*/ + /*case "changepw": if len(os.Args) != 3 { fmt.Println("example: cwtch changepw ~/.cwtch/profiles/XXX") os.Exit(1) @@ -179,9 +129,9 @@ func main() { } pw = pw[:len(pw)-1] - fileStore := storage2.CreateFileProfileStore(os.Args[2], pw) + profileStore, _ := storage.NewProfileStore(nil, os.Args[2], pw) - peer, err := fileStore.Load() + err = profileStore.Load() if err != nil { log.Errorln(err) os.Exit(1) @@ -195,7 +145,8 @@ func main() { } newpw1 = newpw1[:len(newpw1)-1] // fuck go with this linebreak shit ^ea - fileStore2 := storage2.CreateFileProfileStore(os.Args[2], newpw1) + fileStore2, _ := storage.NewProfileStore(nil, os.Args[2], newpw1) + // No way to copy, populate this method err = fileStore2.Save(peer) if err != nil { log.Errorln(err) @@ -203,5 +154,6 @@ func main() { } log.Infoln("success!") + */ } } diff --git a/event/common.go b/event/common.go index 55f32e2..624ee2a 100644 --- a/event/common.go +++ b/event/common.go @@ -24,6 +24,8 @@ const ( SendMessageToPeer = Type("SendMessageToPeer") NewMessageFromPeer = Type("NewMessageFromPeer") + + SetProfileName = Type("SetProfileName") ) // Field defines common event attributes diff --git a/model/profile.go b/model/profile.go index ee67785..b4a5356 100644 --- a/model/profile.go +++ b/model/profile.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "cwtch.im/cwtch/protocol" "encoding/base32" + "encoding/json" "errors" "git.openprivacy.ca/openprivacy/libricochet-go/utils" "github.com/golang/protobuf/proto" @@ -32,8 +33,6 @@ type Profile struct { Contacts map[string]*PublicProfile Ed25519PrivateKey ed25519.PrivateKey Groups map[string]*Group - Custom map[string]string - lock sync.Mutex } // MaxGroupMessageLength is the maximum length of a message posted to a server group. @@ -62,6 +61,7 @@ func (p *PublicProfile) GetAttribute(name string) (value string, exists bool) { // GenerateNewProfile creates a new profile, with new encryption and signing keys, and a profile name. func GenerateNewProfile(name string) *Profile { p := new(Profile) + p.init() p.Name = name pub, priv, _ := ed25519.GenerateKey(rand.Reader) p.Ed25519PublicKey = pub @@ -71,7 +71,6 @@ func GenerateNewProfile(name string) *Profile { p.Contacts = make(map[string]*PublicProfile) p.Contacts[p.Onion] = &p.PublicProfile p.Groups = make(map[string]*Group) - p.Custom = make(map[string]string) return p } @@ -149,21 +148,6 @@ func (p *Profile) GetGroups() []string { return keys } -// SetCustomAttribute allows applications to store arbitrary configuration info at the profile level. -func (p *Profile) SetCustomAttribute(name string, value string) { - p.lock.Lock() - defer p.lock.Unlock() - p.Custom[name] = value -} - -// GetCustomAttribute returns the value of a value set with SetCustomAttribute. If no such value has been set exists is set to false. -func (p *Profile) GetCustomAttribute(name string) (value string, exists bool) { - p.lock.Lock() - defer p.lock.Unlock() - value, exists = p.Custom[name] - return -} - // GetContacts returns an unordered list of contact onions associated with this profile. func (p *Profile) GetContacts() []string { p.lock.Lock() @@ -370,3 +354,14 @@ func (p *Profile) EncryptMessageToGroup(message string, groupID string) ([]byte, } return nil, nil, errors.New("group does not exist") } + +// GetCopy returns a full deep copy of the Profile struct and its members +func (p *Profile) GetCopy() *Profile { + p.lock.Lock() + defer p.lock.Unlock() + + newp := new(Profile) + bytes, _ := json.Marshal(p) + json.Unmarshal(bytes, &newp) + return newp +} diff --git a/model/profile_test.go b/model/profile_test.go index e67cdc3..0560ae4 100644 --- a/model/profile_test.go +++ b/model/profile_test.go @@ -50,8 +50,8 @@ func TestProfileIdentity(t *testing.T) { t.Errorf("alice should be only contact: %v", alice.GetContacts()) } - alice.SetCustomAttribute("test", "hello world") - value, _ := alice.GetCustomAttribute("test") + alice.SetAttribute("test", "hello world") + value, _ := alice.GetAttribute("test") if value != "hello world" { t.Errorf("value from custom attribute should have been 'hello world', instead was: %v", value) } diff --git a/server/server.go b/server/server.go index 72cebad..3f20792 100644 --- a/server/server.go +++ b/server/server.go @@ -5,7 +5,7 @@ import ( "cwtch.im/cwtch/server/listen" "cwtch.im/cwtch/server/metrics" "cwtch.im/cwtch/server/send" - "cwtch.im/cwtch/storage" + "cwtch.im/cwtch/server/storage" "git.openprivacy.ca/openprivacy/libricochet-go/application" "git.openprivacy.ca/openprivacy/libricochet-go/channels" "git.openprivacy.ca/openprivacy/libricochet-go/connectivity" diff --git a/server/server_instance.go b/server/server_instance.go index 851668c..2d587d5 100644 --- a/server/server_instance.go +++ b/server/server_instance.go @@ -3,7 +3,7 @@ package server import ( "cwtch.im/cwtch/protocol" "cwtch.im/cwtch/server/listen" - "cwtch.im/cwtch/storage" + "cwtch.im/cwtch/server/storage" "git.openprivacy.ca/openprivacy/libricochet-go/application" "git.openprivacy.ca/openprivacy/libricochet-go/channels" ) diff --git a/server/server_instance_test.go b/server/server_instance_test.go index 9be7fa4..4a45702 100644 --- a/server/server_instance_test.go +++ b/server/server_instance_test.go @@ -3,7 +3,7 @@ package server import ( "cwtch.im/cwtch/protocol" "cwtch.im/cwtch/server/metrics" - "cwtch.im/cwtch/storage" + "cwtch.im/cwtch/server/storage" "git.openprivacy.ca/openprivacy/libricochet-go/application" "os" "testing" diff --git a/storage/message_store.go b/server/storage/message_store.go similarity index 100% rename from storage/message_store.go rename to server/storage/message_store.go diff --git a/storage/message_store_test.go b/server/storage/message_store_test.go similarity index 100% rename from storage/message_store_test.go rename to server/storage/message_store_test.go diff --git a/storage/engine.go b/storage/engine.go new file mode 100644 index 0000000..82be054 --- /dev/null +++ b/storage/engine.go @@ -0,0 +1 @@ +package storage diff --git a/storage/file_enc.go b/storage/file_enc.go new file mode 100644 index 0000000..e6d377b --- /dev/null +++ b/storage/file_enc.go @@ -0,0 +1,23 @@ +package storage + +import ( + "crypto/rand" + "git.openprivacy.ca/openprivacy/libricochet-go/log" + "golang.org/x/crypto/pbkdf2" + "golang.org/x/crypto/sha3" + "io" +) + +// createKey derives a key from a password +func createKey(password string) ([32]byte, [128]byte, error) { + var salt [128]byte + if _, err := io.ReadFull(rand.Reader, salt[:]); err != nil { + log.Errorf("Cannot read from random: %v\n", err) + return [32]byte{}, salt, err + } + dk := pbkdf2.Key([]byte(password), salt[:], 4096, 32, sha3.New512) + + var dkr [32]byte + copy(dkr[:], dk) + return dkr, salt, nil +} diff --git a/storage/file_profile_store.go b/storage/file_profile_store.go deleted file mode 100644 index 455706b..0000000 --- a/storage/file_profile_store.go +++ /dev/null @@ -1,109 +0,0 @@ -package storage - -import ( - "crypto/rand" - "cwtch.im/cwtch/model" - "cwtch.im/cwtch/peer" - "encoding/json" - "fmt" - "git.openprivacy.ca/openprivacy/libricochet-go/log" - "golang.org/x/crypto/nacl/secretbox" - "golang.org/x/crypto/pbkdf2" - "golang.org/x/crypto/sha3" - "io" - "io/ioutil" -) - -// fileProfileStore stores a cwtchPeer in an encrypted file -type fileProfileStore struct { - profilefile string - password string -} - -// CreateFileProfileStore instantiates a fileProfileStore given a filename and a password -func CreateFileProfileStore(profilefile string, password string) ProfileStore { - filestore := new(fileProfileStore) - filestore.password = password - filestore.profilefile = profilefile - return filestore -} - -// Save serializes a cwtchPeer to a file -func (fps *fileProfileStore) Save(cwtchPeer peer.CwtchPeer) error { - - key, salt, _ := createKey(fps.password) - encryptedbytes, err := encryptProfile(cwtchPeer, key) - if err != nil { - return err - } - - // the salt for the derived key is appended to the front of the file - encryptedbytes = append(salt[:], encryptedbytes...) - err = ioutil.WriteFile(fps.profilefile, encryptedbytes, 0600) - return err - -} - -// createKey derives a key from a password -func createKey(password string) ([32]byte, [128]byte, error) { - var salt [128]byte - if _, err := io.ReadFull(rand.Reader, salt[:]); err != nil { - log.Errorf("Cannot read from random: %v\n", err) - return [32]byte{}, salt, err - } - dk := pbkdf2.Key([]byte(password), salt[:], 4096, 32, sha3.New512) - - var dkr [32]byte - copy(dkr[:], dk) - return dkr, salt, nil -} - -//encryptProfile encrypts the cwtchPeer via the specified key. -func encryptProfile(p peer.CwtchPeer, key [32]byte) ([]byte, error) { - var nonce [24]byte - - if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { - log.Errorf("Cannot read from random: %v\n", err) - return nil, err - } - - bytes, _ := json.Marshal(p.GetProfile()) - encrypted := secretbox.Seal(nonce[:], []byte(bytes), &nonce, &key) - return encrypted, nil -} - -//decryptProfile decrypts the passed ciphertext into a cwtchPeer via the specified key. -func decryptProfile(ciphertext []byte, key [32]byte) (*model.Profile, error) { - - var decryptNonce [24]byte - copy(decryptNonce[:], ciphertext[:24]) - decrypted, ok := secretbox.Open(nil, ciphertext[24:], &decryptNonce, &key) - if ok { - cp := new(model.Profile) - err := json.Unmarshal(decrypted, &cp) - if err == nil { - return cp, nil - } - return nil, err - } - return nil, fmt.Errorf("Failed to decrypt") -} - -// Load instantiates a cwtchPeer from the file store -func (fps *fileProfileStore) Load() (peer.CwtchPeer, error) { - encryptedbytes, err := ioutil.ReadFile(fps.profilefile) - if err == nil { - var dkr [32]byte - //Separate the salt from the encrypted bytes, then generate the derived key - salt, encryptedbytes := encryptedbytes[0:128], encryptedbytes[128:] - dk := pbkdf2.Key([]byte(fps.password), salt, 4096, 32, sha3.New512) - copy(dkr[:], dk) - - profile, err := decryptProfile(encryptedbytes, dkr) - if err == nil { - return peer.FromProfile(profile), nil - } - return nil, err - } - return nil, err -} diff --git a/storage/file_profile_store_test.go b/storage/file_profile_store_test.go deleted file mode 100644 index df298d7..0000000 --- a/storage/file_profile_store_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package storage - -import ( - "cwtch.im/cwtch/peer" - "testing" -) - -func TestFileProfileStore(t *testing.T) { - fileStore := CreateFileProfileStore(".test.json", "password") - alice := peer.NewCwtchPeer("alice") - fileStore.Save(alice) - - aliceLoaded, err := fileStore.Load() - - if err != nil { - t.Errorf("alice profile should have been loaded from store instead %v", err) - } - - if aliceLoaded.GetProfile().Name != "alice" { - t.Errorf("alice profile should have been loaded from store instead %v", aliceLoaded) - } -} diff --git a/storage/file_store.go b/storage/file_store.go new file mode 100644 index 0000000..2060112 --- /dev/null +++ b/storage/file_store.go @@ -0,0 +1,90 @@ +package storage + +import ( + "crypto/rand" + "fmt" + "git.openprivacy.ca/openprivacy/libricochet-go/log" + "golang.org/x/crypto/nacl/secretbox" + "golang.org/x/crypto/pbkdf2" + "golang.org/x/crypto/sha3" + "io" + "io/ioutil" +) + +// fileStore stores a cwtchPeer in an encrypted file +type fileStore struct { + filename string + password string +} + +// FileStore is a primitive around storing encrypted files +type FileStore interface { + Save([]byte) error + Load() ([]byte, error) +} + +// NewFileStore instantiates a fileStore given a filename and a password +func NewFileStore(filename string, password string) FileStore { + filestore := new(fileStore) + filestore.password = password + filestore.filename = filename + return filestore +} + +// Save serializes a cwtchPeer to a file +func (fps *fileStore) Save(data []byte) error { + key, salt, _ := createKey(fps.password) + encryptedbytes, err := encryptFileData(data, key) + if err != nil { + return err + } + + // the salt for the derived key is appended to the front of the file + encryptedbytes = append(salt[:], encryptedbytes...) + err = ioutil.WriteFile(fps.filename, encryptedbytes, 0600) + return err + +} + +//encryptFileData encrypts the cwtchPeer via the specified key. +func encryptFileData(data []byte, key [32]byte) ([]byte, error) { + var nonce [24]byte + + if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { + log.Errorf("Cannot read from random: %v\n", err) + return nil, err + } + + encrypted := secretbox.Seal(nonce[:], data, &nonce, &key) + return encrypted, nil +} + +//decryptFile decrypts the passed ciphertext into a cwtchPeer via the specified key. +func decryptFile(ciphertext []byte, key [32]byte) ([]byte, error) { + var decryptNonce [24]byte + copy(decryptNonce[:], ciphertext[:24]) + decrypted, ok := secretbox.Open(nil, ciphertext[24:], &decryptNonce, &key) + if ok { + return decrypted, nil + } + return nil, fmt.Errorf("Failed to decrypt") +} + +// Load instantiates a cwtchPeer from the file store +func (fps *fileStore) Load() ([]byte, error) { + encryptedbytes, err := ioutil.ReadFile(fps.filename) + if err == nil { + var dkr [32]byte + //Separate the salt from the encrypted bytes, then generate the derived key + salt, encryptedbytes := encryptedbytes[0:128], encryptedbytes[128:] + dk := pbkdf2.Key([]byte(fps.password), salt, 4096, 32, sha3.New512) + copy(dkr[:], dk) + + data, err := decryptFile(encryptedbytes, dkr) + if err == nil { + return data, nil + } + return nil, err + } + return nil, err +} diff --git a/storage/profile_store.go b/storage/profile_store.go index e0e0b4e..ffbb0c2 100644 --- a/storage/profile_store.go +++ b/storage/profile_store.go @@ -1,11 +1,86 @@ package storage import ( - "cwtch.im/cwtch/peer" + "cwtch.im/cwtch/event" + "cwtch.im/cwtch/model" + "encoding/json" ) +type profileStore struct { + fs FileStore + profile *model.Profile + eventManager *event.Manager + queue *event.Queue +} + // ProfileStore is an interface to managing the storage of Cwtch Profiles type ProfileStore interface { - Save(cwtchPeer peer.CwtchPeer) error - Load() (peer.CwtchPeer, error) + Save() error + Init(name string) + Load() error + Shutdown() + GetProfileCopy() *model.Profile +} + +// NewProfileStore returns a profile store backed by a filestore listening for events and saving them +func NewProfileStore(eventManager *event.Manager, filename string, password string) (ProfileStore, error) { + ps := &profileStore{fs: NewFileStore(filename, password), profile: nil, eventManager: eventManager} + err := ps.Load() + if err == nil { + ps.queue = event.NewEventQueue(100) + go ps.eventHandler() + + ps.eventManager.Subscribe(event.BlockPeer, ps.queue.EventChannel) + } + return ps, err +} + +func (ps *profileStore) Init(name string) { + ps.profile = model.GenerateNewProfile(name) + ps.Save() +} + +func (ps *profileStore) Save() error { + bytes, _ := json.Marshal(ps.profile) + return ps.fs.Save(bytes) +} + +// Load instantiates a cwtchPeer from the file store +func (ps *profileStore) Load() error { + decrypted, err := ps.fs.Load() + if err != nil { + return err + } + cp := new(model.Profile) + err = json.Unmarshal(decrypted, &cp) + if err == nil { + ps.profile = cp + return nil + } + return err +} + +func (ps *profileStore) GetProfileCopy() *model.Profile { + return ps.profile.GetCopy() +} + +func (ps *profileStore) eventHandler() { + for { + ev := ps.queue.Next() + switch ev.EventType { + case event.BlockPeer: + contact, exists := ps.profile.GetContact(ev.Data["Onion"]) + if exists { + contact.Blocked = true + } + default: + return + } + + ps.Save() + } +} + +func (ps *profileStore) Shutdown() { + ps.queue.Shutdown() }