Add support for signed updates

This commit is contained in:
Daniel 2022-08-12 13:19:03 +02:00
parent 85a84c1210
commit 0e5eb4b6de
7 changed files with 363 additions and 4 deletions

View file

@ -3,8 +3,11 @@ package updater
import (
"bytes"
"context"
"errors"
"fmt"
"hash"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
@ -12,6 +15,8 @@ import (
"path/filepath"
"time"
"github.com/safing/jess/filesig"
"github.com/safing/jess/lhash"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils/renameio"
)
@ -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 {
@ -49,8 +74,16 @@ 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)
}
@ -58,6 +91,39 @@ func (reg *ResourceRegistry) fetchFile(ctx context.Context, client *http.Client,
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 {
@ -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 {

View file

@ -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"
)
@ -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)

View file

@ -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
@ -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
}
@ -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
}
}

View file

@ -1,6 +1,7 @@
package updater
import (
"fmt"
"os"
"runtime"
"sync"
@ -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.
@ -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
}

View file

@ -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()))
}

127
updater/signing.go Normal file
View file

@ -0,0 +1,127 @@
package updater
import (
"errors"
"fmt"
"strings"
"time"
"github.com/safing/portbase/formats/dsd"
"github.com/safing/jess"
)
// VerificationOptions holds options for verification of files.
type VerificationOptions struct {
TrustStore jess.TrustStore
DownloadPolicy SignaturePolicy
DiskLoadPolicy SignaturePolicy
}
// GetVerificationOptions returns the verification options for the given identifier.
func (reg *ResourceRegistry) GetVerificationOptions(identifier string) *VerificationOptions {
if reg.Verification == nil {
return nil
}
var (
longestPrefix = -1
bestMatch *VerificationOptions
)
for prefix, opts := range reg.Verification {
if len(prefix) > longestPrefix && strings.HasPrefix(identifier, prefix) {
longestPrefix = len(prefix)
bestMatch = opts
}
}
return bestMatch
}
// SignaturePolicy defines behavior in case of errors.
type SignaturePolicy uint8
// Signature Policies.
const (
// SignaturePolicyRequire fails on any error.
SignaturePolicyRequire = iota
// SignaturePolicyWarn only warns on errors.
SignaturePolicyWarn
// SignaturePolicyDisable only downloads signatures, but does not verify them.
SignaturePolicyDisable
)
// IndexFile represents an index file.
type IndexFile struct {
Channel string
Published time.Time
Expires time.Time
Versions map[string]string
}
var (
// ErrIndexFromFuture is returned when a signed index is parsed with a
// Published timestamp that lies in the future.
ErrIndexFromFuture = errors.New("index is from the future")
// ErrIndexIsOlder is returned when a signed index is parsed with an older
// Published timestamp than the current Published timestamp.
ErrIndexIsOlder = errors.New("index is older than the current one")
// ErrIndexChannelMismatch is returned when a signed index is parsed with a
// different channel that the expected one.
ErrIndexChannelMismatch = errors.New("index does not match the expected channel")
// ErrIndexExpired is returned when a signed index is parsed with a Expires
// timestamp in the past.
ErrIndexExpired = errors.New("index has expired")
verificationRequirements = jess.NewRequirements().
Remove(jess.Confidentiality).
Remove(jess.RecipientAuthentication)
)
// ParseIndex parses the signed index and checks if it is valid.
func ParseIndex(indexData []byte, verifOpts *VerificationOptions, channel string, currentPublished time.Time) (*IndexFile, error) {
// FIXME: fall back to the old index format.
// FIXME: use this function for index parsing.
// Parse data.
letter, err := jess.LetterFromDSD(indexData)
if err != nil {
return nil, fmt.Errorf("failed to parse signed index: %w", err)
}
// Verify signatures.
signedIndexData, err := letter.Open(verificationRequirements, verifOpts.TrustStore)
if err != nil {
return nil, fmt.Errorf("failed to verify signature: %w", err)
}
// Load into struct.
signedIndex := &IndexFile{}
_, err = dsd.Load(signedIndexData, signedIndex)
if err != nil {
return nil, fmt.Errorf("failed to parse signed index data: %w", err)
}
// Check the index metadata.
switch {
case time.Now().Before(signedIndex.Published):
return signedIndex, ErrIndexFromFuture
case time.Now().After(signedIndex.Expires):
return signedIndex, ErrIndexExpired
case currentPublished.After(signedIndex.Published):
return signedIndex, ErrIndexIsOlder
case channel != "" && channel != signedIndex.Channel:
return signedIndex, ErrIndexChannelMismatch
}
return signedIndex, nil
}

View file

@ -11,6 +11,7 @@ import (
"path/filepath"
"strings"
"github.com/safing/jess/filesig"
"github.com/safing/portbase/log"
"github.com/safing/portbase/utils"
)
@ -51,6 +52,11 @@ func (reg *ResourceRegistry) ScanStorage(root string) error {
return nil
}
// Ignore file signatures.
if strings.HasSuffix(path, filesig.Extension) {
return nil
}
// get relative path to storage
relativePath, err := filepath.Rel(reg.storageDir.Path, path)
if err != nil {