Files
portmaster/service/updates/index.go
T
Alexandr Stelnykovych fab4d3e68b fix: fix variable shadowing in copyAndCheckSHA256Sum
Separate variable declaration from assignment in the SHA256 validation
logic to prevent variable shadowing and ensure proper error handling
scope.
2026-04-10 13:30:22 +03:00

420 lines
12 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
}
func (a *Artifact) String() string {
return fmt.Sprintf("%s(v%s)", a.Filename, a.Version)
}
// 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
// isLocallyGenerated indicates whether the index was generated from a local directory
// rather than downloaded from an official release channel.
//
// When true, the Published field reflects the local generation time rather than an
// official release date. Time-based sanity checks (version/date mismatch detection)
// are therefore skipped when this index is the current one.
isLocallyGenerated bool
}
// 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 fmt.Errorf(
"new index name (%q) does not match current index name (%q)",
newIndex.Name, index.Name,
)
case index.versionNum.LessThan(newIndex.versionNum) && index.Published.After(newIndex.Published) && !index.isLocallyGenerated:
// The new index is newer in version (upgrade), but older in publish date. This is suspicious and should be prevented.
return errors.New("new index has newer version but older publish date")
case index.versionNum.GreaterThan(newIndex.versionNum) && index.Published.Before(newIndex.Published) && !index.isLocallyGenerated:
// The new index is older in version (downgrade), but newer in publish date. This is suspicious and should be prevented.
return errors.New("new index has older version but newer publish date")
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.versionNum.GreaterThan(newIndex.versionNum) && index.Published.After(time.Now().Add(-time.Minute*20)):
// Downgrade. Special case for preventing CDN caching issues:
// If the current index is newer in version and has a publish date that is very recent (just updated),
// the new index is likely an older version being served due to CDN caching.
// Prevent downgrade in this case.
return ErrSameIndex
case index.Published.Equal(newIndex.Published):
// "Do nothing".
return ErrSameIndex
case index.versionNum.Equal(newIndex.versionNum) && index.isLocallyGenerated:
// This is especially important for locally generated indexes, where the publish date is not a reliable indicator of the index's age.
// "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 != "" {
var err error
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
}