diff --git a/.golangci.yml b/.golangci.yml index 5b406a47..9ac51197 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,6 +15,9 @@ issues: - at least one file in a package should have a package comment # Package comments. - "package-comments: should have a package comment" + # Migration uses underscores to make versions clearer. + - "var-naming: don't use underscores in Go names" + - "ST1003: should not use underscores in Go names" exclude-rules: - path: _test\.go diff --git a/internal/vault/migrate.go b/internal/vault/migrate.go new file mode 100644 index 00000000..f7639d0e --- /dev/null +++ b/internal/vault/migrate.go @@ -0,0 +1,47 @@ +// 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 vault + +import "fmt" + +type Version int + +const ( + v2_3_x Version = iota + v2_4_x + v2_5_x + + Current = v2_5_x +) + +// upgrade migrates the vault from the given version to the next version. +func upgrade(v Version, b []byte) ([]byte, error) { + switch v { + case v2_3_x: + return upgrade_2_3_x(b) + + case v2_4_x: + return upgrade_2_4_x(b) + + case Current: + return nil, fmt.Errorf("already at current version %d", Current) + + default: + return nil, fmt.Errorf("unknown version %d", v) + } +} diff --git a/internal/vault/migrate_test.go b/internal/vault/migrate_test.go new file mode 100644 index 00000000..0934faa0 --- /dev/null +++ b/internal/vault/migrate_test.go @@ -0,0 +1,96 @@ +// 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 vault + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "os" + "path/filepath" + "testing" + + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/stretchr/testify/require" + "github.com/vmihailenco/msgpack/v5" +) + +func TestMigrate(t *testing.T) { + dir := t.TempDir() + + // Create a v2.3.x vault. + b := newLegacyVault(t, []byte("my secret key"), v2_3_x, Data_2_3_x{ + Settings: Settings_2_3_x{ + GluonDir: "v2.3.x-gluon-dir", + IMAPPort: "1234", // string in v2.3.x, current version uses int + SMTPPort: "5678", // string in v2.3.x, current version uses int + }, + Users: []UserData_2_3_x{{ + ID: "user-id", // called "ID" in v2.3.x, current version has "UserID" + Name: "user-name", // called "Name" in v2.3.x, current version has "Username" + GluonKey: []byte("gluon-key"), // []byte in v2.3.x and current version, string in v2.4.x (intermediate) + SplitMode: true, // bool in v2.3.x and v2.4.x, enum in current version + }}, + }) + + // Write the vault to disk. + require.NoError(t, os.WriteFile(filepath.Join(dir, "vault.enc"), b, 0600)) + + // Migrate the vault. + s, corrupt, err := New(dir, "default-gluon-dir", []byte("my secret key")) + require.NoError(t, err) + require.False(t, corrupt) + + // Check the migrated vault. + require.Equal(t, "v2.3.x-gluon-dir", s.GetGluonDir()) + require.Equal(t, 1234, s.GetIMAPPort()) + require.Equal(t, 5678, s.GetSMTPPort()) + + // The user should be migrated. + userIDs := s.GetUserIDs() + require.Len(t, userIDs, 1) + + // The migrated user should be correct. + require.NoError(t, s.GetUser("user-id", func(user *User) { + require.Equal(t, "user-id", user.UserID()) + require.Equal(t, "user-name", user.Username()) + require.Equal(t, []byte("gluon-key"), user.GluonKey()) + require.Equal(t, SplitMode, user.AddressMode()) + })) +} + +func newLegacyVault[T any](t *testing.T, key []byte, version Version, data T) []byte { + hash256 := sha256.Sum256(key) + + aes, err := aes.NewCipher(hash256[:]) + require.NoError(t, err) + + gcm, err := cipher.NewGCM(aes) + require.NoError(t, err) + + b, err := msgpack.Marshal(data) + require.NoError(t, err) + + dec, err := msgpack.Marshal(File{Version: version, Data: b}) + require.NoError(t, err) + + nonce, err := crypto.RandomToken(gcm.NonceSize()) + require.NoError(t, err) + + return gcm.Seal(nonce, nonce, dec, nil) +} diff --git a/internal/vault/migrate_v2.3.x.go b/internal/vault/migrate_v2.3.x.go new file mode 100644 index 00000000..c88eaa9f --- /dev/null +++ b/internal/vault/migrate_v2.3.x.go @@ -0,0 +1,90 @@ +// 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 vault + +import ( + "strconv" + + "github.com/bradenaw/juniper/xslices" + "github.com/vmihailenco/msgpack/v5" +) + +type Data_2_3_x struct { + Settings Settings_2_3_x + Users []UserData_2_3_x +} + +func (data Data_2_3_x) migrate() Data_2_4_x { + return Data_2_4_x{ + Settings: data.Settings.migrate(), + Users: xslices.Map(data.Users, func(user UserData_2_3_x) UserData_2_4_x { return user.migrate() }), + } +} + +type Settings_2_3_x struct { + GluonDir string + + IMAPPort string + SMTPPort string +} + +func (settings Settings_2_3_x) migrate() Settings_2_4_x { + imapPort, err := strconv.Atoi(settings.IMAPPort) + if err != nil { + panic(err) + } + + smtpPort, err := strconv.Atoi(settings.SMTPPort) + if err != nil { + panic(err) + } + + return Settings_2_4_x{ + GluonDir: settings.GluonDir, + + IMAPPort: imapPort, + SMTPPort: smtpPort, + } +} + +type UserData_2_3_x struct { + ID string + Name string + + GluonKey []byte + SplitMode bool +} + +func (user UserData_2_3_x) migrate() UserData_2_4_x { + return UserData_2_4_x{ + UserID: user.ID, + Username: user.Name, + GluonKey: string(user.GluonKey), + SplitMode: user.SplitMode, + } +} + +func upgrade_2_3_x(b []byte) ([]byte, error) { + var old Data_2_3_x + + if err := msgpack.Unmarshal(b, &old); err != nil { + return nil, err + } + + return msgpack.Marshal(old.migrate()) +} diff --git a/internal/vault/migrate_v2.4.x.go b/internal/vault/migrate_v2.4.x.go new file mode 100644 index 00000000..4f50a973 --- /dev/null +++ b/internal/vault/migrate_v2.4.x.go @@ -0,0 +1,86 @@ +// 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 vault + +import ( + "github.com/bradenaw/juniper/xslices" + "github.com/vmihailenco/msgpack/v5" +) + +type Data_2_4_x struct { + Settings Settings_2_4_x + Users []UserData_2_4_x +} + +func (data Data_2_4_x) migrate() Data { + return Data{ + Settings: data.Settings.migrate(), + Users: xslices.Map(data.Users, func(user UserData_2_4_x) UserData { return user.migrate() }), + } +} + +type Settings_2_4_x struct { + GluonDir string + + IMAPPort int + SMTPPort int +} + +func (settings Settings_2_4_x) migrate() Settings { + newSettings := newDefaultSettings(settings.GluonDir) + + newSettings.IMAPPort = settings.IMAPPort + newSettings.SMTPPort = settings.SMTPPort + + return newSettings +} + +type UserData_2_4_x struct { + UserID string + Username string + + GluonKey string + SplitMode bool +} + +func (user UserData_2_4_x) migrate() UserData { + var mode AddressMode + + if user.SplitMode { + mode = SplitMode + } else { + mode = CombinedMode + } + + return UserData{ + UserID: user.UserID, + Username: user.Username, + GluonKey: []byte(user.GluonKey), + AddressMode: mode, + } +} + +func upgrade_2_4_x(b []byte) ([]byte, error) { + var old Data_2_4_x + + if err := msgpack.Unmarshal(b, &old); err != nil { + return nil, err + } + + return msgpack.Marshal(old.migrate()) +} diff --git a/internal/vault/types_certs.go b/internal/vault/types_certs.go new file mode 100644 index 00000000..db7031b8 --- /dev/null +++ b/internal/vault/types_certs.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 vault + +import "github.com/ProtonMail/proton-bridge/v2/internal/certs" + +type Certs struct { + Bridge Cert + Installed bool +} + +type Cert struct { + Cert, Key []byte +} + +func newDefaultCerts() Certs { + return Certs{ + Bridge: newTLSCert(), + } +} + +func newTLSCert() Cert { + template, err := certs.NewTLSTemplate() + if err != nil { + panic(err) + } + + certPEM, keyPEM, err := certs.GenerateCert(template) + if err != nil { + panic(err) + } + + return Cert{ + Cert: certPEM, + Key: keyPEM, + } +} diff --git a/internal/vault/types_data.go b/internal/vault/types_data.go new file mode 100644 index 00000000..a285a9f0 --- /dev/null +++ b/internal/vault/types_data.go @@ -0,0 +1,32 @@ +// 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 vault + +type Data struct { + Settings Settings + Users []UserData + Cookies []byte + Certs Certs +} + +func newDefaultData(gluonDir string) Data { + return Data{ + Settings: newDefaultSettings(gluonDir), + Certs: newDefaultCerts(), + } +} diff --git a/internal/vault/types_file.go b/internal/vault/types_file.go new file mode 100644 index 00000000..b1c9fbda --- /dev/null +++ b/internal/vault/types_file.go @@ -0,0 +1,78 @@ +// 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 vault + +import ( + "crypto/cipher" + + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/vmihailenco/msgpack/v5" +) + +// File holds a versioned, serialized data. +type File struct { + Version Version + Data []byte +} + +func unmarshalFile[T any](gcm cipher.AEAD, enc []byte, data *T) error { + dec, err := gcm.Open(nil, enc[:gcm.NonceSize()], enc[gcm.NonceSize():], nil) + if err != nil { + return err + } + + var f File + + if err := msgpack.Unmarshal(dec, &f); err != nil { + return err + } + + for v := f.Version; v < Current; v++ { + b, err := upgrade(v, f.Data) + if err != nil { + return err + } + + f.Data = b + } + + if err := msgpack.Unmarshal(f.Data, data); err != nil { + return err + } + + return nil +} + +func marshalFile[T any](gcm cipher.AEAD, t T) ([]byte, error) { + b, err := msgpack.Marshal(t) + if err != nil { + return nil, err + } + + dec, err := msgpack.Marshal(File{Version: Current, Data: b}) + if err != nil { + return nil, err + } + + nonce, err := crypto.RandomToken(gcm.NonceSize()) + if err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, dec, nil), nil +} diff --git a/internal/vault/types_settings.go b/internal/vault/types_settings.go new file mode 100644 index 00000000..48bb3319 --- /dev/null +++ b/internal/vault/types_settings.go @@ -0,0 +1,77 @@ +// 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 vault + +import ( + "math/rand" + "runtime" + + "github.com/ProtonMail/proton-bridge/v2/internal/updater" +) + +type Settings struct { + GluonDir string + + IMAPPort int + SMTPPort int + IMAPSSL bool + SMTPSSL bool + + UpdateChannel updater.Channel + UpdateRollout float64 + + ColorScheme string + ProxyAllowed bool + ShowAllMail bool + Autostart bool + AutoUpdate bool + + LastVersion string + FirstStart bool + FirstStartGUI bool + + SyncWorkers int + SyncBuffer int +} + +func newDefaultSettings(gluonDir string) Settings { + return Settings{ + GluonDir: gluonDir, + + IMAPPort: 1143, + SMTPPort: 1025, + IMAPSSL: false, + SMTPSSL: false, + + UpdateChannel: updater.DefaultUpdateChannel, + UpdateRollout: rand.Float64(), //nolint:gosec + + ColorScheme: "", + ProxyAllowed: true, + ShowAllMail: true, + Autostart: false, + AutoUpdate: true, + + LastVersion: "0.0.0", + FirstStart: true, + FirstStartGUI: true, + + SyncWorkers: runtime.NumCPU(), + SyncBuffer: runtime.NumCPU(), + } +} diff --git a/internal/vault/types.go b/internal/vault/types_user.go similarity index 62% rename from internal/vault/types.go rename to internal/vault/types_user.go index 3971ac82..9673d554 100644 --- a/internal/vault/types.go +++ b/internal/vault/types_user.go @@ -17,81 +17,7 @@ package vault -import ( - "math/rand" - "runtime" - - "github.com/ProtonMail/gluon/imap" - "github.com/ProtonMail/proton-bridge/v2/internal/updater" -) - -type Data struct { - Settings Settings - Users []UserData - Cookies []byte - Certs Certs -} - -type Certs struct { - Bridge Cert - Installed bool -} - -type Cert struct { - Cert, Key []byte -} - -type Settings struct { - GluonDir string - - IMAPPort int - SMTPPort int - IMAPSSL bool - SMTPSSL bool - - UpdateChannel updater.Channel - UpdateRollout float64 - - ColorScheme string - ProxyAllowed bool - ShowAllMail bool - Autostart bool - AutoUpdate bool - - LastVersion string - FirstStart bool - FirstStartGUI bool - - SyncWorkers int - SyncBuffer int -} - -func newDefaultSettings(gluonDir string) Settings { - return Settings{ - GluonDir: gluonDir, - - IMAPPort: 1143, - SMTPPort: 1025, - IMAPSSL: false, - SMTPSSL: false, - - UpdateChannel: updater.DefaultUpdateChannel, - UpdateRollout: rand.Float64(), //nolint:gosec - - ColorScheme: "", - ProxyAllowed: true, - ShowAllMail: true, - Autostart: false, - AutoUpdate: true, - - LastVersion: "0.0.0", - FirstStart: true, - FirstStartGUI: true, - - SyncWorkers: runtime.NumCPU(), - SyncBuffer: runtime.NumCPU(), - } -} +import "github.com/ProtonMail/gluon/imap" // UserData holds information about a single bridge user. // The user may or may not be logged in. diff --git a/internal/vault/vault.go b/internal/vault/vault.go index 0379a49e..657cb0bc 100644 --- a/internal/vault/vault.go +++ b/internal/vault/vault.go @@ -20,7 +20,6 @@ package vault import ( "crypto/aes" "crypto/cipher" - "crypto/rand" "crypto/sha256" "errors" "fmt" @@ -29,10 +28,8 @@ import ( "path/filepath" "sync" - "github.com/ProtonMail/proton-bridge/v2/internal/certs" "github.com/bradenaw/juniper/xslices" "github.com/sirupsen/logrus" - "github.com/vmihailenco/msgpack/v5" ) // Vault is an encrypted data vault that stores bridge and user data. @@ -228,9 +225,7 @@ func newVault(path, gluonDir string, gcm cipher.AEAD) (*Vault, bool, error) { var corrupt bool - if dec, err := decrypt(gcm, enc); err != nil { - corrupt = true - } else if err := msgpack.Unmarshal(dec, new(Data)); err != nil { + if err := unmarshalFile(gcm, enc, new(Data)); err != nil { corrupt = true } @@ -255,14 +250,9 @@ func (vault *Vault) get() Data { vault.encLock.RLock() defer vault.encLock.RUnlock() - dec, err := decrypt(vault.gcm, vault.enc) - if err != nil { - panic(err) - } - var data Data - if err := msgpack.Unmarshal(dec, &data); err != nil { + if err := unmarshalFile(vault.gcm, vault.enc, &data); err != nil { panic(err) } @@ -273,25 +263,15 @@ func (vault *Vault) mod(fn func(data *Data)) error { vault.encLock.Lock() defer vault.encLock.Unlock() - dec, err := decrypt(vault.gcm, vault.enc) - if err != nil { - return err - } - var data Data - if err := msgpack.Unmarshal(dec, &data); err != nil { + if err := unmarshalFile(vault.gcm, vault.enc, &data); err != nil { return err } fn(&data) - mod, err := msgpack.Marshal(data) - if err != nil { - return err - } - - enc, err := encrypt(vault.gcm, mod) + enc, err := marshalFile(vault.gcm, data) if err != nil { return err } @@ -318,23 +298,7 @@ func (vault *Vault) modUser(userID string, fn func(userData *UserData)) error { } func initVault(path, gluonDir string, gcm cipher.AEAD) ([]byte, error) { - bridgeCert, err := newTLSCert() - if err != nil { - return nil, err - } - - dec, err := msgpack.Marshal(Data{ - Settings: newDefaultSettings(gluonDir), - - Certs: Certs{ - Bridge: bridgeCert, - }, - }) - if err != nil { - return nil, err - } - - enc, err := encrypt(gcm, dec) + enc, err := marshalFile(gcm, newDefaultData(gluonDir)) if err != nil { return nil, err } @@ -345,34 +309,3 @@ func initVault(path, gluonDir string, gcm cipher.AEAD) ([]byte, error) { return enc, nil } - -func decrypt(gcm cipher.AEAD, enc []byte) ([]byte, error) { - return gcm.Open(nil, enc[:gcm.NonceSize()], enc[gcm.NonceSize():], nil) -} - -func encrypt(gcm cipher.AEAD, data []byte) ([]byte, error) { - nonce := make([]byte, gcm.NonceSize()) - - if _, err := rand.Read(nonce); err != nil { - return nil, err - } - - return gcm.Seal(nonce, nonce, data, nil), nil -} - -func newTLSCert() (Cert, error) { - template, err := certs.NewTLSTemplate() - if err != nil { - return Cert{}, err - } - - certPEM, keyPEM, err := certs.GenerateCert(template) - if err != nil { - return Cert{}, err - } - - return Cert{ - Cert: certPEM, - Key: keyPEM, - }, nil -}