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:
parent
6329a43531
commit
116dfa144d
39
README.md
39
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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIBLzCB1qADAgECAgglJIDaK1tbjjAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDEwRU
|
||||
ZXN0MCAXDTE4MTEwNjE1MTcwMFoYDzIxMTgxMTA2MTUxNzAwWjAPMQ0wCwYDVQQD
|
||||
EwRUZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs8fjo/Mi5A3c2v2YxV6A
|
||||
QPJnr70qYMEpsmqn0BTcI8RhZUgB46tWqeDYdO15yQKbZjfI/dr0fvS21jyW0GSX
|
||||
rKMaMBgwCwYDVR0PBAQDAgXgMAkGA1UdEwQCMAAwCgYIKoZIzj0EAwIDSAAwRQIh
|
||||
AI1pUr0nrw3m++sR8HEBoejM5Qh1QJA7gF9y1jY6rc/aAiALFPJXckSLAQuq5IvQ
|
||||
7cugOPws7/OoUo1124LKPugISg==
|
||||
-----END CERTIFICATE-----
|
|
@ -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-----
|
|
@ -0,0 +1 @@
|
|||
e412095724426c984940efde02ea000251a12b37506c977341e0a07600dbfcb6 foo.zip
|
Binary file not shown.
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue