mirror of
https://github.com/safing/portmaster
synced 2025-04-07 12:39:09 +00:00
346 lines
11 KiB
Go
346 lines
11 KiB
Go
package updater
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/safing/jess/filesig"
|
|
"github.com/safing/jess/lhash"
|
|
"github.com/safing/portmaster/base/log"
|
|
"github.com/safing/portmaster/base/utils"
|
|
"github.com/safing/portmaster/base/utils/renameio"
|
|
)
|
|
|
|
func (reg *ResourceRegistry) fetchFile(ctx context.Context, client *http.Client, rv *ResourceVersion, tries int) error {
|
|
// backoff when retrying
|
|
if tries > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil // module is shutting down
|
|
case <-time.After(time.Duration(tries*tries) * time.Second):
|
|
}
|
|
}
|
|
|
|
// check destination dir
|
|
dirPath := filepath.Dir(rv.storagePath())
|
|
err := reg.storageDir.EnsureAbsPath(dirPath)
|
|
if err != nil {
|
|
return fmt.Errorf("could not create updates folder: %s", dirPath)
|
|
}
|
|
|
|
// If verification is enabled, download signature first.
|
|
var (
|
|
verifiedHash *lhash.LabeledHash
|
|
sigFileData []byte
|
|
)
|
|
if rv.resource.VerificationOptions != nil {
|
|
verifiedHash, sigFileData, err = reg.fetchAndVerifySigFile(
|
|
ctx, client,
|
|
rv.resource.VerificationOptions,
|
|
rv.versionedSigPath(), rv.SigningMetadata(),
|
|
tries,
|
|
)
|
|
if err != nil {
|
|
switch rv.resource.VerificationOptions.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 {
|
|
return fmt.Errorf("could not create temp file for download: %w", err)
|
|
}
|
|
defer atomicFile.Cleanup() //nolint:errcheck // ignore error for now, tmp dir will be cleaned later again anyway
|
|
|
|
// start file download
|
|
resp, downloadURL, err := reg.makeRequest(ctx, client, rv.versionedPath(), tries)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
// Write to the hasher at the same time, if needed.
|
|
var hasher hash.Hash
|
|
var writeDst io.Writer = atomicFile
|
|
if verifiedHash != nil {
|
|
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 rv.resource.VerificationOptions.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)
|
|
}
|
|
|
|
// Reset hasher to signal that the sig should not be written.
|
|
hasher = nil
|
|
}
|
|
}
|
|
|
|
// Write signature file, if we have one and if verification succeeded.
|
|
if len(sigFileData) > 0 && hasher != nil {
|
|
sigFilePath := rv.storagePath() + filesig.Extension
|
|
err := os.WriteFile(sigFilePath, sigFileData, 0o0644) //nolint:gosec
|
|
if err != nil {
|
|
switch rv.resource.VerificationOptions.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 {
|
|
return fmt.Errorf("%s: failed to finalize file %s: %w", reg.Name, rv.storagePath(), err)
|
|
}
|
|
// set permissions
|
|
// TODO: distinguish between executable and non executable files.
|
|
err = utils.SetExecPermission(rv.storagePath(), utils.PublicReadPermission)
|
|
if err != nil {
|
|
log.Warningf("%s: failed to set permissions on downloaded file %s: %s", reg.Name, rv.storagePath(), err)
|
|
}
|
|
|
|
log.Debugf("%s: fetched %s and stored to %s", reg.Name, downloadURL, rv.storagePath())
|
|
return nil
|
|
}
|
|
|
|
func (reg *ResourceRegistry) fetchMissingSig(ctx context.Context, client *http.Client, rv *ResourceVersion, tries int) error {
|
|
// backoff when retrying
|
|
if tries > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil // module is shutting down
|
|
case <-time.After(time.Duration(tries*tries) * time.Second):
|
|
}
|
|
}
|
|
|
|
// Check destination dir.
|
|
dirPath := filepath.Dir(rv.storagePath())
|
|
err := reg.storageDir.EnsureAbsPath(dirPath)
|
|
if err != nil {
|
|
return fmt.Errorf("could not create updates folder: %s", dirPath)
|
|
}
|
|
|
|
// Download and verify the missing signature.
|
|
verifiedHash, sigFileData, err := reg.fetchAndVerifySigFile(
|
|
ctx, client,
|
|
rv.resource.VerificationOptions,
|
|
rv.versionedSigPath(), rv.SigningMetadata(),
|
|
tries,
|
|
)
|
|
if err != nil {
|
|
switch rv.resource.VerificationOptions.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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Check if the signature matches the resource file.
|
|
ok, err := verifiedHash.MatchesFile(rv.storagePath())
|
|
if err != nil {
|
|
switch rv.resource.VerificationOptions.DownloadPolicy {
|
|
case SignaturePolicyRequire:
|
|
return fmt.Errorf("error while verifying resource file: %w", err)
|
|
case SignaturePolicyWarn:
|
|
log.Warningf("%s: error while verifying resource file %s", reg.Name, rv.storagePath())
|
|
case SignaturePolicyDisable:
|
|
log.Debugf("%s: error while verifying resource file %s", reg.Name, rv.storagePath())
|
|
}
|
|
return nil
|
|
}
|
|
if !ok {
|
|
switch rv.resource.VerificationOptions.DownloadPolicy {
|
|
case SignaturePolicyRequire:
|
|
return errors.New("resource file does not match signed checksum")
|
|
case SignaturePolicyWarn:
|
|
log.Warningf("%s: checksum does not match resource file from %s", reg.Name, rv.storagePath())
|
|
case SignaturePolicyDisable:
|
|
log.Debugf("%s: checksum does not match resource file from %s", reg.Name, rv.storagePath())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Write signature file.
|
|
err = os.WriteFile(rv.storageSigPath(), sigFileData, 0o0644) //nolint:gosec
|
|
if err != nil {
|
|
switch rv.resource.VerificationOptions.DownloadPolicy {
|
|
case SignaturePolicyRequire:
|
|
return fmt.Errorf("failed to write signature file %s: %w", rv.storageSigPath(), err)
|
|
case SignaturePolicyWarn:
|
|
log.Warningf("%s: failed to write signature file %s: %s", reg.Name, rv.storageSigPath(), err)
|
|
case SignaturePolicyDisable:
|
|
log.Debugf("%s: failed to write signature file %s: %s", reg.Name, rv.storageSigPath(), err)
|
|
}
|
|
}
|
|
|
|
log.Debugf("%s: fetched %s and stored to %s", reg.Name, rv.versionedSigPath(), rv.storageSigPath())
|
|
return nil
|
|
}
|
|
|
|
func (reg *ResourceRegistry) fetchAndVerifySigFile(ctx context.Context, client *http.Client, verifOpts *VerificationOptions, sigFilePath string, requiredMetadata map[string]string, tries int) (*lhash.LabeledHash, []byte, error) {
|
|
// Download signature file.
|
|
resp, _, err := reg.makeRequest(ctx, client, sigFilePath, 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,
|
|
requiredMetadata,
|
|
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) (fileData []byte, downloadedFrom string, err error) {
|
|
// backoff when retrying
|
|
if tries > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, "", nil // module is shutting down
|
|
case <-time.After(time.Duration(tries*tries) * time.Second):
|
|
}
|
|
}
|
|
|
|
// start file download
|
|
resp, downloadURL, err := reg.makeRequest(ctx, client, downloadPath, tries)
|
|
if err != nil {
|
|
return nil, downloadURL, err
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
// download and write file
|
|
buf := bytes.NewBuffer(make([]byte, 0, resp.ContentLength))
|
|
n, err := io.Copy(buf, resp.Body)
|
|
if err != nil {
|
|
return nil, downloadURL, fmt.Errorf("failed to download %q: %w", downloadURL, err)
|
|
}
|
|
if resp.ContentLength != n {
|
|
return nil, downloadURL, fmt.Errorf("failed to finish download of %q: written %d out of %d bytes", downloadURL, n, resp.ContentLength)
|
|
}
|
|
|
|
return buf.Bytes(), downloadURL, nil
|
|
}
|
|
|
|
func (reg *ResourceRegistry) makeRequest(ctx context.Context, client *http.Client, downloadPath string, tries int) (resp *http.Response, downloadURL string, err error) {
|
|
// parse update URL
|
|
updateBaseURL := reg.UpdateURLs[tries%len(reg.UpdateURLs)]
|
|
u, err := url.Parse(updateBaseURL)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to parse update URL %q: %w", updateBaseURL, err)
|
|
}
|
|
// add download path
|
|
u.Path = path.Join(u.Path, downloadPath)
|
|
// compile URL
|
|
downloadURL = u.String()
|
|
|
|
// create request
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create request for %q: %w", downloadURL, err)
|
|
}
|
|
|
|
// set user agent
|
|
if reg.UserAgent != "" {
|
|
req.Header.Set("User-Agent", reg.UserAgent)
|
|
}
|
|
|
|
// start request
|
|
resp, err = client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to make request to %q: %w", downloadURL, err)
|
|
}
|
|
|
|
// check return code
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close()
|
|
return nil, "", fmt.Errorf("failed to fetch %q: %d %s", downloadURL, resp.StatusCode, resp.Status)
|
|
}
|
|
|
|
return resp, downloadURL, err
|
|
}
|