diff --git a/internal/app/migration.go b/internal/app/migration.go index 2a91562e..f4313688 100644 --- a/internal/app/migration.go +++ b/internal/app/migration.go @@ -32,6 +32,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/vault" + "github.com/ProtonMail/proton-bridge/v3/pkg/algo" "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" "github.com/allan-simon/go-singleinstance" "github.com/hashicorp/go-multierror" @@ -120,26 +121,52 @@ func migrateOldAccounts(locations *locations.Locations, v *vault.Vault) error { return fmt.Errorf("failed to create credentials store: %w", err) } + var migrationErrors error + for _, userID := range users { logrus.WithField("userID", userID).Info("Migrating account") - - creds, err := store.Get(userID) - if err != nil { - return fmt.Errorf("failed to get user: %w", err) + if err := migrateOldAccount(userID, store, v); err != nil { + migrationErrors = multierror.Append(migrationErrors, err) } + } - authUID, authRef, err := creds.SplitAPIToken() - if err != nil { - return fmt.Errorf("failed to split api token: %w", err) - } + return migrationErrors +} - user, err := v.AddUser(creds.UserID, creds.EmailList()[0], authUID, authRef, creds.MailboxPassword) - if err != nil { - return fmt.Errorf("failed to add user: %w", err) - } +func migrateOldAccount(userID string, store *credentials.Store, v *vault.Vault) error { + creds, err := store.Get(userID) + if err != nil { + return fmt.Errorf("failed to get user %q: %w", userID, err) + } + authUID, authRef, err := creds.SplitAPIToken() + if err != nil { + return fmt.Errorf("failed to split api token for user %q: %w", userID, err) + } + + user, err := v.AddUser(creds.UserID, creds.EmailList()[0], authUID, authRef, creds.MailboxPassword) + if err != nil { + return fmt.Errorf("failed to add user %q: %w", userID, err) + } + + defer func() { if err := user.Close(); err != nil { - return fmt.Errorf("failed to close user: %w", err) + logrus.WithField("userID", userID).WithError(err).Error("Failed to close vault user after migration") + } + }() + + dec, err := algo.B64RawDecode([]byte(creds.BridgePassword)) + if err != nil { + return fmt.Errorf("failed to decode bridge password for user %q: %w", userID, err) + } + + if err := user.SetBridgePass(dec); err != nil { + return fmt.Errorf("failed to set bridge password to user %q: %w", userID, err) + } + + if !creds.IsCombinedAddressMode { + if err := user.SetAddressMode(vault.SplitMode); err != nil { + return fmt.Errorf("failed to set split address mode to user %q: %w", userID, err) } } diff --git a/internal/app/migration_test.go b/internal/app/migration_test.go index 3b0c432d..bd7ac4b9 100644 --- a/internal/app/migration_test.go +++ b/internal/app/migration_test.go @@ -25,11 +25,16 @@ import ( "runtime" "testing" + "github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/ProtonMail/proton-bridge/v3/internal/bridge" "github.com/ProtonMail/proton-bridge/v3/internal/cookies" + "github.com/ProtonMail/proton-bridge/v3/internal/legacy/credentials" "github.com/ProtonMail/proton-bridge/v3/internal/locations" "github.com/ProtonMail/proton-bridge/v3/internal/updater" "github.com/ProtonMail/proton-bridge/v3/internal/vault" + "github.com/ProtonMail/proton-bridge/v3/pkg/algo" + "github.com/ProtonMail/proton-bridge/v3/pkg/keychain" + dockerCredentials "github.com/docker/docker-credential-helpers/credentials" "github.com/stretchr/testify/require" ) @@ -84,46 +89,111 @@ func TestMigratePrefsToVault(t *testing.T) { } func TestKeychainMigration(t *testing.T) { - // migration needed only for linux + // Migration tested only for linux. if runtime.GOOS != "linux" { return } tmpDir := t.TempDir() - os.Setenv("XDG_CONFIG_HOME", tmpDir) - oldCacheDir := filepath.Join(tmpDir, "protonmail", "bridge") - require.NoError(t, os.MkdirAll(oldCacheDir, 0o700)) + // Prepare for keychain migration test + { + require.NoError(t, os.Setenv("XDG_CONFIG_HOME", tmpDir)) + oldCacheDir := filepath.Join(tmpDir, "protonmail", "bridge") + require.NoError(t, os.MkdirAll(oldCacheDir, 0o700)) - oldPrefs, err := os.ReadFile(filepath.Join("testdata", "prefs.json")) - require.NoError(t, err) + oldPrefs, err := os.ReadFile(filepath.Join("testdata", "prefs.json")) + require.NoError(t, err) - require.NoError(t, os.WriteFile( - filepath.Join(oldCacheDir, "prefs.json"), - oldPrefs, 0o600, - )) + require.NoError(t, os.WriteFile( + filepath.Join(oldCacheDir, "prefs.json"), + oldPrefs, 0o600, + )) + } locations := locations.New(bridge.NewTestLocationsProvider(tmpDir), "config-name") - settingsFolder, err := locations.ProvideSettingsPath() require.NoError(t, err) + // Check that there is nothing yet keychainName, err := vault.GetHelper(settingsFolder) require.NoError(t, err) require.Equal(t, "", keychainName) + // Check migration require.NoError(t, migrateKeychainHelper(locations)) - keychainName, err = vault.GetHelper(settingsFolder) require.NoError(t, err) require.Equal(t, "secret-service", keychainName) + // Change the migrated value require.NoError(t, vault.SetHelper(settingsFolder, "different")) - // Calling migration again will not overwrite + // Calling migration again will not overwrite existing prefs require.NoError(t, migrateKeychainHelper(locations)) keychainName, err = vault.GetHelper(settingsFolder) require.NoError(t, err) require.Equal(t, "different", keychainName) - +} + +func TestUserMigration(t *testing.T) { + keychainHelper := keychain.NewTestHelper() + + keychain.Helpers["mock"] = func(string) (dockerCredentials.Helper, error) { return keychainHelper, nil } + + kc, err := keychain.NewKeychain("mock", "bridge") + require.NoError(t, err) + + require.NoError(t, kc.Put("brokenID", "broken")) + require.NoError(t, kc.Put( + "emptyID", + (&credentials.Credentials{}).Marshal(), + )) + + wantUID := "uidtoken" + wantRefresh := "refreshtoken" + + wantCredentials := credentials.Credentials{ + UserID: "validID", + Name: "user@pm.me", + Emails: "user@pm.me;alias@pm.me", + APIToken: wantUID + ":" + wantRefresh, + MailboxPassword: []byte("secret"), + BridgePassword: "bElu2Q1Vusy28J3Wf56cIg", + Version: "v2.3.X", + Timestamp: 100, + IsCombinedAddressMode: true, + } + require.NoError(t, kc.Put( + wantCredentials.UserID, + wantCredentials.Marshal(), + )) + + tmpDir := t.TempDir() + locations := locations.New(bridge.NewTestLocationsProvider(tmpDir), "config-name") + settingsFolder, err := locations.ProvideSettingsPath() + require.NoError(t, err) + require.NoError(t, vault.SetHelper(settingsFolder, "mock")) + + token, err := crypto.RandomToken(32) + require.NoError(t, err) + + v, corrupt, err := vault.New(settingsFolder, settingsFolder, token) + require.NoError(t, err) + require.False(t, corrupt) + + require.NoError(t, migrateOldAccounts(locations, v)) + require.Equal(t, []string{wantCredentials.UserID}, v.GetUserIDs()) + + require.NoError(t, v.GetUser(wantCredentials.UserID, func(u *vault.User) { + require.Equal(t, wantCredentials.UserID, u.UserID()) + require.Equal(t, wantUID, u.AuthUID()) + require.Equal(t, wantRefresh, u.AuthRef()) + require.Equal(t, wantCredentials.MailboxPassword, u.KeyPass()) + require.Equal(t, + []byte(wantCredentials.BridgePassword), + algo.B64RawEncode(u.BridgePass()), + ) + require.Equal(t, vault.CombinedMode, u.AddressMode()) + })) } diff --git a/internal/user/sync_build.go b/internal/user/sync_build.go index ef5032a7..f5052f67 100644 --- a/internal/user/sync_build.go +++ b/internal/user/sync_build.go @@ -25,6 +25,7 @@ import ( "github.com/ProtonMail/gluon/imap" "github.com/ProtonMail/go-proton-api" "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ProtonMail/proton-bridge/v3/pkg/algo" "github.com/ProtonMail/proton-bridge/v3/pkg/message" "github.com/bradenaw/juniper/xslices" ) @@ -135,7 +136,7 @@ func newFailedMessageLiteral( "Error": syncErr.Error(), }); err != nil { panic(err) - } else if _, err := buf.Write(lineWrap(b64Encode(b))); err != nil { + } else if _, err := buf.Write(lineWrap(algo.B64Encode(b))); err != nil { panic(err) } diff --git a/internal/user/types.go b/internal/user/types.go index acd3fb3c..5cefc62a 100644 --- a/internal/user/types.go +++ b/internal/user/types.go @@ -18,7 +18,6 @@ package user import ( - "encoding/base64" "fmt" "reflect" "strings" @@ -58,36 +57,6 @@ func groupBy[Key comparable, Value any](items []Value, key func(Value) Key) map[ return groups } -// b64Encode returns the base64 encoding of the given byte slice. -func b64Encode(b []byte) []byte { - enc := make([]byte, base64.StdEncoding.EncodedLen(len(b))) - - base64.StdEncoding.Encode(enc, b) - - return enc -} - -// b64RawEncode returns the base64 encoding of the given byte slice. -func b64RawEncode(b []byte) []byte { - enc := make([]byte, base64.RawURLEncoding.EncodedLen(len(b))) - - base64.RawURLEncoding.Encode(enc, b) - - return enc -} - -// b64RawDecode returns the bytes represented by the base64 encoding of the given byte slice. -func b64RawDecode(b []byte) ([]byte, error) { - dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(b))) - - n, err := base64.RawURLEncoding.Decode(dec, b) - if err != nil { - return nil, err - } - - return dec[:n], nil -} - // getAddrID returns the address ID for the given email address. func getAddrID(apiAddrs map[string]proton.Address, email string) (string, error) { for _, addr := range apiAddrs { diff --git a/internal/user/user.go b/internal/user/user.go index 42755c66..58b24c19 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -36,6 +36,7 @@ import ( "github.com/ProtonMail/proton-bridge/v3/internal/logging" "github.com/ProtonMail/proton-bridge/v3/internal/safe" "github.com/ProtonMail/proton-bridge/v3/internal/vault" + "github.com/ProtonMail/proton-bridge/v3/pkg/algo" "github.com/bradenaw/juniper/xslices" "github.com/go-resty/resty/v2" "github.com/sirupsen/logrus" @@ -355,7 +356,7 @@ func (user *User) GluonKey() []byte { // BridgePass returns the user's bridge password, used for authentication over SMTP and IMAP. func (user *User) BridgePass() []byte { - return b64RawEncode(user.vault.BridgePass()) + return algo.B64RawEncode(user.vault.BridgePass()) } // UsedSpace returns the total space used by the user on the API. @@ -431,7 +432,7 @@ func (user *User) CheckAuth(email string, password []byte) (string, error) { panic("your wish is my command.. I crash") } - dec, err := b64RawDecode(password) + dec, err := algo.B64RawDecode(password) if err != nil { return "", fmt.Errorf("failed to decode password: %w", err) } diff --git a/internal/vault/helper.go b/internal/vault/helper.go index 29302b88..fffc2d48 100644 --- a/internal/vault/helper.go +++ b/internal/vault/helper.go @@ -34,13 +34,11 @@ func getKeychainPrefPath(vaultDir string) string { } func GetHelper(vaultDir string) (string, error) { - filePath := getKeychainPrefPath(vaultDir) - - if _, err := os.Stat(filePath); errors.Is(err, fs.ErrNotExist) { + if _, err := os.Stat(getKeychainPrefPath(vaultDir)); errors.Is(err, fs.ErrNotExist) { return "", nil } - b, err := os.ReadFile(filePath) + b, err := os.ReadFile(getKeychainPrefPath(vaultDir)) if err != nil { return "", err } diff --git a/internal/vault/types_user.go b/internal/vault/types_user.go index b58c76c2..d2c20338 100644 --- a/internal/vault/types_user.go +++ b/internal/vault/types_user.go @@ -28,7 +28,7 @@ type UserData struct { GluonKey []byte GluonIDs map[string]string UIDValidity map[string]imap.UID - BridgePass []byte + BridgePass []byte // raw token represented as byte slice (needs to be encoded) AddressMode AddressMode AuthUID string diff --git a/internal/vault/user.go b/internal/vault/user.go index 50867f42..6bafb63c 100644 --- a/internal/vault/user.go +++ b/internal/vault/user.go @@ -99,11 +99,18 @@ func (user *User) SetAddressMode(mode AddressMode) error { }) } -// BridgePass returns the user's bridge password (unencoded). +// BridgePass returns the user's bridge password as raw token bytes (unencoded). func (user *User) BridgePass() []byte { return user.vault.getUser(user.userID).BridgePass } +// SetBridgePass saves bridge password as raw token bytes (unecoded). +func (user *User) SetBridgePass(newPass []byte) error { + return user.vault.modUser(user.userID, func(data *UserData) { + data.BridgePass = newPass + }) +} + // AuthUID returns the user's auth UID. func (user *User) AuthUID() string { return user.vault.getUser(user.userID).AuthUID diff --git a/pkg/algo/encode.go b/pkg/algo/encode.go new file mode 100644 index 00000000..34a565d1 --- /dev/null +++ b/pkg/algo/encode.go @@ -0,0 +1,50 @@ +// Copyright (c) 2022 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package algo + +import "encoding/base64" + +// B64Encode returns the base64 encoding of the given byte slice. +func B64Encode(b []byte) []byte { + enc := make([]byte, base64.StdEncoding.EncodedLen(len(b))) + + base64.StdEncoding.Encode(enc, b) + + return enc +} + +// B64RawEncode returns the base64 encoding of the given byte slice. +func B64RawEncode(b []byte) []byte { + enc := make([]byte, base64.RawURLEncoding.EncodedLen(len(b))) + + base64.RawURLEncoding.Encode(enc, b) + + return enc +} + +// B64RawDecode returns the bytes represented by the base64 encoding of the given byte slice. +func B64RawDecode(b []byte) ([]byte, error) { + dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(b))) + + n, err := base64.RawURLEncoding.Decode(dec, b) + if err != nil { + return nil, err + } + + return dec[:n], nil +} diff --git a/pkg/keychain/keychain_test.go b/pkg/keychain/keychain_test.go index d7eebe09..8e024b71 100644 --- a/pkg/keychain/keychain_test.go +++ b/pkg/keychain/keychain_test.go @@ -21,7 +21,6 @@ import ( "encoding/base64" "testing" - "github.com/docker/docker-credential-helpers/credentials" "github.com/stretchr/testify/require" ) @@ -33,7 +32,7 @@ var testData = map[string]string{ //nolint:gochecknoglobals } func TestInsertReadRemove(t *testing.T) { - keychain := newKeychain(newTestHelper(), hostURL("bridge")) + keychain := newKeychain(NewTestHelper(), hostURL("bridge")) for id, secret := range testData { expectedList, _ := keychain.List() @@ -115,35 +114,3 @@ func TestInsertReadRemove(t *testing.T) { require.NotContains(t, actualList, id) } } - -type testHelper map[string]*credentials.Credentials - -func newTestHelper() testHelper { - return make(testHelper) -} - -func (h testHelper) Add(creds *credentials.Credentials) error { - h[creds.ServerURL] = creds - return nil -} - -func (h testHelper) Delete(url string) error { - delete(h, url) - return nil -} - -func (h testHelper) Get(url string) (string, string, error) { - creds := h[url] - - return creds.Username, creds.Secret, nil -} - -func (h testHelper) List() (map[string]string, error) { - list := make(map[string]string) - - for url, creds := range h { - list[url] = creds.Username - } - - return list, nil -} diff --git a/pkg/keychain/test_helper.go b/pkg/keychain/test_helper.go new file mode 100644 index 00000000..28eaf760 --- /dev/null +++ b/pkg/keychain/test_helper.go @@ -0,0 +1,52 @@ +// Copyright (c) 2022 Proton AG +// +// This file is part of Proton Mail Bridge. +// +// Proton Mail Bridge is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Proton Mail Bridge is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Proton Mail Bridge. If not, see . + +package keychain + +import "github.com/docker/docker-credential-helpers/credentials" + +type TestHelper map[string]*credentials.Credentials + +func NewTestHelper() TestHelper { + return make(TestHelper) +} + +func (h TestHelper) Add(creds *credentials.Credentials) error { + h[creds.ServerURL] = creds + return nil +} + +func (h TestHelper) Delete(url string) error { + delete(h, url) + return nil +} + +func (h TestHelper) Get(url string) (string, string, error) { + creds := h[url] + + return creds.Username, creds.Secret, nil +} + +func (h TestHelper) List() (map[string]string, error) { + list := make(map[string]string) + + for url, creds := range h { + list[url] = creds.Username + } + + return list, nil +}