Add hash and signature validation

Go-self-update lacks support for checking integrity of downloaded files.
For more advanced situation it's necessary to validate the hash or
verify against public signatures. This patch adds support for
SHA2 hash and ECDSA PublicKey signature validation.
SHA2 uses file with suffix `.sha256`, whereas ECDSA uses
`.sig` file endings. See `selfupdate/validate_test.go` for examples.

Signed-off-by: Tobias Kohlbau <t.kohlbau@myopenfactory.com>
This commit is contained in:
Tobias Kohlbau 2018-11-06 16:28:06 +01:00
parent 6329a43531
commit 116dfa144d
11 changed files with 317 additions and 7 deletions

View File

@ -29,6 +29,7 @@ GitHub and replaces itself.
- Many archive and compression formats are supported (zip, tar, gzip, xzip)
- Support private repositories
- Support [GitHub Enterprise][]
- Support hash, signature validation
And small wrapper CLIs are provided:
@ -290,6 +291,44 @@ In summary, structure of releases on GitHub looks like:
Tags which don't contain a version number are ignored (i.e. `nightly`). And releases marked
as `pre-release` are also ignored.
### Hash or Signature Validation
go-github-selfupdate supports hash or signature validatiom of the downloaded files. It comes
with support for sha256 hashes or ECDSA signatures. In addition to internal functions the
user can implement the `Validator` interface for own validation mechanisms.
```go
// Validator represents an interface which enables additional validation of releases.
type Validator interface {
// Validate validates release bytes against an additional asset bytes.
// See SHA2Validator or ECDSAValidator for more information.
Validate(release, asset []byte) error
// Suffix describes the additional file ending which is used for finding the
// additional asset.
Suffix() string
}
```
## SHA256
To verify the integrity by SHA256 generate a hash sum and save it within a file which has the
same naming as original file with the suffix `.sha256`.
For e.g. use sha256sum, the file `selfupdate/testdata/foo.zip.sha256` is generated with:
```shell
sha256sum foo.zip > foo.zip.sha256
```
## ECDSA
To verify the signature by ECDSA generate a signature and save it within a file which has the
same naming as original file with the suffix `.sig`.
For e.g. use openssl, the file `selfupdate/testdata/foo.zip.sig` is generated with:
```shell
openssl dgst -sha256 -sign Test.pem -out foo.zip.sig foo.zip
```
go-github-selfupdate makes use of go internal crypto package. Therefore the used private key
has to be compatbile with FIPS 186-3.
### Development
#### Running tests

View File

