diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index ca813a25..0ed99684 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -216,11 +216,12 @@ func isTopLevelAppInfoPlist(path string) bool { } type downloadItemResult struct { - ArtworkURL string `plist:"artworkURL,omitempty"` - HashMD5 string `plist:"md5,omitempty"` - URL string `plist:"URL,omitempty"` - Sinfs []Sinf `plist:"sinfs,omitempty"` - Metadata map[string]interface{} `plist:"metadata,omitempty"` + PreflightPackageURL string `plist:"preflightPackageURL,omitempty"` + ArtworkURL string `plist:"artworkURL,omitempty"` + HashMD5 string `plist:"md5,omitempty"` + URL string `plist:"URL,omitempty"` + Sinfs []Sinf `plist:"sinfs,omitempty"` + Metadata map[string]interface{} `plist:"metadata,omitempty"` } type downloadResult struct { diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index 964c0024..f0a66cbd 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -66,6 +66,16 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion } item := res.Data.Items[0] + // A pinned Mac version can be returned even when no platform was supplied. + // Use the response's package type instead of passing it to the IPA reader. + if input.Platform == "" { + softwarePlatform := downloadMetadataString(item.Metadata, "software-platform") + if strings.EqualFold(softwarePlatform, "macos") || + (softwarePlatform == "" && strings.EqualFold(downloadMetadataString(item.Metadata, "product-type"), "mac-os-app")) { + platform = PlatformMacOS + } + } + if platform == PlatformMacOS { packagePlatform, err := downloadPackagePlatform(platform, item) if err != nil { diff --git a/pkg/appstore/appstore_macos_preflight_metadata.go b/pkg/appstore/appstore_macos_preflight_metadata.go new file mode 100644 index 00000000..afdce235 --- /dev/null +++ b/pkg/appstore/appstore_macos_preflight_metadata.go @@ -0,0 +1,149 @@ +package appstore + +import ( + "encoding/xml" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/blacktop/go-macho/pkg/xar" +) + +type macPreflightDistributionInfo struct { + XMLName xml.Name `xml:"installer-gui-script"` + Products []struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + } `xml:"product"` + Packages []struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr"` + Bundles []struct { + ID string `xml:"id,attr"` + } `xml:"relocate>bundle"` + } `xml:"pkg-ref"` +} + +func macPreflightVersionMetadata(archive *xar.Reader, bundleID string) (versionMetadata, error) { + var distribution *xar.File + + for _, file := range archive.Files { + if file.Name != "Distribution" { + continue + } + + if distribution != nil { + return versionMetadata{}, errors.New("macOS preflight package contains multiple Distribution files") + } + + distribution = file + } + + if distribution == nil { + return versionMetadata{}, errors.New("macOS preflight package does not contain Distribution") + } + + if !distribution.VerifyChecksum() { + return versionMetadata{}, errors.New("macOS preflight Distribution checksum mismatch") + } + + source, err := distribution.Open() + if err != nil { + return versionMetadata{}, fmt.Errorf("failed to open Mac preflight Distribution: %w", err) + } + + data, readErr := io.ReadAll(source) + closeErr := source.Close() + + if err := errors.Join(readErr, closeErr); err != nil { + return versionMetadata{}, fmt.Errorf("failed to read Mac preflight Distribution: %w", err) + } + + var info macPreflightDistributionInfo + if err := xml.Unmarshal(data, &info); err != nil { + return versionMetadata{}, fmt.Errorf("failed to parse Mac preflight Distribution: %w", err) + } + + if len(info.Products) != 1 || info.Products[0].ID == "" || (bundleID != "" && info.Products[0].ID != bundleID) { + return versionMetadata{}, errors.New("macOS preflight package does not contain a unique product for the requested bundle") + } + + product := info.Products[0] + version := strings.TrimSpace(product.Version) + + if version == "" { + return versionMetadata{}, errors.New("macOS preflight package does not contain a display version") + } + + // References to the same package can be split across several pkg-ref + // elements. Only use versions belonging to the requested product's bundle. + packageIDs := make(map[string]bool) + + for _, pkg := range info.Packages { + for _, bundle := range pkg.Bundles { + if bundle.ID == product.ID && pkg.ID != "" { + packageIDs[pkg.ID] = true + } + } + } + + var built time.Time + + for _, pkg := range info.Packages { + if !packageIDs[pkg.ID] || pkg.Version == "" { + continue + } + + timestamp, err := legacyMacPackageBuildTime(version, pkg.Version) + if err != nil { + return versionMetadata{}, err + } + + if !built.IsZero() && !built.Equal(timestamp) { + return versionMetadata{}, errors.New("macOS preflight package contains conflicting build timestamps") + } + + built = timestamp + } + + if built.IsZero() { + return versionMetadata{}, errors.New("macOS preflight package does not contain a build timestamp for the requested bundle") + } + + // As with the payload timestamp fallback, this is the build time, not an + // exact store release date. The API date is app-level, and the preflight + // archive's creation time can reflect a much later repackaging date. + return versionMetadata{DisplayVersion: version, ReleaseDate: built}, nil +} + +func legacyMacPackageBuildTime(displayVersion, packageVersion string) (time.Time, error) { + // Legacy Apple installers use major.minor.patch.0.1.. + // Do not interpret arbitrary dotted versions as timestamps. + parts := strings.Split(packageVersion, ".") + version := strings.Split(displayVersion, ".") + + for len(version) < 3 { + version = append(version, "0") + } + + if len(parts) != 6 || len(version) != 3 || strings.Join(parts[:3], ".") != strings.Join(version, ".") || + parts[3] != "0" || parts[4] != "1" || len(parts[5]) != 10 { + return time.Time{}, errors.New("macOS preflight package does not contain a supported build timestamp") + } + + for _, part := range parts { + if part == "" || strings.Trim(part, "0123456789") != "" { + return time.Time{}, errors.New("macOS preflight package contains an invalid installer version") + } + } + + seconds, err := strconv.ParseInt(parts[5], 10, 64) + if err != nil || seconds <= 0 { + return time.Time{}, errors.New("macOS preflight package contains an invalid build timestamp") + } + + return time.Unix(seconds, 0).UTC(), nil +} diff --git a/pkg/appstore/appstore_macos_preflight_metadata_test.go b/pkg/appstore/appstore_macos_preflight_metadata_test.go new file mode 100644 index 00000000..60bc4e06 --- /dev/null +++ b/pkg/appstore/appstore_macos_preflight_metadata_test.go @@ -0,0 +1,126 @@ +package appstore + +import ( + "bytes" + "crypto/sha1" // #nosec G505 -- XAR uses SHA-1 for archive checksums. + "fmt" + gohttp "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "time" + + "github.com/blacktop/go-macho/pkg/xar" + "github.com/majd/ipatool/v2/pkg/http" + "github.com/majd/ipatool/v2/pkg/util/machine" + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +func macPreflightXAR(distribution string) []byte { + data := []byte(distribution) + checksum := sha1.Sum(data) // #nosec G401 -- XAR uses SHA-1 for archive checksums. + entry := fmt.Sprintf(`fileDistribution%d%d%d%x%x`, len(data), sha1.Size, len(data), checksum, checksum) + + // The live preflight packages were repacked in 2023. Their creation time + // must not replace the original build timestamp in the installer version. + return makeTestXARArchive(`2023-01-04T22:53:15`+entry, data...) +} + +func macPreflightDistribution(version, packageVersion string) string { + return fmt.Sprintf(` + + + +`, version, packageVersion, packageVersion) +} + +var _ = Describe("Mac preflight version metadata", func() { + DescribeTable("validates the selected product and its build timestamp", func(distribution, bundle, expectedError string) { + data := macPreflightXAR(distribution) + archive, err := xar.NewReader(bytes.NewReader(data), int64(len(data))) + Expect(err).ToNot(HaveOccurred()) + metadata, err := macPreflightVersionMetadata(archive, bundle) + if expectedError != "" { + Expect(err).To(MatchError(ContainSubstring(expectedError))) + } else { + Expect(err).ToNot(HaveOccurred()) + Expect(metadata.DisplayVersion).To(Equal("4.2")) + Expect(metadata.ReleaseDate).To(Equal(time.Unix(1341568473, 0).UTC())) + } + }, + Entry("unique product without a bundle filter", macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), "", ""), + Entry("wrong bundle", macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), "com.other.mac", "requested bundle"), + Entry("missing version", macPreflightDistribution("", "4.2.0.0.1.1341568473"), "", "display version"), + Entry("missing timestamp", macPreflightDistribution("4.2", ""), "", "build timestamp"), + Entry("ordinary dotted version", macPreflightDistribution("4.2", "4.2.0"), "", "supported build timestamp"), + Entry("different package version", macPreflightDistribution("4.2", "4.3.0.0.1.1341568473"), "", "supported build timestamp"), + Entry("invalid timestamp", macPreflightDistribution("4.2", "4.2.0.0.1.abcdefghij"), "", "invalid installer version"), + Entry("zero timestamp", macPreflightDistribution("4.2", "4.2.0.0.1.0000000000"), "", "invalid build timestamp"), + Entry("unrelated package", strings.ReplaceAll(macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), `", ``, 1), "", "unique product"), + Entry("malformed XML", "<", "", "failed to parse"), + ) + + It("rejects corrupted installer metadata", func() { + data := macPreflightXAR(macPreflightDistribution("4.2", "4.2.0.0.1.1341568473")) + data[len(data)-1] ^= 1 + archive, err := xar.NewReader(bytes.NewReader(data), int64(len(data))) + Expect(err).ToNot(HaveOccurred()) + _, err = macPreflightVersionMetadata(archive, "com.example.mac") + Expect(err).To(MatchError(ContainSubstring("checksum mismatch"))) + }) + + DescribeTable("reads the selected legacy version without downloading the encrypted app", func(platform Platform, softwarePlatform, versionID, displayVersion, packageVersion string, timestamp int64) { + ctrl := gomock.NewController(GinkgoT()) + defer ctrl.Finish() + tempRoot := GinkgoT().TempDir() + GinkgoT().Setenv("TMPDIR", tempRoot) + GinkgoT().Setenv("TMP", tempRoot) + GinkgoT().Setenv("TEMP", tempRoot) + data := macPreflightXAR(macPreflightDistribution(displayVersion, packageVersion)) + var encryptedRequests atomic.Int64 + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + if r.URL.Path != "/preflight.pkg" { + encryptedRequests.Add(1) + w.WriteHeader(gohttp.StatusInternalServerError) + + return + } + _, _ = w.Write(data) + })) + defer server.Close() + downloads := http.NewMockClient[downloadResult](ctrl) + machines := machine.NewMockMachine(ctrl) + store := &appstore{downloadClient: downloads, machine: machines, os: operatingsystem.New(), httpClient: http.NewClient[interface{}](http.Args{})} + machines.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + downloads.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", versionID)) + }).Return(http.Result[downloadResult]{StatusCode: 200, Data: downloadResult{Items: []downloadItemResult{{ + URL: server.URL + "/encrypted.pkg", PreflightPackageURL: server.URL + "/preflight.pkg", + Metadata: map[string]interface{}{ + "software-platform": softwarePlatform, "product-type": "mac-os-app", "softwareVersionBundleId": "com.example.mac", + "bundleShortVersionString": "4.3", "releaseDate": "2011-01-03T08:00:00Z", + }, + }}}}, nil) + + out, err := store.GetVersionMetadata(GetVersionMetadataInput{App: App{ID: 409201541}, VersionID: versionID, Platform: platform}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.DisplayVersion).To(Equal(displayVersion)) + Expect(out.ReleaseDate).To(Equal(time.Unix(timestamp, 0).UTC())) + Expect(encryptedRequests.Load()).To(BeZero()) + entries, err := os.ReadDir(tempRoot) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(BeEmpty()) + }, + Entry("4.0.5 without a platform", Platform(""), "macos", "3235485", "4.0.5", "4.0.5.0.1.1292967101", int64(1292967101)), + Entry("4.0.5 with macOS", PlatformMacOS, "macos", "3235485", "4.0.5", "4.0.5.0.1.1292967101", int64(1292967101)), + Entry("4.2 without a platform", Platform(""), "macos", "12013122", "4.2", "4.2.0.0.1.1341568473", int64(1341568473)), + Entry("4.2 with macOS", PlatformMacOS, "macos", "12013122", "4.2", "4.2.0.0.1.1341568473", int64(1341568473)), + Entry("product type without platform metadata", Platform(""), "", "12013122", "4.2", "4.2.0.0.1.1341568473", int64(1341568473)), + ) +}) diff --git a/pkg/appstore/appstore_macos_version_metadata.go b/pkg/appstore/appstore_macos_version_metadata.go index 9f2680a1..58c2608a 100644 --- a/pkg/appstore/appstore_macos_version_metadata.go +++ b/pkg/appstore/appstore_macos_version_metadata.go @@ -24,7 +24,14 @@ func (t *appstore) readVersionMetadataFromMacPackage(ctx context.Context, item d defer os.RemoveAll(directory) destination := filepath.Join(directory, "app.pkg") - if _, err := t.downloadMacPackage(ctx, item, destination, hardwareID, nil); err != nil { + // Older device-based downloads omit dpInfo but include a small, readable + // preflight archive containing the selected build's installer metadata. + preflight := !hasMacDPInfo(item.Sinfs) && item.PreflightPackageURL != "" + if preflight { + if err := t.downloadFile(ctx, item.PreflightPackageURL, destination, nil); err != nil { + return versionMetadata{}, fmt.Errorf("failed to download Mac preflight package: %w", err) + } + } else if _, err := t.downloadMacPackage(ctx, item, destination, hardwareID, nil); err != nil { return versionMetadata{}, err } @@ -39,6 +46,10 @@ func (t *appstore) readVersionMetadataFromMacPackage(ctx context.Context, item d defer archive.Close() + if preflight { + return macPreflightVersionMetadata(&archive.Reader, bundleID) + } + return macPackageVersionMetadata(&archive.Reader, bundleID) }