From 0e5eb4b6def89e6370ab1db665a03bad0cd33c0d Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 12 Aug 2022 13:19:03 +0200 Subject: [PATCH] Add support for signed updates --- updater/fetch.go | 120 ++++++++++++++++++++++++++++++++++++++++- updater/file.go | 39 ++++++++++++++ updater/get.go | 15 +++++- updater/registry.go | 28 ++++++++++ updater/resource.go | 32 +++++++++++ updater/signing.go | 127 ++++++++++++++++++++++++++++++++++++++++++++ updater/storage.go | 6 +++ 7 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 updater/signing.go diff --git a/updater/fetch.go b/updater/fetch.go index adad517..fb15f40 100644 --- a/updater/fetch.go +++ b/updater/fetch.go @@ -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 { diff --git a/updater/file.go b/updater/file.go index 8c78978..21bb94f 100644 --- a/updater/file.go +++ b/updater/file.go @@ -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) diff --git a/updater/get.go b/updater/get.go index b04dbbc..37bc4a3 100644 --- a/updater/get.go +++ b/updater/get.go @@ -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 } } diff --git a/updater/registry.go b/updater/registry.go index 6267a33..7200cd0 100644 --- a/updater/registry.go +++ b/updater/registry.go @@ -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 } diff --git a/updater/resource.go b/updater/resource.go index eeda5db..1f097ba 100644 --- a/updater/resource.go +++ b/updater/resource.go @@ -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())) } diff --git a/updater/signing.go b/updater/signing.go new file mode 100644 index 0000000..fca16eb --- /dev/null +++ b/updater/signing.go @@ -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 +} diff --git a/updater/storage.go b/updater/storage.go index e95895f..c0020f4 100644 --- a/updater/storage.go +++ b/updater/storage.go @@ -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 {