@ -59,6 +59,15 @@ func findAssetFromReleasse(rel *github.RepositoryRelease, suffixes []string, tar
return nil, semver.Version{}, false
}
func findValidationAsset(rel *github.RepositoryRelease, validationName string) (*github.ReleaseAsset, bool) {
for _, asset := range rel.Assets {
if asset.GetName() == validationName {
return &asset, true
}
}
return nil, false
}
func findReleaseAndAsset(rels []*github.RepositoryRelease, targetVersion string) (*github.RepositoryRelease, *github.ReleaseAsset, semver.Version, bool) {
// Generate candidates
suffixes := make([]string, 0, 2*7*2)
@ -143,6 +152,7 @@ func (up *Updater) DetectVersion(slug string, version string) (release *Release,
url,
asset.GetSize(),
asset.GetID(),
-1,
rel.GetHTMLURL(),
rel.GetBody(),
rel.GetName(),
@ -150,6 +160,16 @@ func (up *Updater) DetectVersion(slug string, version string) (release *Release,
repo[0],
repo[1],
}
if up.validator != nil {
validationName := asset.GetName()+up.validator.Suffix()
validationAsset, ok := findValidationAsset(rel, validationName)
if !ok {
return nil, false, fmt.Errorf("Failed finding validation file %q", validationName)
}
release.ValidationAssetID = validationAsset.GetID()
}
return release, true, nil
}

View File

@ -16,6 +16,8 @@ type Release struct {
AssetByteSize int
// AssetID is the ID of the asset on GitHub
AssetID int64
// ValidationAssetID is the ID of additional validaton asset on GitHub
ValidationAssetID int64
// URL is a URL to release page for browsing
URL string
// ReleaseNotes is a release notes of the release

9
selfupdate/testdata/Test.crt vendored Normal file
View File

@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU
ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD
EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A
QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX
rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh
AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ
7cugOPws7/OoUo1124LKPugISg==
-----END CERTIFICATE-----

14
selfupdate/testdata/Test.pem vendored Normal file
View File

@ -0,0 +1,14 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJvTkRedVrQDNjCb9/RfVjzRwz8S059Y1J6w2N8gy8jVoAoGCCqGSM49
AwEHoUQDQgAEs8fjo/Mi5A3c2v2YxV6AQPJnr70qYMEpsmqn0BTcI8RhZUgB46tW
qeDYdO15yQKbZjfI/dr0fvS21jyW0GSXrA==
-----END EC PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU
ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD
EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A
QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX
rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh
AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ
7cugOPws7/OoUo1124LKPugISg==
-----END CERTIFICATE-----

1
selfupdate/testdata/foo.zip.sha256 vendored Normal file
View File

@ -0,0 +1 @@
e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip

BIN
selfupdate/testdata/foo.zip.sig vendored Normal file

Binary file not shown.

View File

@ -1,8 +1,10 @@
package selfupdate
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
@ -13,9 +15,7 @@ import (
"github.com/inconshreveable/go-update"
)
func uncompressAndUpdate(src io.ReadCloser, assetURL, cmdPath string) error {
defer src.Close()
func uncompressAndUpdate(src io.Reader, assetURL, cmdPath string) error {
_, cmd := filepath.Split(cmdPath)
asset, err := UncompressCommand(src, assetURL, cmd)
if err != nil {
@ -67,7 +67,41 @@ func (up *Updater) UpdateTo(rel *Release, cmdPath string) error {
return err
}
}
return uncompressAndUpdate(src, rel.AssetURL, cmdPath)
defer src.Close()
data, err := ioutil.ReadAll(src)
if err != nil {
return fmt.Errorf("Failed reading asset body: %v", err)
}
if up.validator == nil {
return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath)
}
validationSrc, validationRedirectURL, err := up.api.Repositories.DownloadReleaseAsset(up.apiCtx, rel.RepoOwner, rel.RepoName, rel.ValidationAssetID)
if err != nil {
return fmt.Errorf("Failed to call GitHub Releases API for getting an validation asset(ID: %d) for repository '%s/%s': %s", rel.ValidationAssetID, rel.RepoOwner, rel.RepoName, err)
}
if validationRedirectURL != "" {
log.Println("Redirect URL was returned while trying to download a release validation asset from GitHub API. Falling back to downloading from asset URL directly:", redirectURL)
validationSrc, err = up.downloadDirectlyFromURL(validationRedirectURL)
if err != nil {
return err
}
}
defer validationSrc.Close()
validationData, err := ioutil.ReadAll(validationSrc)
if err != nil {
return fmt.Errorf("Failed reading validation asset body: %v", err)
}
if err := up.validator.Validate(data, validationData); err != nil {
return fmt.Errorf("Failed validating asset content: %v", err)
}
return uncompressAndUpdate(bytes.NewReader(data), rel.AssetURL, cmdPath)
}
// UpdateCommand updates a given command binary to the latest version.
@ -129,6 +163,7 @@ func UpdateTo(assetURL, cmdPath string) error {
if err != nil {
return err
}
defer src.Close()
return uncompressAndUpdate(src, assetURL, cmdPath)
}

View File

@ -15,6 +15,7 @@ import (
type Updater struct {
api *github.Client
apiCtx context.Context
validator Validator
}
// Config represents the configuration of self-update.
@ -27,6 +28,8 @@ type Config struct {
// EnterpriseUploadURL is a URL to upload stuffs to GitHub Enterprise instance. This is often the same as an API base URL.
// So if this field is not set and EnterpriseBaseURL is set, EnterpriseBaseURL is also set to this field.
EnterpriseUploadURL string
// Validator represents types which enable additional validation of downloaded release.
Validator Validator
}
func newHTTPClient(ctx context.Context, token string) *http.Client {
@ -52,7 +55,7 @@ func NewUpdater(config Config) (*Updater, error) {
if config.EnterpriseBaseURL == "" {
client := github.NewClient(hc)
return &Updater{client, ctx}, nil
return &Updater{client, ctx, config.Validator}, nil
}
u := config.EnterpriseUploadURL
@ -63,7 +66,7 @@ func NewUpdater(config Config) (*Updater, error) {
if err != nil {
return nil, err
}
return &Updater{client, ctx}, nil
return &Updater{api: client, apiCtx: ctx, validator: config.Validator}, nil
}
// DefaultUpdater creates a new updater instance with default configuration.
@ -76,5 +79,5 @@ func DefaultUpdater() *Updater {
}
ctx := context.Background()
client := newHTTPClient(ctx, token)
return &Updater{github.NewClient(client), ctx}
return &Updater{api: github.NewClient(client), apiCtx: ctx}
}

73
selfupdate/validate.go Normal file
View File

@ -0,0 +1,73 @@
package selfupdate
import (
"crypto/ecdsa"
"crypto/sha256"
"encoding/asn1"
"fmt"
"math/big"
)
// Validator represents an interface which enables additional validation of releases.
type Validator interface {
// Validate validates release bytes against an additional asset bytes.
// See SHA2Validator or ECDSAValidator for more information.
Validate(release, asset []byte) error
// Suffix describes the additional file ending which is used for finding the
// additional asset.
Suffix() string
}
// SHA2Validator specifies a SHA256 validator for additional file validation
// before updating.
type SHA2Validator struct {
}
// Validate validates the SHA256 sum of the release against the contents of an
// additional asset file.
func (v *SHA2Validator) Validate(release, asset []byte) error {
calculatedHash := fmt.Sprintf("%x", sha256.Sum256(release))
hash := fmt.Sprintf("%s", asset[:sha256.BlockSize])
if calculatedHash != hash {
return fmt.Errorf("sha2: validation failed: hash mismatch: expected=%q, got=%q", calculatedHash, hash)
}
return nil
}
// Suffix returns the suffix for SHA2 validation.
func (v *SHA2Validator) Suffix() string {
return ".sha256"
}
// ECDSAValidator specifies a ECDSA validator for additional file validation
// before updating.
type ECDSAValidator struct {
PublicKey *ecdsa.PublicKey
}
// Validate validates the ECDSA signature the release against the signature
// contained in an addtional asset file.
// additional asset file.
func (v *ECDSAValidator) Validate(input, signature []byte) error {
h := sha256.New()
h.Write(input)
var rs struct {
R *big.Int
S *big.Int
}
if _, err := asn1.Unmarshal(signature, &rs); err != nil {
return fmt.Errorf("failed to unmarshal ecdsa signature: %v", err)
}
if !ecdsa.Verify(v.PublicKey, h.Sum([]byte{}), rs.R, rs.S) {
return fmt.Errorf("ecdsa: signature verification failed")
}
return nil
}
// Suffix returns the suffix for ECDSA validation.
func (v *ECDSAValidator) Suffix() string {
return ".sig"
}

114
selfupdate/validate_test.go Normal file
View File

@ -0,0 +1,114 @@
package selfupdate
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"io/ioutil"
"testing"
)
func TestSHA2Validator(t *testing.T) {
validator := &SHA2Validator{}
data, err := ioutil.ReadFile("testdata/foo.zip")
if err != nil {
t.Fatal(err)
}
hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256")
if err != nil {
t.Fatal(err)
}
if err := validator.Validate(data, hashData); err != nil {
t.Fatal(err)
}
}
func TestSHA2ValidatorFail(t *testing.T) {
validator := &SHA2Validator{}
data, err := ioutil.ReadFile("testdata/foo.zip")
if err != nil {
t.Fatal(err)
}
hashData, err := ioutil.ReadFile("testdata/foo.zip.sha256")
if err != nil {
t.Fatal(err)
}
hashData[0] = '0'
if err := validator.Validate(data, hashData); err == nil {
t.Fatal(err)
}
}
func TestECDSAValidator(t *testing.T) {
pemData, err := ioutil.ReadFile("testdata/Test.crt")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(pemData)
if block == nil || block.Type != "CERTIFICATE" {
t.Fatalf("failed to decode PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to parse certificate")
}
pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok {
t.Errorf("PublicKey is not ECDSA")
}
validator := &ECDSAValidator{
PublicKey: pubKey,
}
data, err := ioutil.ReadFile("testdata/foo.zip")
if err != nil {
t.Fatal(err)
}
signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig")
if err != nil {
t.Fatal(err)
}
if err := validator.Validate(data, signatureData); err != nil {
t.Fatal(err)
}
}
func TestECDSAValidatorFail(t *testing.T) {
pemData, err := ioutil.ReadFile("testdata/Test.crt")
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(pemData)
if block == nil || block.Type != "CERTIFICATE" {
t.Fatalf("failed to decode PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("failed to parse certificate")
}
pubKey, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok {
t.Errorf("PublicKey is not ECDSA")
}
validator := &ECDSAValidator{
PublicKey: pubKey,
}
data, err := ioutil.ReadFile("testdata/foo.tar.xz")
if err != nil {
t.Fatal(err)
}
signatureData, err := ioutil.ReadFile("testdata/foo.zip.sig")
if err != nil {
t.Fatal(err)
}
if err := validator.Validate(data, signatureData); err == nil {
t.Fatal(err)
}
}