diff --git a/README.md b/README.md index d5fe0dd..92a5f2f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/selfupdate/detect.go b/selfupdate/detect.go index a20a645..a8c61fd 100644 --- a/selfupdate/detect.go +++ b/selfupdate/detect.go @@ -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 } diff --git a/selfupdate/release.go b/selfupdate/release.go index 9ee0cb0..014ac47 100644 --- a/selfupdate/release.go +++ b/selfupdate/release.go @@ -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 diff --git a/selfupdate/testdata/Test.crt b/selfupdate/testdata/Test.crt new file mode 100644 index 0000000..7f4eff3 --- /dev/null +++ b/selfupdate/testdata/Test.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU +ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD +EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A +QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX +rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh +AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ +7cugOPws7/OoUo1124LKPugISg== +-----END CERTIFICATE----- diff --git a/selfupdate/testdata/Test.pem b/selfupdate/testdata/Test.pem new file mode 100644 index 0000000..c240365 --- /dev/null +++ b/selfupdate/testdata/Test.pem @@ -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----- diff --git a/selfupdate/testdata/foo.zip.sha256 b/selfupdate/testdata/foo.zip.sha256 new file mode 100644 index 0000000..9a3192d --- /dev/null +++ b/selfupdate/testdata/foo.zip.sha256 @@ -0,0 +1 @@ +e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip diff --git a/selfupdate/testdata/foo.zip.sig b/selfupdate/testdata/foo.zip.sig new file mode 100644 index 0000000..03d06a6 Binary files /dev/null and b/selfupdate/testdata/foo.zip.sig differ diff --git a/selfupdate/update.go b/selfupdate/update.go index d032da2..705afc8 100644 --- a/selfupdate/update.go +++ b/selfupdate/update.go @@ -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) } diff --git a/selfupdate/updater.go b/selfupdate/updater.go index 322d469..4239233 100644 --- a/selfupdate/updater.go +++ b/selfupdate/updater.go @@ -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} } diff --git a/selfupdate/validate.go b/selfupdate/validate.go new file mode 100644 index 0000000..7d8a63f --- /dev/null +++ b/selfupdate/validate.go @@ -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" +} diff --git a/selfupdate/validate_test.go b/selfupdate/validate_test.go new file mode 100644 index 0000000..f52f2a3 --- /dev/null +++ b/selfupdate/validate_test.go @@ -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) + } +}