Skip to content

Commit

Permalink
Add support for signed updates
Browse files Browse the repository at this point in the history
  • Loading branch information
dhaavi committed Sep 23, 2022
1 parent 85a84c1 commit 0e5eb4b
Show file tree
Hide file tree
Showing 7 changed files with 363 additions and 4 deletions.
120 changes: 118 additions & 2 deletions updater/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ package updater
import (
"bytes"
"context"
"errors"
"fmt"
"hash"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"time"

"github.com/safing/jess/filesig"
"github.com/safing/jess/lhash"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils/renameio"
)
Expand All @@ -33,6 +38,26 @@ func (reg *ResourceRegistry) fetchFile(ctx context.Context, client *http.Client,
return fmt.Errorf("could not create updates folder: %s", dirPath)
}

// If verification is enabled, download signature first.
var (
verifiedHash *lhash.LabeledHash
sigFileData []byte
verifOpts = reg.GetVerificationOptions(rv.resource.Identifier)
)
if verifOpts != nil {
verifiedHash, sigFileData, err = reg.fetchAndVerifySigFile(ctx, client, rv, verifOpts, tries)
if err != nil {
switch verifOpts.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("signature verification failed: %w", err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to verify downloaded signature of %s: %s", reg.Name, rv.versionedPath(), err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to verify downloaded signature of %s: %s", reg.Name, rv.versionedPath(), err)
}
}
}

