diff --git a/.gitignore b/.gitignore index 80079e7e..33cfa301 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ frontend/qml/*.qmlc /bridge_*_*.tgz /ie_*_*.tgz /versioner +/hasher cmd/Desktop-Bridge/deploy cmd/Import-Export/deploy internal/frontend/qt*/moc.cpp diff --git a/Makefile b/Makefile index efea918f..133128fd 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ TARGET_CMD?=Desktop-Bridge TARGET_OS?=${GOOS} ## Build -.PHONY: build build-ie build-nogui build-ie-nogui build-launcher build-launcher-ie versioner +.PHONY: build build-ie build-nogui build-ie-nogui build-launcher build-launcher-ie versioner hasher # Keep version hardcoded so app build works also without Git repository. BRIDGE_APP_VERSION?=1.5.5+git @@ -87,7 +87,10 @@ build-launcher-ie: go build -ldflags="-X 'main.ConfigName=importExport' -X 'main.ExeName=Import-Export'" -o launcher-ie cmd/launcher/main.go versioner: - go build ${BUILD_FLAGS} ${GO_LDFLAGS} -o versioner cmd/versioner/main.go + go build ${BUILD_FLAGS} ${GO_LDFLAGS} -o versioner utils/versioner/main.go + +hasher: + go build -o hasher utils/hasher/main.go ${TGZ_TARGET}: ${DEPLOY_DIR}/${TARGET_OS} rm -f $@ diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index c0ca82ca..cc69661c 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -149,7 +149,12 @@ func getPathToExecutable(name string, versioner *versioner.Versioner, kr *crypto vlog := logrus.WithField("version", version) if err := version.VerifyFiles(kr); err != nil { - vlog.WithError(err).Error("Failed to verify files") + vlog.WithError(err).Error("Files failed verification and will be removed") + + if err := version.Remove(); err != nil { + vlog.WithError(err).Error("Failed to remove files") + } + continue } diff --git a/internal/versioner/version.go b/internal/versioner/version.go index 4e64863e..b263ed3c 100644 --- a/internal/versioner/version.go +++ b/internal/versioner/version.go @@ -18,14 +18,19 @@ package versioner import ( + "bytes" + "errors" "io/ioutil" "os" "path/filepath" "github.com/Masterminds/semver/v3" "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ProtonMail/proton-bridge/pkg/sum" ) +const sumFile = ".sum" + type Version struct { version *semver.Version path string @@ -47,31 +52,34 @@ func (v Versions) Swap(i, j int) { // VerifyFiles verifies all files in the version directory. func (v *Version) VerifyFiles(kr *crypto.KeyRing) error { - return filepath.Walk(v.path, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } + fileBytes, err := ioutil.ReadFile(filepath.Join(v.path, sumFile)) // nolint[gosec] + if err != nil { + return err + } - if filepath.Ext(path) == ".sig" || info.IsDir() { - return nil - } + sigBytes, err := ioutil.ReadFile(filepath.Join(v.path, sumFile+".sig")) // nolint[gosec] + if err != nil { + return err + } - fileBytes, err := ioutil.ReadFile(path) // nolint[gosec] - if err != nil { - return err - } + if err := kr.VerifyDetached( + crypto.NewPlainMessage(fileBytes), + crypto.NewPGPSignature(sigBytes), + crypto.GetUnixTime(), + ); err != nil { + return err + } - sigBytes, err := ioutil.ReadFile(path + ".sig") // nolint[gosec] - if err != nil { - return err - } + sum, err := sum.RecursiveSum(v.path, sumFile) + if err != nil { + return err + } - return kr.VerifyDetached( - crypto.NewPlainMessage(fileBytes), - crypto.NewPGPSignature(sigBytes), - crypto.GetUnixTime(), - ) - }) + if !bytes.Equal(sum, fileBytes) { + return errors.New("sum mismatch") + } + + return nil } // GetExecutable returns the full path to the executable of the given version. @@ -85,3 +93,8 @@ func (v *Version) GetExecutable(name string) (string, error) { return exe, nil } + +// Remove removes this version directory. +func (v *Version) Remove() error { + return os.RemoveAll(v.path) +} diff --git a/internal/versioner/version_test.go b/internal/versioner/version_test.go index 0fe84b70..b42799e7 100644 --- a/internal/versioner/version_test.go +++ b/internal/versioner/version_test.go @@ -26,6 +26,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ProtonMail/proton-bridge/pkg/sum" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,12 +40,12 @@ func TestVerifyFiles(t *testing.T) { path: tempDir, } - kr := createSignedFiles(t, - filepath.Join(tempDir, "f1.txt"), - filepath.Join(tempDir, "f2.png"), - filepath.Join(tempDir, "f3.dat"), - filepath.Join(tempDir, "sub", "f4.tar"), - filepath.Join(tempDir, "sub", "f5.tgz"), + kr := createSignedFiles(t, tempDir, + "f1.txt", + "f2.png", + "f3.dat", + filepath.Join("sub", "f4.tar"), + filepath.Join("sub", "f5.tgz"), ) assert.NoError(t, version.VerifyFiles(kr)) @@ -59,12 +60,12 @@ func TestVerifyWithBadFile(t *testing.T) { path: tempDir, } - kr := createSignedFiles(t, - filepath.Join(tempDir, "f1.txt"), - filepath.Join(tempDir, "f2.png"), - filepath.Join(tempDir, "f3.bad"), - filepath.Join(tempDir, "sub", "f4.tar"), - filepath.Join(tempDir, "sub", "f5.tgz"), + kr := createSignedFiles(t, tempDir, + "f1.txt", + "f2.png", + "f3.bad", + filepath.Join("sub", "f4.tar"), + filepath.Join("sub", "f5.tgz"), ) badKeyRing := makeKeyRing(t) @@ -82,12 +83,12 @@ func TestVerifyWithBadSubFile(t *testing.T) { path: tempDir, } - kr := createSignedFiles(t, - filepath.Join(tempDir, "f1.txt"), - filepath.Join(tempDir, "f2.png"), - filepath.Join(tempDir, "f3.dat"), - filepath.Join(tempDir, "sub", "f4.tar"), - filepath.Join(tempDir, "sub", "f5.bad"), + kr := createSignedFiles(t, tempDir, + "f1.txt", + "f2.png", + "f3.dat", + filepath.Join("sub", "f4.tar"), + filepath.Join("sub", "f5.bad"), ) badKeyRing := makeKeyRing(t) @@ -96,15 +97,24 @@ func TestVerifyWithBadSubFile(t *testing.T) { assert.Error(t, version.VerifyFiles(kr)) } -func createSignedFiles(t *testing.T, paths ...string) *crypto.KeyRing { +func createSignedFiles(t *testing.T, root string, paths ...string) *crypto.KeyRing { kr := makeKeyRing(t) for _, path := range paths { - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) - makeFile(t, path) - signFile(t, path, kr) + makeFile(t, filepath.Join(root, path)) } + sum, err := sum.RecursiveSum(root, "") + require.NoError(t, err) + + sumFile, err := os.Create(filepath.Join(root, sumFile)) + require.NoError(t, err) + + _, err = sumFile.Write(sum) + require.NoError(t, err) + + signFile(t, sumFile.Name(), kr) + return kr } @@ -119,6 +129,8 @@ func makeKeyRing(t *testing.T) *crypto.KeyRing { } func makeFile(t *testing.T, path string) { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + f, err := os.Create(path) require.NoError(t, err) diff --git a/internal/versioner/versioner_remove_test.go b/internal/versioner/versioner_remove_test.go new file mode 100644 index 00000000..f2c344b6 --- /dev/null +++ b/internal/versioner/versioner_remove_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +// +build !darwin + +package versioner + +import ( + "io/ioutil" + "path/filepath" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RemoveOldVersions is a noop on darwin; we don't test it there. + +func TestRemoveOldVersions(t *testing.T) { + updates, err := ioutil.TempDir("", "updates") + require.NoError(t, err) + + v := newTestVersioner(t, "myCoolApp", updates, "2.3.4-beta", "2.3.4", "2.3.5", "2.4.0") + + allVersions, err := v.ListVersions() + require.NoError(t, err) + require.Len(t, allVersions, 4) + + assert.NoError(t, v.RemoveOldVersions()) + + cleanedVersions, err := v.ListVersions() + assert.NoError(t, err) + assert.Len(t, cleanedVersions, 1) + + assert.Equal(t, semver.MustParse("2.4.0"), cleanedVersions[0].version) + assert.Equal(t, filepath.Join(updates, "2.4.0"), cleanedVersions[0].path) +} diff --git a/internal/versioner/versioner_test.go b/internal/versioner/versioner_test.go index c8d12eb8..dd75c209 100644 --- a/internal/versioner/versioner_test.go +++ b/internal/versioner/versioner_test.go @@ -50,26 +50,6 @@ func TestListVersions(t *testing.T) { assert.Equal(t, filepath.Join(updates, "2.3.4-beta"), versions[3].path) } -func TestRemoveOldVersions(t *testing.T) { - updates, err := ioutil.TempDir("", "updates") - require.NoError(t, err) - - v := newTestVersioner(t, "myCoolApp", updates, "2.3.4-beta", "2.3.4", "2.3.5", "2.4.0") - - allVersions, err := v.ListVersions() - require.NoError(t, err) - require.Len(t, allVersions, 4) - - assert.NoError(t, v.RemoveOldVersions()) - - cleanedVersions, err := v.ListVersions() - assert.NoError(t, err) - assert.Len(t, cleanedVersions, 1) - - assert.Equal(t, semver.MustParse("2.4.0"), cleanedVersions[0].version) - assert.Equal(t, filepath.Join(updates, "2.4.0"), cleanedVersions[0].path) -} - func newTestVersioner(t *testing.T, exeName, updates string, versions ...string) *Versioner { for _, version := range versions { makeDummyVersionDirectory(t, exeName, updates, version) diff --git a/pkg/sum/sum.go b/pkg/sum/sum.go new file mode 100644 index 00000000..d3d3d9e7 --- /dev/null +++ b/pkg/sum/sum.go @@ -0,0 +1,66 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package sum + +import ( + "crypto/sha512" + "io" + "os" + "path/filepath" + "strings" +) + +// RecursiveSum computes the sha512 sum of all files in the root directory and descendents. +// If a skipFile is provided (e.g. the path of a checksum file relative to rootDir), it (and its signature) is ignored. +func RecursiveSum(rootDir, skipFile string) ([]byte, error) { + hash := sha512.New() + + if err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // The hashfile itself isn't included in the hash. + if path == filepath.Join(rootDir, skipFile) || path == filepath.Join(rootDir, skipFile+".sig") { + return nil + } + + if _, err := hash.Write([]byte(strings.TrimPrefix(path, rootDir))); err != nil { + return err + } + + f, err := os.Open(path) // nolint[gosec] + if err != nil { + return err + } + + if _, err := io.Copy(hash, f); err != nil { + return err + } + + return nil + }); err != nil { + return nil, err + } + + return hash.Sum([]byte{}), nil +} diff --git a/pkg/sum/sum_test.go b/pkg/sum/sum_test.go new file mode 100644 index 00000000..d616f543 --- /dev/null +++ b/pkg/sum/sum_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package sum + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRecursiveSum(t *testing.T) { + tempDir, err := ioutil.TempDir("", "verify-test") + require.NoError(t, err) + + createFiles(t, tempDir, + filepath.Join("a", "1"), + filepath.Join("a", "2"), + filepath.Join("b", "3"), + filepath.Join("b", "4"), + filepath.Join("b", "c", "5"), + filepath.Join("b", "c", "6"), + ) + + sumOriginal := sum(t, tempDir) + + // Renaming files should produce a different checksum. + require.NoError(t, os.Rename(filepath.Join(tempDir, "a", "1"), filepath.Join(tempDir, "a", "11"))) + sumRenamed := sum(t, tempDir) + require.NotEqual(t, sumOriginal, sumRenamed) + + // Reverting to the original name should produce the same checksum again. + require.NoError(t, os.Rename(filepath.Join(tempDir, "a", "11"), filepath.Join(tempDir, "a", "1"))) + require.Equal(t, sumOriginal, sum(t, tempDir)) + + // Moving files should produce a different checksum. + require.NoError(t, os.Rename(filepath.Join(tempDir, "a", "1"), filepath.Join(tempDir, "1"))) + sumMoved := sum(t, tempDir) + require.NotEqual(t, sumOriginal, sumMoved) + + // Moving files back to their original location should produce the same checksum again. + require.NoError(t, os.Rename(filepath.Join(tempDir, "1"), filepath.Join(tempDir, "a", "1"))) + require.Equal(t, sumOriginal, sum(t, tempDir)) + + // Changing file data should produce a different checksum. + originalData := modifyFile(t, filepath.Join(tempDir, "a", "1"), []byte("something")) + require.NotEqual(t, sumOriginal, sum(t, tempDir)) + + // Reverting file data should produce the original checksum. + modifyFile(t, filepath.Join(tempDir, "a", "1"), originalData) + require.Equal(t, sumOriginal, sum(t, tempDir)) +} + +func createFiles(t *testing.T, root string, paths ...string) { + for _, path := range paths { + makeFile(t, filepath.Join(root, path)) + } +} + +func makeFile(t *testing.T, path string) { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + + f, err := os.Create(path) + require.NoError(t, err) + + _, err = f.WriteString(path) + require.NoError(t, err) + + require.NoError(t, f.Close()) +} + +func sum(t *testing.T, path string) []byte { + sum, err := RecursiveSum(path, "") + require.NoError(t, err) + + return sum +} + +func modifyFile(t *testing.T, path string, data []byte) []byte { + r, err := os.Open(path) + require.NoError(t, err) + + b, err := ioutil.ReadAll(r) + require.NoError(t, err) + require.NoError(t, r.Close()) + + f, err := os.Create(path) + require.NoError(t, err) + + _, err = f.Write(data) + require.NoError(t, err) + require.NoError(t, f.Close()) + + return b +} diff --git a/utils/hasher/main.go b/utils/hasher/main.go new file mode 100644 index 00000000..96b590e9 --- /dev/null +++ b/utils/hasher/main.go @@ -0,0 +1,62 @@ +// Copyright (c) 2020 Proton Technologies AG +// +// This file is part of ProtonMail Bridge. +// +// ProtonMail 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. +// +// ProtonMail 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 ProtonMail Bridge. If not, see . + +package main + +import ( + "os" + + "github.com/ProtonMail/proton-bridge/pkg/sum" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v2" +) + +func main() { + if err := createApp().Run(os.Args); err != nil { + logrus.Fatal(err) + } +} + +func createApp() *cli.App { // nolint[funlen] + app := cli.NewApp() + + app.Name = "hasher" + app.Usage = "Generate the recursive hash of a directory" + app.Action = computeSum + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "root", + Usage: "The root directory from which to begin recursive hashing", + Required: true, + }, + } + + return app +} + +func computeSum(c *cli.Context) error { + b, err := sum.RecursiveSum(c.String("root"), "") + if err != nil { + return err + } + + if _, err := c.App.Writer.Write(b); err != nil { + return err + } + + return nil +} diff --git a/cmd/versioner/main.go b/utils/versioner/main.go similarity index 100% rename from cmd/versioner/main.go rename to utils/versioner/main.go