Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ jobs:
shell: bash
run: |
if [[ "${GITHUB_TOKEN}" != "" ]]; then
go test -v -race -coverprofile=coverage.txt . ./update
go test -v -race -coverprofile=coverage.txt . ./update ./pgp
else
go test -v -race -short -coverprofile=coverage.txt . ./update
go test -v -race -short -coverprofile=coverage.txt . ./update ./pgp
fi

- name: Code coverage with codecov
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ ifeq ($(GOBIN),)
GOBIN := $(GOPATH)/bin
endif

TESTS=. ./update
TESTS=. ./update ./pgp
COVERAGE_FILE=coverage.txt

BUILD_DATE=`date`
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Self-Update library for Github, Gitea and Gitlab hosted applications in Go
* [Hash or Signature Validation](#hash-or-signature-validation)
* [SHA256](#sha256)
* [ECDSA](#ecdsa)
* [PGP](#pgp)
* [Using a single checksum file for all your assets](#using-a-single-checksum-file-for-all-your-assets)
* [macOS universal binaries](#macos-universal-binaries)
* [Other providers than Github](#other-providers-than-github)
Expand Down Expand Up @@ -261,7 +262,7 @@ More information on targeting ARM cpu can be found here: [GoArm](https://github.
# Hash or Signature Validation

go-selfupdate supports hash or signature validation of the downloaded files. It comes
with support for sha256 hashes or ECDSA signatures. If you need something different,
with support for sha256 hashes, ECDSA or PGP signatures. If you need something different,
you can implement the `Validator` interface with your own validation mechanism:

```go
Expand Down Expand Up @@ -298,6 +299,25 @@ openssl dgst -sha256 -sign Test.pem -out foo.zip.sig foo.zip
go-selfupdate makes use of go internal crypto package. Therefore the private key
has to be compatible with FIPS 186-3.

## PGP

To verify the signature by PGP generate a detached signature and save it within a file which has the
same naming as original file with the suffix `.asc` (armored) or `.sig` (binary).
For e.g. use gpg, the file `foo.zip.asc` is generated with:
```shell
gpg --armor --detach-sign foo.zip
```

The PGP validator lives in the `pgp` sub-package, so applications not using it don't need to import the PGP libraries:

```go
import "github.com/creativeprojects/go-selfupdate/pgp"

updater, _ := selfupdate.NewUpdater(selfupdate.Config{
Validator: new(pgp.Validator).WithArmoredKeyRing(publicKey),
})
```

## Using a single checksum file for all your assets

Tools like [goreleaser][] produce a single checksum file for all your assets. A Validator is provided out of the box for this case:
Expand All @@ -306,6 +326,16 @@ Tools like [goreleaser][] produce a single checksum file for all your assets. A
updater, _ := NewUpdater(Config{Validator: &ChecksumValidator{UniqueFilename: "checksums.txt"}})
```

If the checksum file is itself signed (`checksums.txt.sig` for ECDSA, `checksums.txt.asc` for PGP),
use a validator that checks the assets against the checksum file, and the checksum file against its signature:

```go
// ECDSA
updater, _ := NewUpdater(Config{Validator: NewChecksumWithECDSAValidator("checksums.txt", pemCertificate)})
// PGP
updater, _ := NewUpdater(Config{Validator: pgp.NewChecksumValidator("checksums.txt", armoredPublicKey)})
```

# macOS universal binaries

You can ask the updater to choose a macOS universal binary as a fallback if the native architecture wasn't found.
Expand Down
4 changes: 2 additions & 2 deletions detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -919,13 +919,13 @@ func TestFindReleaseAndAsset(t *testing.T) {

func TestBuildMultistepValidationChain(t *testing.T) {
testVersion := "v0.14.0"
source, keyRing := mockPGPSourceRepository(t)
source := mockSignedSourceRepository(t)
checksumValidator := &ChecksumValidator{UniqueFilename: "checksums.txt"}

t.Run("ValidConfig", func(t *testing.T) {
updater, _ := NewUpdater(Config{
Source: source,
Validator: NewChecksumWithPGPValidator("checksums.txt", keyRing),
Validator: newChecksumWithSignatureValidator("checksums.txt"),
})

release, found, err := updater.DetectVersion(context.Background(), testGithubRepository, testVersion)
Expand Down
2 changes: 0 additions & 2 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ var (
ErrHashNotFound = errors.New("hash not found in checksum file")
ErrECDSAValidationFailed = errors.New("ECDSA signature verification failed")
ErrInvalidECDSASignature = errors.New("invalid ECDSA signature")
ErrInvalidPGPSignature = errors.New("invalid PGP signature")
ErrPGPKeyRingNotSet = errors.New("PGP key ring not set")
ErrCannotDecompressFile = errors.New("failed to decompress")
ErrExecutableNotFoundInArchive = errors.New("executable not found")
)
43 changes: 24 additions & 19 deletions mockdata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"time"

"github.com/stretchr/testify/require"
"github.com/ProtonMail/go-crypto/openpgp"
)

// mockSourceRepository creates a new *MockSource pre-populated with different versions and assets
Expand Down Expand Up @@ -279,15 +278,10 @@ func mockSourceRepository(t *testing.T) *MockSource {
return NewMockSource(releases, files)
}

// mockPGPSourceRepository creates a variant of mockSourceRepository where "checksums.txt" is signed with PGP
func mockPGPSourceRepository(t *testing.T) (source *MockSource, PGPKeyRing []byte) {

source = mockSourceRepository(t)

var err error

var entity *openpgp.Entity
PGPKeyRing, entity = getTestPGPKeyRing(t)
// mockSignedSourceRepository creates a variant of mockSourceRepository where "checksums.txt" is "signed":
// "checksums.txt.asc" holds the SHA256 of "checksums.txt" and is checked by signatureValidator.
func mockSignedSourceRepository(t *testing.T) *MockSource {
source := mockSourceRepository(t)

for i, release := range source.releases {
rel := release.(*GitHubRelease)
Expand All @@ -296,19 +290,30 @@ func mockPGPSourceRepository(t *testing.T) (source *MockSource, PGPKeyRing []byt
signatureID := id + 1
shaSums := source.files[id]

// Create SHA256SUMS.asc (by signing SHA256SUMS)
signature := &bytes.Buffer{}
err = openpgp.ArmoredDetachSign(signature, entity, bytes.NewReader(shaSums), nil)
require.NoError(t, err)

rel.assets = append(rel.assets, &GitHubAsset{
id: signatureID,
name: "checksums.txt.asc",
})
source.files[signatureID] = signature.Bytes()

t.Logf("file id %d contains PGP signature:\n%s\n", signatureID, string(source.files[signatureID]))
source.files[signatureID] = []byte(fmt.Sprintf("%x", sha256.Sum256(shaSums)))
}

return
return source
}

// signatureValidator stands in for a signature validator (such as pgp.Validator) in multistep
// validation tests: it checks a SHA256 hash stored in a ".asc" file.
type signatureValidator struct {
SHAValidator
}

func (v *signatureValidator) GetValidationAssetName(releaseFilename string) string {
return releaseFilename + ".asc"
}

// newChecksumWithSignatureValidator mirrors pgp.NewChecksumValidator using signatureValidator
func newChecksumWithSignatureValidator(checksumsFilename string) Validator {
return new(PatternValidator).
Add(checksumsFilename, new(signatureValidator)).
Add("*", &ChecksumValidator{UniqueFilename: checksumsFilename}).
SkipValidation("*.asc")
}
112 changes: 112 additions & 0 deletions pgp/updater_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package pgp

import (
"bytes"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/creativeprojects/go-selfupdate"
)

// memorySource is an in-memory selfupdate.Source
type memorySource struct {
releases []selfupdate.SourceRelease
files map[int64][]byte
}

func (s *memorySource) ListReleases(_ context.Context, _ selfupdate.Repository) ([]selfupdate.SourceRelease, error) {
return s.releases, nil
}

func (s *memorySource) DownloadReleaseAsset(_ context.Context, _ *selfupdate.Release, assetID int64) (io.ReadCloser, error) {
content, ok := s.files[assetID]
if !ok {
return nil, selfupdate.ErrAssetNotFound
}
return io.NopCloser(bytes.NewReader(content)), nil
}

func TestUpdateWithSignedChecksums(t *testing.T) {
const (
archiveID = 1
checksumsID = 2
signatureID = 3
assetName = "new_version_1.0.0_linux_amd64.tar.gz"
)
keyRing, entity := getTestKeyRing(t)

archive, err := os.ReadFile("../testdata/new_version.tar.gz")
require.NoError(t, err)

checksums := fmt.Sprintf("%x %s\n", sha256.Sum256(archive), assetName)
signature := &bytes.Buffer{}
require.NoError(t, openpgp.ArmoredDetachSign(signature, entity, strings.NewReader(checksums), nil))

source := &memorySource{
releases: []selfupdate.SourceRelease{
&selfupdate.HttpRelease{
ID: 1,
Name: "v1.0.0",
TagName: "v1.0.0",
PublishedAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
Assets: []*selfupdate.HttpAsset{
{ID: archiveID, Name: assetName, Size: len(archive)},
{ID: checksumsID, Name: "checksums.txt", Size: len(checksums)},
{ID: signatureID, Name: "checksums.txt.asc", Size: signature.Len()},
},
},
},
files: map[int64][]byte{
archiveID: archive,
checksumsID: []byte(checksums),
signatureID: signature.Bytes(),
},
}

updater, err := selfupdate.NewUpdater(selfupdate.Config{
Source: source,
Validator: NewChecksumValidator("checksums.txt", keyRing),
OS: "linux",
Arch: "amd64",
})
require.NoError(t, err)

release, found, err := updater.DetectLatest(context.Background(), selfupdate.NewRepositorySlug("owner", "new_version"))
require.NoError(t, err)
require.True(t, found)
require.Len(t, release.ValidationChain, 2)
assert.Equal(t, "checksums.txt", release.ValidationChain[0].ValidationAssetName)
assert.Equal(t, "checksums.txt.asc", release.ValidationChain[1].ValidationAssetName)

// the executable name must match the file inside the archive
target := filepath.Join(t.TempDir(), "new_version")
require.NoError(t, os.WriteFile(target, nil, 0o600))

t.Run("ValidSignature", func(t *testing.T) {
assert.NoError(t, updater.UpdateTo(context.Background(), release, target))
})

t.Run("InvalidSignature", func(t *testing.T) {
source.files[signatureID] = archive
err := updater.UpdateTo(context.Background(), release, target)
assert.ErrorIs(t, err, ErrInvalidSignature)
})

t.Run("TamperedChecksums", func(t *testing.T) {
source.files[signatureID] = signature.Bytes()
source.files[checksumsID] = []byte(checksums + "deadbeef tampered_file\n")
err := updater.UpdateTo(context.Background(), release, target)
assert.ErrorContains(t, err, "openpgp: invalid signature")
})
}
83 changes: 83 additions & 0 deletions pgp/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Package pgp provides a PGP signature validator for go-selfupdate.
//
// It is kept in a separate package so that applications not using PGP
// signatures do not pull in the OpenPGP library.
package pgp

import (
"bytes"
"errors"
"fmt"
"io"

"github.com/ProtonMail/go-crypto/openpgp"

"github.com/creativeprojects/go-selfupdate"
)

// Possible errors returned
var (
ErrInvalidSignature = errors.New("invalid PGP signature")
ErrKeyRingNotSet = errors.New("PGP key ring not set")
)

// Validator specifies a PGP validator for additional file validation
// before updating.
type Validator struct {
// KeyRing is usually filled by openpgp.ReadArmoredKeyRing(bytes.NewReader(key)) with key being the PGP pub key.
KeyRing openpgp.EntityList
// Binary toggles whether to validate detached *.sig (binary) or *.asc (ascii) signature files
Binary bool
}

// WithArmoredKeyRing is a convenience method to set KeyRing
func (g *Validator) WithArmoredKeyRing(key []byte) *Validator {
if ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key)); err == nil {
g.KeyRing = ring
} else {
panic(fmt.Errorf("failed setting armored public key ring: %w", err))
}
return g
}

// Validate checks the PGP signature of the release against the signature
// contained in an additional asset file.
func (g *Validator) Validate(filename string, release, signature []byte) (err error) {
if g.KeyRing == nil {
return ErrKeyRingNotSet
}

data, sig := bytes.NewReader(release), bytes.NewReader(signature)
// A nil *packet.Config selects the library defaults.
if g.Binary {
_, err = openpgp.CheckDetachedSignature(g.KeyRing, data, sig, nil)
} else {
_, err = openpgp.CheckArmoredDetachedSignature(g.KeyRing, data, sig, nil)
}

if errors.Is(err, io.EOF) {
err = ErrInvalidSignature
}

return err
}

// GetValidationAssetName returns the asset name for PGP validation.
func (g *Validator) GetValidationAssetName(releaseFilename string) string {
if g.Binary {
return releaseFilename + ".sig"
}
return releaseFilename + ".asc"
}

// NewChecksumValidator returns a validator that checks assets with a checksums file
// (e.g. SHA256SUMS) and the checksums file with an armored PGP signature (e.g. SHA256SUMS.asc).
func NewChecksumValidator(checksumsFilename string, armoredKeyRing []byte) selfupdate.Validator {
return new(selfupdate.PatternValidator).
Add(checksumsFilename, new(Validator).WithArmoredKeyRing(armoredKeyRing)).
Add("*", &selfupdate.ChecksumValidator{UniqueFilename: checksumsFilename}).
SkipValidation("*.asc")
}

// Verify interface
var _ selfupdate.Validator = &Validator{}
Loading
Loading