// open file for writing
atomicFile, err := renameio.TempFile(reg.tmpDir.Path, rv.storagePath())
if err != nil {
Expand All @@ -49,15 +74,56 @@ func (reg *ResourceRegistry) fetchFile(ctx context.Context, client *http.Client,
_ = resp.Body.Close()
}()

// download and write file
n, err := io.Copy(atomicFile, resp.Body)
// Write to the hasher at the same time, if needed.
var hasher hash.Hash
var writeDst io.Writer = atomicFile
if verifiedHash != nil && verifOpts.DownloadPolicy != SignaturePolicyDisable {
hasher = verifiedHash.Algorithm().RawHasher()
writeDst = io.MultiWriter(hasher, atomicFile)
}

// Download and write file.
n, err := io.Copy(writeDst, resp.Body)
if err != nil {
return fmt.Errorf("failed to download %q: %w", downloadURL, err)
}
if resp.ContentLength != n {
return fmt.Errorf("failed to finish download of %q: written %d out of %d bytes", downloadURL, n, resp.ContentLength)
}

// Before file is finalized, check if hash, if available.
if hasher != nil {
downloadDigest := hasher.Sum(nil)
if verifiedHash.EqualRaw(downloadDigest) {
log.Infof("%s: verified signature of %s", reg.Name, downloadURL)
} else {
switch verifOpts.DownloadPolicy {
case SignaturePolicyRequire:
return errors.New("file does not match signed checksum")
case SignaturePolicyWarn:
log.Warningf("%s: checksum does not match file from %s", reg.Name, downloadURL)
case SignaturePolicyDisable:
log.Debugf("%s: checksum does not match file from %s", reg.Name, downloadURL)
}
}
}

// Write signature file, if we have one.
if len(sigFileData) > 0 {
sigFilePath := rv.storagePath() + filesig.Extension
err := ioutil.WriteFile(sigFilePath, sigFileData, 0o0644) //nolint:gosec
if err != nil {
switch verifOpts.DownloadPolicy {
case SignaturePolicyRequire:
return fmt.Errorf("failed to write signature file %s: %w", sigFilePath, err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to write signature file %s: %s", reg.Name, sigFilePath, err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to write signature file %s: %s", reg.Name, sigFilePath, err)
}
}
}

// finalize file
err = atomicFile.CloseAtomicallyReplace()
if err != nil {
Expand All @@ -76,6 +142,56 @@ func (reg *ResourceRegistry) fetchFile(ctx context.Context, client *http.Client,
return nil
}

func (reg *ResourceRegistry) fetchAndVerifySigFile(ctx context.Context, client *http.Client, rv *ResourceVersion, verifOpts *VerificationOptions, tries int) (*lhash.LabeledHash, []byte, error) {
// Download signature file.
resp, _, err := reg.makeRequest(ctx, client, rv.versionedPath()+filesig.Extension, tries)
if err != nil {
return nil, nil, err
}
defer func() {
_ = resp.Body.Close()
}()
sigFileData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}

// Extract all signatures.
sigs, err := filesig.ParseSigFile(sigFileData)
switch {
case len(sigs) == 0 && err != nil:
return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
case len(sigs) == 0:
return nil, nil, errors.New("no signatures found in signature file")
case err != nil:
return nil, nil, fmt.Errorf("failed to parse signature file: %w", err)
}

// Verify all signatures.
var verifiedHash *lhash.LabeledHash
for _, sig := range sigs {
fd, err := filesig.VerifyFileData(
sig,
rv.SigningMetadata(),
verifOpts.TrustStore,
)
if err != nil {
return nil, sigFileData, err
}

// Save or check verified hash.
if verifiedHash == nil {
verifiedHash = fd.FileHash()
} else if !fd.FileHash().Equal(verifiedHash) {
// Return an error if two valid hashes mismatch.
// For simplicity, all hash algorithms must be the same for now.
return nil, sigFileData, errors.New("file hashes from different signatures do not match")
}
}

return verifiedHash, sigFileData, nil
}

func (reg *ResourceRegistry) fetchData(ctx context.Context, client *http.Client, downloadPath string, tries int) ([]byte, error) {
// backoff when retrying
if tries > 0 {
Expand Down
39 changes: 39 additions & 0 deletions updater/file.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package updater

import (
"fmt"
"io"
"os"
"strings"

semver "github.com/hashicorp/go-version"

"github.com/safing/jess/filesig"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils"
)
Expand Down Expand Up @@ -45,6 +47,43 @@ func (file *File) Path() string {
return file.storagePath
}

// SigningMetadata returns the metadata to be included in signatures.
func (file *File) SigningMetadata() map[string]string {
return map[string]string{
"id": file.Identifier(),
"version": file.Version(),
}
}

// Verify verifies the given file.
func (file *File) Verify() ([]*filesig.FileData, error) {
// Check if verification is configured.
verifOpts := file.resource.registry.GetVerificationOptions(file.resource.Identifier)
if verifOpts == nil {
return nil, ErrVerificationNotConfigured
}

// Verify file.
fileData, err := filesig.VerifyFile(
file.storagePath,
file.storagePath+filesig.Extension,
file.SigningMetadata(),
verifOpts.TrustStore,
)
if err != nil {
switch verifOpts.DiskLoadPolicy {
case SignaturePolicyRequire:
return nil, fmt.Errorf("failed to verify file: %w", err)
case SignaturePolicyWarn:
log.Warningf("%s: failed to verify %s: %s", file.resource.registry.Name, file.storagePath, err)
case SignaturePolicyDisable:
log.Debugf("%s: failed to verify %s: %s", file.resource.registry.Name, file.storagePath, err)
}
}

return fileData, nil
}

// Blacklist notifies the update system that this file is somehow broken, and should be ignored from now on, until restarted.
func (file *File) Blacklist() error {
return file.resource.Blacklist(file.version.VersionNumber)
Expand Down
15 changes: 13 additions & 2 deletions updater/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (

// Errors returned by the updater package.
var (
ErrNotFound = errors.New("the requested file could not be found")
ErrNotAvailableLocally = errors.New("the requested file is not available locally")
ErrNotFound = errors.New("the requested file could not be found")
ErrNotAvailableLocally = errors.New("the requested file is not available locally")
ErrVerificationNotConfigured = errors.New("verification not configured for this resource")
)

// GetFile returns the selected (mostly newest) file with the given
Expand All @@ -29,6 +30,14 @@ func (reg *ResourceRegistry) GetFile(identifier string) (*File, error) {
// check if file is available locally
if file.version.Available {
file.markActiveWithLocking()

// Verify file, if configured.
_, err := file.Verify()
if err != nil && !errors.Is(err, ErrVerificationNotConfigured) {
// FIXME: If verification is required, try deleting the resource and downloading it again.
return nil, fmt.Errorf("failed to verify file: %w", err)
}

return file, nil
}

Expand All @@ -52,6 +61,8 @@ func (reg *ResourceRegistry) GetFile(identifier string) (*File, error) {
log.Tracef("%s: failed to download %s: %s, retrying (%d)", reg.Name, file.versionedPath, err, tries+1)
} else {
file.markActiveWithLocking()

// TODO: We just download the file - should we verify it again?
return file, nil
}
}
Expand Down
28 changes: 28 additions & 0 deletions updater/registry.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package updater

import (
"fmt"
"os"
"runtime"
"sync"
Expand Down Expand Up @@ -28,6 +29,12 @@ type ResourceRegistry struct {
MandatoryUpdates []string
AutoUnpack []string

// Verification holds a map of VerificationOptions assigned to their
// applicable identifier path prefix.
// Use an empty string to denote the default.
// Use empty options to disable verification for a path prefix.
Verification map[string]*VerificationOptions

// UsePreReleases signifies that pre-releases should be used when selecting a
// version. Even if false, a pre-release version will still be used if it is
// defined as the current version by an index.
Expand Down Expand Up @@ -76,6 +83,27 @@ func (reg *ResourceRegistry) Initialize(storageDir *utils.DirStructure) error {
log.Warningf("%s: failed to create tmp dir: %s", reg.Name, err)
}

// Check verification options.
if reg.Verification != nil {
for prefix, opts := range reg.Verification {
// Check if verification is disable for this prefix.
if opts == nil {
continue
}

// If enabled, a trust store is required.
if opts.TrustStore == nil {
return fmt.Errorf("verification enabled for prefix %q, but no trust store configured", prefix)
}

// Warn if all policies are disabled.
if opts.DownloadPolicy == SignaturePolicyDisable &&
opts.DiskLoadPolicy == SignaturePolicyDisable {
log.Warningf("%s: verification enabled for prefix %q, but all policies set to disable", reg.Name, prefix)
}
}
}

return nil
}

Expand Down
32 changes: 32 additions & 0 deletions updater/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,42 @@ boundarySearch:
res.Versions = res.Versions[purgeBoundary:]
}

// SigningMetadata returns the metadata to be included in signatures.
func (rv *ResourceVersion) SigningMetadata() map[string]string {
return map[string]string{
"id": rv.resource.Identifier,
"version": rv.VersionNumber,
}
}

// GetFile returns the version as a *File.
// It locks the resource for doing so.
func (rv *ResourceVersion) GetFile() *File {
rv.resource.Lock()
defer rv.resource.Unlock()

// check for notifier
if rv.resource.notifier == nil {
// create new notifier
rv.resource.notifier = newNotifier()
}

// create file
return &File{
resource: rv.resource,
version: rv,
notifier: rv.resource.notifier,
versionedPath: rv.versionedPath(),
storagePath: rv.storagePath(),
}
}

// versionedPath returns the versioned identifier.
func (rv *ResourceVersion) versionedPath() string {
return GetVersionedPath(rv.resource.Identifier, rv.VersionNumber)
}

// storagePath returns the absolute storage path.
func (rv *ResourceVersion) storagePath() string {
return filepath.Join(rv.resource.registry.storageDir.Path, filepath.FromSlash(rv.versionedPath()))
}
Loading

0 comments on commit 0e5eb4b

Please sign in to comment.