First cut of profile import/export
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Sarah Jamie Lewis 2022-03-08 13:45:26 -08:00
parent 8f138b47b0
commit 5a87f835b4
7 changed files with 273 additions and 2 deletions

3
.gitignore vendored
View File

@ -27,4 +27,5 @@ tokens.db
tokens1.db tokens1.db
arch/ arch/
testing/encryptedstorage/encrypted_storage_profiles testing/encryptedstorage/encrypted_storage_profiles
testing/encryptedstorage/tordir testing/encryptedstorage/tordir
*.tar.gz

View File

@ -34,6 +34,7 @@ type application struct {
type Application interface { type Application interface {
LoadProfiles(password string) LoadProfiles(password string)
CreateTaggedPeer(name string, password string, tag string) CreateTaggedPeer(name string, password string, tag string)
ImportProfile(exportedCwtchFile string, password string) (peer.CwtchPeer, error)
DeletePeer(onion string, currentPassword string) DeletePeer(onion string, currentPassword string)
AddPeerPlugin(onion string, pluginID plugins.PluginID) AddPeerPlugin(onion string, pluginID plugins.PluginID)
LaunchPeers() LaunchPeers()
@ -125,6 +126,15 @@ func (app *application) AddPeerPlugin(onion string, pluginID plugins.PluginID) {
app.AddPlugin(onion, pluginID, app.eventBuses[onion], app.acn) 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 // 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) { func (app *application) LoadProfiles(password string) {
count := 0 count := 0

View File

@ -72,6 +72,12 @@ type cwtchPeer struct {
eventBus event.Manager 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() { func (cp *cwtchPeer) Delete() {
cp.mutex.Lock() cp.mutex.Lock()
defer cp.mutex.Unlock() defer cp.mutex.Unlock()

View File

@ -1,6 +1,8 @@
package peer package peer
import ( import (
"archive/tar"
"compress/gzip"
"cwtch.im/cwtch/event" "cwtch.im/cwtch/event"
"cwtch.im/cwtch/model" "cwtch.im/cwtch/model"
"cwtch.im/cwtch/model/attr" "cwtch.im/cwtch/model/attr"
@ -8,7 +10,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.openprivacy.ca/openprivacy/log" "git.openprivacy.ca/openprivacy/log"
"io"
"os" "os"
"path"
"path/filepath"
"strings"
) )
// StorageKeyType is an interface wrapper around storage key types // 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)) _, err := cps.db.Exec(fmt.Sprintf(`PRAGMA rekey="x'%x'";`, newkey))
return err 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
}

View File

@ -116,5 +116,6 @@ type CwtchPeer interface {
ShareFile(fileKey string, serializedManifest string) ShareFile(fileKey string, serializedManifest string)
CheckPassword(password string) bool CheckPassword(password string) bool
ChangePassword(oldpassword string, newpassword string, newpasswordAgain string) error ChangePassword(oldpassword string, newpassword string, newpasswordAgain string) error
Export(file string) error
Delete() Delete()
} }

View File

@ -1,6 +1,8 @@
package peer package peer
import ( import (
"archive/tar"
"compress/gzip"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"errors" "errors"
@ -13,11 +15,13 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
) )
const versionFile = "VERSION" const versionFile = "VERSION"
const version = "2" const version = "2"
const saltFile = "SALT" const saltFile = "SALT"
const dbFile = "db"
// CreateKeySalt derives a key and salt from a password: returns key, salt, err // CreateKeySalt derives a key and salt from a password: returns key, salt, err
func CreateKeySalt(password string) ([32]byte, [128]byte, error) { func CreateKeySalt(password string) ([32]byte, [128]byte, error) {
@ -165,3 +169,149 @@ func FromEncryptedDatabase(profileDirectory string, password string) (CwtchPeer,
} }
return FromEncryptedStorage(cps), nil 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
}

View File

@ -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) t.Fatalf("unexpected issue when fetching all of alices conversations. Expected 1 got : %v %v", conversations, err)
} }
aliceOnion := alice.GetOnion()
alice.PeerWithOnion(bob.GetOnion()) alice.PeerWithOnion(bob.GetOnion())
time.Sleep(time.Second * 40) time.Sleep(time.Second * 40)
@ -94,7 +95,12 @@ func TestEncryptedStorage(t *testing.T) {
time.Sleep(time.Second * 30) 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) body, _, err := bob.GetChannelMessage(ci.ID, 0, 1)
if body != "Hello Bob" || err != nil { if body != "Hello Bob" || err != nil {
t.Fatalf("unexpected message in conversation channel %v %v", body, err) 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) 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() app.Shutdown()