mirror of
https://github.com/safing/portbase
synced 2025-09-01 10:09:50 +00:00
Add support for signed updates
This commit is contained in:
parent
85a84c1210
commit
0e5eb4b6de
7 changed files with 363 additions and 4 deletions
120
updater/fetch.go
120
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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
127
updater/signing.go
Normal 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
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue