mirror of
https://github.com/safing/portmaster
synced 2025-09-07 04:59:16 +00:00
387 lines
10 KiB
Go
387 lines
10 KiB
Go
package updates
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"time"
|
|
|
|
semver "github.com/hashicorp/go-version"
|
|
|
|
"github.com/safing/jess"
|
|
"github.com/safing/jess/filesig"
|
|
"github.com/safing/portmaster/base/utils"
|
|
)
|
|
|
|
// MaxUnpackSize defines the maximum size that is allowed to be unpacked.
|
|
const MaxUnpackSize = 1 << 30 // 2^30 == 1GB
|
|
|
|
const currentPlatform = runtime.GOOS + "_" + runtime.GOARCH
|
|
|
|
var zeroVersion = semver.Must(semver.NewVersion("0.0.0"))
|
|
|
|
// Artifact represents a single file with metadata.
|
|
type Artifact struct {
|
|
Filename string `json:"Filename"`
|
|
SHA256 string `json:"SHA256"`
|
|
URLs []string `json:"URLs"`
|
|
Platform string `json:"Platform,omitempty"`
|
|
Unpack string `json:"Unpack,omitempty"`
|
|
Version string `json:"Version,omitempty"`
|
|
|
|
localFile string
|
|
versionNum *semver.Version
|
|
}
|
|
|
|
// GetFileMode returns the required filesystem permission for the artifact.
|
|
func (a *Artifact) GetFileMode() utils.FSPermission {
|
|
if a.Platform == currentPlatform {
|
|
return utils.PublicReadExecPermission
|
|
}
|
|
|
|
return utils.PublicReadPermission
|
|
}
|
|
|
|
// Path returns the absolute path to the local file.
|
|
func (a *Artifact) Path() string {
|
|
return a.localFile
|
|
}
|
|
|
|
// SemVer returns the version of the artifact.
|
|
func (a *Artifact) SemVer() *semver.Version {
|
|
return a.versionNum
|
|
}
|
|
|
|
// IsNewerThan returns whether the artifact is newer than the given artifact.
|
|
// Returns true if the given artifact is nil.
|
|
// The second return value "ok" is false when version could not be compared.
|
|
// In this case, it is up to the caller to decide how to proceed.
|
|
func (a *Artifact) IsNewerThan(b *Artifact) (newer, ok bool) {
|
|
switch {
|
|
case a == nil:
|
|
return false, false
|
|
case b == nil:
|
|
return true, true
|
|
case a.versionNum == nil:
|
|
return false, false
|
|
case b.versionNum == nil:
|
|
return false, false
|
|
case a.versionNum.GreaterThan(b.versionNum):
|
|
return true, true
|
|
default:
|
|
return false, true
|
|
}
|
|
}
|
|
|
|
func (a *Artifact) export(dir string, indexVersion *semver.Version) *Artifact {
|
|
copied := &Artifact{
|
|
Filename: a.Filename,
|
|
SHA256: a.SHA256,
|
|
URLs: a.URLs,
|
|
Platform: a.Platform,
|
|
Unpack: a.Unpack,
|
|
Version: a.Version,
|
|
localFile: filepath.Join(dir, a.Filename),
|
|
versionNum: a.versionNum,
|
|
}
|
|
|
|
// Make sure we have a version number.
|
|
switch {
|
|
case copied.versionNum != nil:
|
|
// Version already parsed.
|
|
case copied.Version != "":
|
|
// Need to parse version.
|
|
v, err := semver.NewVersion(copied.Version)
|
|
if err == nil {
|
|
copied.versionNum = v
|
|
}
|
|
default:
|
|
// No version defined, inherit index version.
|
|
copied.versionNum = indexVersion
|
|
}
|
|
|
|
return copied
|
|
}
|
|
|
|
// Index represents a collection of artifacts with metadata.
|
|
type Index struct {
|
|
Name string `json:"Name"`
|
|
Version string `json:"Version"`
|
|
Published time.Time `json:"Published"`
|
|
Artifacts []*Artifact `json:"Artifacts"`
|
|
|
|
versionNum *semver.Version
|
|
}
|
|
|
|
// LoadIndex loads and parses an index from the given filename.
|
|
// Leave platform empty to use current platform.
|
|
func LoadIndex(filename string, platform string, trustStore jess.TrustStore) (*Index, error) {
|
|
// Read index file from disk.
|
|
content, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read index file: %w", err)
|
|
}
|
|
|
|
// Parse and return.
|
|
return ParseIndex(content, platform, trustStore)
|
|
}
|
|
|
|
// ParseIndex parses an index from a json string.
|
|
// Leave platform empty to use current platform.
|
|
func ParseIndex(jsonContent []byte, platform string, trustStore jess.TrustStore) (*Index, error) {
|
|
// Verify signature.
|
|
if trustStore != nil {
|
|
if err := filesig.VerifyJSONSignature(jsonContent, trustStore); err != nil {
|
|
return nil, fmt.Errorf("verify: %w", err)
|
|
}
|
|
}
|
|
|
|
// Parse json.
|
|
index := &Index{}
|
|
err := json.Unmarshal(jsonContent, index)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse index: %w", err)
|
|
}
|
|
|
|
// Check platform.
|
|
if platform == "" {
|
|
platform = currentPlatform
|
|
}
|
|
|
|
// Initialize data.
|
|
err = index.init(platform)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return index, nil
|
|
}
|
|
|
|
func (index *Index) init(platform string) error {
|
|
// Parse version number, if set.
|
|
if index.Version != "" {
|
|
versionNum, err := semver.NewVersion(index.Version)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid index version %q: %w", index.Version, err)
|
|
}
|
|
index.versionNum = versionNum
|
|
}
|
|
|
|
// Filter artifacts by platform.
|
|
filtered := make([]*Artifact, 0)
|
|
for _, a := range index.Artifacts {
|
|
if a.Platform == "" || a.Platform == platform {
|
|
filtered = append(filtered, a)
|
|
}
|
|
}
|
|
index.Artifacts = filtered
|
|
|
|
// Parse artifact version numbers.
|
|
for _, a := range index.Artifacts {
|
|
if a.Version != "" {
|
|
v, err := semver.NewVersion(a.Version)
|
|
if err == nil {
|
|
a.versionNum = v
|
|
}
|
|
} else {
|
|
a.Version = index.Version
|
|
a.versionNum = index.versionNum
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CanDoUpgrades returns whether the index is able to follow a secure upgrade path.
|
|
func (index *Index) CanDoUpgrades() error {
|
|
switch {
|
|
case index.versionNum == nil:
|
|
return errors.New("missing version number")
|
|
|
|
case index.Published.IsZero():
|
|
return errors.New("missing publish date")
|
|
|
|
case index.Published.After(time.Now().Add(15 * time.Minute)):
|
|
return fmt.Errorf("is from the future (%s)", time.Until(index.Published).Round(time.Minute))
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ShouldUpgradeTo returns whether the given index is a successor and should be upgraded to.
|
|
func (index *Index) ShouldUpgradeTo(newIndex *Index) error {
|
|
// Check if both indexes can do upgrades.
|
|
if err := index.CanDoUpgrades(); err != nil {
|
|
return fmt.Errorf("current index cannot do upgrades: %w", err)
|
|
}
|
|
if err := newIndex.CanDoUpgrades(); err != nil {
|
|
return fmt.Errorf("new index cannot do upgrade: %w", err)
|
|
}
|
|
|
|
switch {
|
|
case index.versionNum.Equal(zeroVersion):
|
|
// The zero version is used for bootstrapping.
|
|
// Upgrade in any case.
|
|
return nil
|
|
|
|
case index.Name != newIndex.Name:
|
|
return errors.New("new index name does not match")
|
|
|
|
case index.Published.After(newIndex.Published):
|
|
return errors.New("new index is older (time)")
|
|
|
|
case index.versionNum.Segments()[0] > newIndex.versionNum.Segments()[0]:
|
|
// Downgrades are allowed, if they are not breaking changes.
|
|
return errors.New("new index is a breaking change downgrade")
|
|
|
|
case index.Published.Equal(newIndex.Published):
|
|
// "Do nothing".
|
|
return ErrSameIndex
|
|
|
|
default:
|
|
// Upgrade!
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// VerifyArtifacts checks if all artifacts are present in the given dir and have the correct hash.
|
|
func (index *Index) VerifyArtifacts(dir string) error {
|
|
for _, artifact := range index.Artifacts {
|
|
err := CheckSHA256SumFile(filepath.Join(dir, artifact.Filename), artifact.SHA256)
|
|
if err != nil {
|
|
return fmt.Errorf("verify %s: %w", artifact.Filename, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (index *Index) Export(signingKey *jess.Signet, trustStore jess.TrustStore) ([]byte, error) {
|
|
// Serialize to json.
|
|
indexData, err := json.Marshal(index)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("serialize: %w", err)
|
|
}
|
|
|
|
// Do not sign if signing key is not given.
|
|
if signingKey == nil {
|
|
return indexData, nil
|
|
}
|
|
|
|
// Make envelope.
|
|
envelope := jess.NewUnconfiguredEnvelope()
|
|
envelope.SuiteID = jess.SuiteSignV1
|
|
envelope.Senders = []*jess.Signet{signingKey}
|
|
|
|
// Sign json data.
|
|
signedIndex, err := filesig.AddJSONSignature(indexData, envelope, trustStore)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sign: %w", err)
|
|
}
|
|
|
|
return signedIndex, nil
|
|
}
|
|
|
|
// CheckSHA256SumFile checks the sha256sum of the given file.
|
|
func CheckSHA256SumFile(filename string, sha256sum string) error {
|
|
// Check expected hash.
|
|
expectedDigest, err := hex.DecodeString(sha256sum)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid hex encoding for expected hash %s: %w", sha256sum, err)
|
|
}
|
|
if len(expectedDigest) != sha256.Size {
|
|
return fmt.Errorf("invalid size for expected hash %s: %w", sha256sum, err)
|
|
}
|
|
|
|
// Open file for checking.
|
|
file, err := os.Open(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("open file: %w", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
|
|
// Calculate hash of the file.
|
|
fileHash := sha256.New()
|
|
if _, err := io.Copy(fileHash, file); err != nil {
|
|
return fmt.Errorf("read file: %w", err)
|
|
}
|
|
if subtle.ConstantTimeCompare(fileHash.Sum(nil), expectedDigest) != 1 {
|
|
return errors.New("sha256sum mismatch")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckSHA256Sum checks the sha256sum of the given data.
|
|
func CheckSHA256Sum(fileData []byte, sha256sum string) error {
|
|
// Check expected hash.
|
|
expectedDigest, err := hex.DecodeString(sha256sum)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid hex encoding for expected hash %s: %w", sha256sum, err)
|
|
}
|
|
if len(expectedDigest) != sha256.Size {
|
|
return fmt.Errorf("invalid size for expected hash %s: %w", sha256sum, err)
|
|
}
|
|
|
|
// Calculate and compare hash of the file.
|
|
hashSum := sha256.Sum256(fileData)
|
|
if subtle.ConstantTimeCompare(hashSum[:], expectedDigest) != 1 {
|
|
return errors.New("sha256sum mismatch")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// copyAndCheckSHA256Sum copies the file from src to dst and check the sha256 sum.
|
|
// As a special case, if the sha256sum is not given, it is not checked.
|
|
func copyAndCheckSHA256Sum(src, dst, sha256sum string, filePermission utils.FSPermission) error {
|
|
// Check expected hash.
|
|
var expectedDigest []byte
|
|
if sha256sum != "" {
|
|
expectedDigest, err := hex.DecodeString(sha256sum)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid hex encoding for expected hash %s: %w", sha256sum, err)
|
|
}
|
|
if len(expectedDigest) != sha256.Size {
|
|
return fmt.Errorf("invalid size for expected hash %s: %w", sha256sum, err)
|
|
}
|
|
}
|
|
|
|
// Read file from source.
|
|
fileData, err := os.ReadFile(src)
|
|
if err != nil {
|
|
return fmt.Errorf("read src file: %w", err)
|
|
}
|
|
|
|
// Calculate and compare hash of the file.
|
|
if len(expectedDigest) > 0 {
|
|
hashSum := sha256.Sum256(fileData)
|
|
if subtle.ConstantTimeCompare(hashSum[:], expectedDigest) != 1 {
|
|
return errors.New("sha256sum mismatch")
|
|
}
|
|
}
|
|
|
|
// Write to temporary file.
|
|
tmpDst := dst + ".copy"
|
|
err = os.WriteFile(tmpDst, fileData, filePermission.AsUnixPermission())
|
|
if err != nil {
|
|
return fmt.Errorf("write temp dst file: %w", err)
|
|
}
|
|
|
|
// Rename/Move to actual location.
|
|
err = os.Rename(tmpDst, dst)
|
|
if err != nil {
|
|
return fmt.Errorf("rename dst file after write: %w", err)
|
|
}
|
|
utils.SetFilePermission(dst, filePermission)
|
|
|
|
return nil
|
|
}
|