diff --git a/.gitignore b/.gitignore index b074e0f..7a57b32 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ tokens.db tokens1.db arch/ testing/encryptedstorage/encrypted_storage_profiles -testing/encryptedstorage/tordir \ No newline at end of file +testing/encryptedstorage/tordir +*.tar.gz \ No newline at end of file diff --git a/app/app.go b/app/app.go index d892aea..b9947ff 100644 --- a/app/app.go +++ b/app/app.go @@ -34,6 +34,7 @@ type application struct { type Application interface { LoadProfiles(password string) CreateTaggedPeer(name string, password string, tag string) + ImportProfile(exportedCwtchFile string, password string) (peer.CwtchPeer, error) DeletePeer(onion string, currentPassword string) AddPeerPlugin(onion string, pluginID plugins.PluginID) LaunchPeers() @@ -125,6 +126,15 @@ func (app *application) AddPeerPlugin(onion string, pluginID plugins.PluginID) { app.AddPlugin(onion, pluginID, app.eventBuses[onion], app.acn) } +func (app *application) ImportProfile(exportedCwtchFile string, password string) (peer.CwtchPeer, error) { + profileDirectory := path.Join(app.directory, "profiles") + profile, err := peer.ImportProfile(exportedCwtchFile, profileDirectory, password) + if err == nil { + app.installProfile(profile) + } + return profile, err +} + // LoadProfiles takes a password and attempts to load any profiles it can from storage with it and create Peers for them func (app *application) LoadProfiles(password string) { count := 0 diff --git a/peer/cwtch_peer.go b/peer/cwtch_peer.go index 1249551..2f0b9e7 100644 --- a/peer/cwtch_peer.go +++ b/peer/cwtch_peer.go @@ -72,6 +72,12 @@ type cwtchPeer struct { eventBus event.Manager } +func (cp *cwtchPeer) Export(file string) error { + cp.mutex.Lock() + defer cp.mutex.Unlock() + return cp.storage.Export(file) +} + func (cp *cwtchPeer) Delete() { cp.mutex.Lock() defer cp.mutex.Unlock() diff --git a/peer/cwtchprofilestorage.go b/peer/cwtchprofilestorage.go index f38d3fc..00ca626 100644 --- a/peer/cwtchprofilestorage.go +++ b/peer/cwtchprofilestorage.go @@ -1,6 +1,8 @@ package peer import ( + "archive/tar" + "compress/gzip" "cwtch.im/cwtch/event" "cwtch.im/cwtch/model" "cwtch.im/cwtch/model/attr" @@ -8,7 +10,11 @@ import ( "errors" "fmt" "git.openprivacy.ca/openprivacy/log" + "io" "os" + "path" + "path/filepath" + "strings" ) // StorageKeyType is an interface wrapper around storage key types @@ -771,3 +777,75 @@ func (cps *CwtchProfileStorage) Rekey(newkey [32]byte) error { _, err := cps.db.Exec(fmt.Sprintf(`PRAGMA rekey="x'%x'";`, newkey)) return err } + +// Export takes in a file name and creates an exported cwtch profile file (which in reality is a compressed tarball). +func (cps *CwtchProfileStorage) Export(filename string) error { + profileDB := filepath.Join(cps.ProfileDirectory, dbFile) + profileSalt := filepath.Join(cps.ProfileDirectory, saltFile) + profileVersion := filepath.Join(cps.ProfileDirectory, versionFile) + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("could not create tarball file '%s', got error '%s'", filename, err.Error()) + } + defer file.Close() + + gzipWriter := gzip.NewWriter(file) + defer gzipWriter.Close() + + tarWriter := tar.NewWriter(gzipWriter) + defer tarWriter.Close() + + // We need to know the base directory so we can import it later (and prevent duplicates)... + profilePath := path.Base(cps.ProfileDirectory) + + err = addFileToTarWriter(profilePath, profileDB, tarWriter) + if err != nil { + return fmt.Errorf("could not add file '%s', to tarball, got error '%s'", profileDB, err.Error()) + } + + err = addFileToTarWriter(profilePath, profileSalt, tarWriter) + if err != nil { + return fmt.Errorf("could not add file '%s', to tarball, got error '%s'", profileDB, err.Error()) + } + + err = addFileToTarWriter(profilePath, profileVersion, tarWriter) + if err != nil { + return fmt.Errorf("could not add file '%s', to tarball, got error '%s'", profileDB, err.Error()) + } + return nil +} + +func addFileToTarWriter(profilePath string, filePath string, tarWriter *tar.Writer) error { + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("could not open file '%s', got error '%s'", filePath, err.Error()) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("could not get stat for file '%s', got error '%s'", filePath, err.Error()) + } + + header := &tar.Header{ + // Note: we are using strings.Join here deliberately so that we can import the profile + // in a cross platform way (e.g. using filepath here would result in different names on Windows v.s Linux) + Name: strings.Join([]string{profilePath, stat.Name()}, "/"), + Size: stat.Size(), + Mode: int64(stat.Mode()), + ModTime: stat.ModTime(), + } + + err = tarWriter.WriteHeader(header) + if err != nil { + return fmt.Errorf("could not write header for file '%s', got error '%s'", filePath, err.Error()) + } + + _, err = io.Copy(tarWriter, file) + if err != nil { + return fmt.Errorf("could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error()) + } + + return nil +} diff --git a/peer/profile_interface.go b/peer/profile_interface.go index 5266229..5e83175 100644 --- a/peer/profile_interface.go +++ b/peer/profile_interface.go @@ -116,5 +116,6 @@ type CwtchPeer interface { ShareFile(fileKey string, serializedManifest string) CheckPassword(password string) bool ChangePassword(oldpassword string, newpassword string, newpasswordAgain string) error + Export(file string) error Delete() } diff --git a/peer/storage.go b/peer/storage.go index 3d59a7f..89bcf76 100644 --- a/peer/storage.go +++ b/peer/storage.go @@ -1,6 +1,8 @@ package peer import ( + "archive/tar" + "compress/gzip" "crypto/rand" "database/sql" "errors" @@ -13,11 +15,13 @@ import ( "os" "path" "path/filepath" + "strings" ) const versionFile = "VERSION" const version = "2" const saltFile = "SALT" +const dbFile = "db" // CreateKeySalt derives a key and salt from a password: returns key, salt, err func CreateKeySalt(password string) ([32]byte, [128]byte, error) { @@ -165,3 +169,149 @@ func FromEncryptedDatabase(profileDirectory string, password string) (CwtchPeer, } return FromEncryptedStorage(cps), nil } + +func ImportProfile(exportedCwtchFile string, profilesDir string, password string) (CwtchPeer, error) { + profileID, err := checkCwtchProfileBackupFile(exportedCwtchFile) + if profileID == "" || err != nil { + log.Errorf("%s is an invalid cwtch backup file: %s", profileID, err) + return nil, err + } + log.Infof("%s is a valid cwtch backup file", profileID) + + profileDBFile := filepath.Join(profilesDir, profileID, dbFile) + log.Debugf("checking %v", profileDBFile) + if _, err := os.Stat(profileDBFile); errors.Is(err, os.ErrNotExist) { + // backup is valid and the profile hasn't been imported yet, time to extract and check the password + profileDir := filepath.Join(profilesDir, profileID) + os.MkdirAll(profileDir, 0700) + err := importCwtchProfileBackupFile(exportedCwtchFile, profilesDir) + if err == nil { + profile, err := FromEncryptedDatabase(profileDir, password) + if err == nil { + return profile, err + } + // Otherwise purge + os.RemoveAll(filepath.Join(profilesDir, profileDir)) + return nil, err + } + return nil, err + } + return nil, fmt.Errorf("%s is already a profile for this app", profileID) +} + +func checkCwtchProfileBackupFile(srcFile string) (string, error) { + f, err := os.Open(srcFile) + if err != nil { + return "", err + } + defer f.Close() + + gzf, err := gzip.NewReader(f) + if err != nil { + return "", err + } + + tarReader := tar.NewReader(gzf) + + profileName := "" + + for { + header, err := tarReader.Next() + + if err == io.EOF { + break + } + + if err != nil { + return "", err + } + + switch header.Typeflag { + case tar.TypeDir: + return "", errors.New("invalid cwtch backup file") + case tar.TypeReg: + parts := strings.Split(header.Name, "/") + if len(parts) != 2 { + return "", errors.New("invalid header name") + } + dir := parts[0] + profileFileType := parts[1] + + if profileName == "" { + profileName = dir + } + if dir != profileName { + return "", errors.New("invalid cwtch backup file") + } + + if profileFileType != dbFile && profileFileType != saltFile && profileFileType != versionFile { + return "", errors.New("invalid cwtch backup file") + } + default: + return "", errors.New("invalid cwtch backup file") + } + } + return profileName, nil +} + +func importCwtchProfileBackupFile(srcFile string, profilesDir string) error { + f, err := os.Open(srcFile) + if err != nil { + return err + } + defer f.Close() + + gzf, err := gzip.NewReader(f) + if err != nil { + return err + } + + tarReader := tar.NewReader(gzf) + + profileName := "" + + for { + header, err := tarReader.Next() + + if err == io.EOF { + break + } + + if err != nil { + return err + } + + switch header.Typeflag { + case tar.TypeDir: + return errors.New("invalid cwtch backup file") + case tar.TypeReg: + // using split here because we deliberately construct these paths in a cross-platform consistent way + parts := strings.Split(header.Name, "/") + if len(parts) != 2 { + return errors.New("invalid header name") + } + dir := parts[0] + base := parts[1] + if profileName == "" { + profileName = dir + } + + if dir != profileName { + return errors.New("invalid cwtch backup file") + } + + // here we use filepath.Join to construct a valid directory path + outFile, err := os.Create(filepath.Join(profilesDir, dir, base)) + if err != nil { + return fmt.Errorf("error importing cwtch profile file: %s", err) + } + defer outFile.Close() + if _, err := io.Copy(outFile, tarReader); err != nil { + return fmt.Errorf("error importing cwtch profile file: %s", err) + } + default: + return errors.New("invalid cwtch backup file") + } + } + return nil +} diff --git a/testing/encryptedstorage/encrypted_storage_integration_test.go b/testing/encryptedstorage/encrypted_storage_integration_test.go index b9ce2ec..8d25d19 100644 --- a/testing/encryptedstorage/encrypted_storage_integration_test.go +++ b/testing/encryptedstorage/encrypted_storage_integration_test.go @@ -79,6 +79,7 @@ func TestEncryptedStorage(t *testing.T) { t.Fatalf("unexpected issue when fetching all of alices conversations. Expected 1 got : %v %v", conversations, err) } + aliceOnion := alice.GetOnion() alice.PeerWithOnion(bob.GetOnion()) time.Sleep(time.Second * 40) @@ -94,7 +95,12 @@ func TestEncryptedStorage(t *testing.T) { time.Sleep(time.Second * 30) - ci, _ := bob.FetchConversationInfo(alice.GetOnion()) + ci, err := bob.FetchConversationInfo(alice.GetOnion()) + for err != nil { + time.Sleep(time.Second * 5) + ci, err = bob.FetchConversationInfo(alice.GetOnion()) + } + body, _, err := bob.GetChannelMessage(ci.ID, 0, 1) if body != "Hello Bob" || err != nil { t.Fatalf("unexpected message in conversation channel %v %v", body, err) @@ -126,6 +132,25 @@ func TestEncryptedStorage(t *testing.T) { t.Fatalf("expeced GetMostRecentMessages to return 1, instead returned: %v %v", len(messages), messages) } + err = alice.Export("alice.tar.gz") + if err != nil { + t.Fatalf("could not export profile: %v", err) + } + + _, err = app.ImportProfile("alice.tar.gz", "password") + if err == nil { + t.Fatal("profile is already imported...this should fail") + } + + app.DeletePeer(alice.GetOnion(), "password") + alice, err = app.ImportProfile("alice.tar.gz", "password") + if err != nil { + t.Fatalf("profile should have successfully imported: %s", err) + } + + if alice.GetOnion() != aliceOnion { + t.Fatalf("profile is not Alice...%s != %s", aliceOnion, alice.GetOnion()) + } app.Shutdown()