// SPDX-License-Identifier: AGPL-3.0-only package releaseartifact import ( "archive/tar" "bytes" "compress/gzip" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "time" ) const maximumNativeArchiveSize = 256 << 20 var unsignedPackageMembers = map[string]os.FileMode{ "BUILDINFO.json": 0o444, "LICENSE": 0o444, "RELEASE.md": 0o444, "SBOM.spdx.json": 0o444, "SHA256SUMS": 0o444, "himesan": 0o555, } // MacOSSigningOptions are the reviewed identities added after Developer ID // signing changes the native binary bytes. type MacOSSigningOptions struct { Directory string UnsignedArchiveSHA256 string Identity string Identifier string FinalizedAt string } type signingInfo struct { SchemaVersion int `json:"schema_version"` UnsignedArchiveSHA256 string `json:"unsigned_archive_sha256"` UnsignedBinarySHA256 string `json:"unsigned_binary_sha256"` SignedBinarySHA256 string `json:"signed_binary_sha256"` Identity string `json:"identity"` Identifier string `json:"identifier"` FinalizedAt string `json:"finalized_at"` } // ExtractVerifiedMacOSPackage verifies the approved unsigned archive and // extracts its fixed file set without delegating path handling to system tar. func ExtractVerifiedMacOSPackage(archivePath, expectedSHA256, outputDirectory string) (string, error) { if !digestPattern.MatchString(expectedSHA256) { return "", errors.New("approved archive digest must be a lowercase SHA-256") } info, err := os.Lstat(archivePath) if err != nil { return "", fmt.Errorf("inspect unsigned archive: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return "", errors.New("unsigned archive must be a regular file, not a symlink") } if info.Size() <= 0 || info.Size() > maximumNativeArchiveSize { return "", errors.New("unsigned archive size is outside the permitted range") } archive, err := os.ReadFile(archivePath) if err != nil { return "", fmt.Errorf("read unsigned archive: %w", err) } if digest(archive) != expectedSHA256 { return "", errors.New("unsigned archive does not match the approved digest") } members, root, err := readUnsignedPackage(archive) if err != nil { return "", err } if err := validateUnsignedPackage(members, root); err != nil { return "", err } outputInfo, err := os.Lstat(outputDirectory) if err != nil { return "", fmt.Errorf("inspect extraction directory: %w", err) } if !outputInfo.IsDir() || outputInfo.Mode()&os.ModeSymlink != 0 { return "", errors.New("extraction destination must be a real directory") } rootPath := filepath.Join(outputDirectory, root) if err := os.Mkdir(rootPath, 0o700); err != nil { return "", fmt.Errorf("create extracted package root: %w", err) } success := false defer func() { if !success { _ = os.RemoveAll(rootPath) } }() for name, mode := range unsignedPackageMembers { if err := writeExclusive(filepath.Join(rootPath, name), members[name], mode); err != nil { return "", fmt.Errorf("extract %s: %w", name, err) } } success = true return rootPath, nil } // FinalizeSignedMacOSDistribution replaces unsigned binary provenance with a // complete signed-distribution record and regenerates every internal checksum. func FinalizeSignedMacOSDistribution(options MacOSSigningOptions) error { if !digestPattern.MatchString(options.UnsignedArchiveSHA256) { return errors.New("unsigned archive digest must be a lowercase SHA-256") } if strings.TrimSpace(options.Identity) == "" || strings.ContainsAny(options.Identity, "\r\n\x00") { return errors.New("signing identity is required and must be one line") } if strings.TrimSpace(options.Identifier) == "" || strings.ContainsAny(options.Identifier, "\r\n\x00") { return errors.New("signing identifier is required and must be one line") } if _, err := time.Parse(time.RFC3339, options.FinalizedAt); err != nil { return fmt.Errorf("finalization time must be RFC3339: %w", err) } for name := range unsignedPackageMembers { if _, err := readRegularFile(filepath.Join(options.Directory, name), maximumNativeArchiveSize); err != nil { return fmt.Errorf("inspect signed distribution %s: %w", name, err) } } buildPath := filepath.Join(options.Directory, "BUILDINFO.json") buildContents, err := os.ReadFile(buildPath) if err != nil { return fmt.Errorf("read unsigned build information: %w", err) } var build buildInfo if err := decodeStrictJSON(buildContents, &build); err != nil { return fmt.Errorf("decode unsigned build information: %w", err) } if build.SchemaVersion != 1 || !digestPattern.MatchString(build.BinarySHA256) || build.GOOS != "darwin" || build.GOARCH != "arm64" { return errors.New("unsigned build information is not a supported Darwin/arm64 package") } unsignedBinaryDigest := build.BinarySHA256 signedBinary, err := os.ReadFile(filepath.Join(options.Directory, "himesan")) if err != nil { return fmt.Errorf("read signed binary: %w", err) } signedBinaryDigest := digest(signedBinary) if signedBinaryDigest == unsignedBinaryDigest { return errors.New("Developer ID signing did not change the native binary bytes") } build.SchemaVersion = 2 build.BinarySHA256 = signedBinaryDigest build.UnsignedBinarySHA256 = unsignedBinaryDigest build.UnsignedArchiveSHA256 = options.UnsignedArchiveSHA256 build.SigningIdentifier = options.Identifier newBuild, err := indentedJSON(build) if err != nil { return err } sbomPath := filepath.Join(options.Directory, "SBOM.spdx.json") sbomContents, err := os.ReadFile(sbomPath) if err != nil { return fmt.Errorf("read unsigned SBOM: %w", err) } var sbom spdxDocument if err := decodeStrictJSON(sbomContents, &sbom); err != nil { return fmt.Errorf("decode unsigned SBOM: %w", err) } if len(sbom.Packages) != 1 || len(sbom.Packages[0].Checksums) != 1 || sbom.Packages[0].Checksums[0].Algorithm != "SHA256" || sbom.Packages[0].Checksums[0].ChecksumValue != unsignedBinaryDigest { return errors.New("unsigned SBOM does not match the unsigned binary") } sbom.Packages[0].Checksums[0].ChecksumValue = signedBinaryDigest newSBOM, err := indentedJSON(sbom) if err != nil { return err } signingContents, err := indentedJSON(signingInfo{ SchemaVersion: 1, UnsignedArchiveSHA256: options.UnsignedArchiveSHA256, UnsignedBinarySHA256: unsignedBinaryDigest, SignedBinarySHA256: signedBinaryDigest, Identity: options.Identity, Identifier: options.Identifier, FinalizedAt: options.FinalizedAt, }) if err != nil { return err } if err := replaceRegularFile(buildPath, newBuild, 0o444); err != nil { return fmt.Errorf("replace signed build information: %w", err) } if err := replaceRegularFile(sbomPath, newSBOM, 0o444); err != nil { return fmt.Errorf("replace signed SBOM: %w", err) } if err := writeExclusive(filepath.Join(options.Directory, "SIGNING.json"), signingContents, 0o444); err != nil { return fmt.Errorf("write signing information: %w", err) } checksumNames := []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "SIGNING.json", "himesan"} var checksumLines []string for _, name := range checksumNames { contents, err := os.ReadFile(filepath.Join(options.Directory, name)) if err != nil { return fmt.Errorf("read signed distribution member %s: %w", name, err) } checksumLines = append(checksumLines, digest(contents)+" "+name) } checksums := []byte(strings.Join(checksumLines, "\n") + "\n") if err := replaceRegularFile(filepath.Join(options.Directory, "SHA256SUMS"), checksums, 0o444); err != nil { return fmt.Errorf("replace signed distribution checksums: %w", err) } return nil } func readUnsignedPackage(archive []byte) (map[string][]byte, string, error) { gzipReader, err := gzip.NewReader(bytes.NewReader(archive)) if err != nil { return nil, "", fmt.Errorf("open unsigned gzip archive: %w", err) } defer gzipReader.Close() tarReader := tar.NewReader(io.LimitReader(gzipReader, maximumNativeArchiveSize+1)) members := make(map[string][]byte, len(unsignedPackageMembers)) root := "" var totalSize int64 for { header, err := tarReader.Next() if errors.Is(err, io.EOF) { break } if err != nil { return nil, "", fmt.Errorf("read unsigned tar archive: %w", err) } if header.Typeflag != tar.TypeReg || header.Size < 0 || header.Size > maximumNativeArchiveSize { return nil, "", errors.New("unsigned archive contains a non-regular or oversized member") } totalSize += header.Size if totalSize > maximumNativeArchiveSize { return nil, "", errors.New("unsigned archive expands beyond the permitted size") } clean := filepath.ToSlash(filepath.Clean(header.Name)) parts := strings.Split(clean, "/") if len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." { return nil, "", fmt.Errorf("unsigned archive member has an unsafe path: %q", header.Name) } if root == "" { root = parts[0] } else if root != parts[0] { return nil, "", errors.New("unsigned archive contains more than one package root") } name := parts[1] expectedMode, ok := unsignedPackageMembers[name] if !ok || os.FileMode(header.Mode).Perm() != expectedMode || header.Linkname != "" { return nil, "", fmt.Errorf("unsigned archive member %q has an unexpected name, mode, or link", name) } if _, exists := members[name]; exists { return nil, "", fmt.Errorf("unsigned archive repeats member %q", name) } contents, err := io.ReadAll(io.LimitReader(tarReader, header.Size+1)) if err != nil || int64(len(contents)) != header.Size { return nil, "", fmt.Errorf("read unsigned archive member %q", name) } members[name] = contents } if err := gzipReader.Close(); err != nil { return nil, "", fmt.Errorf("finish unsigned gzip archive: %w", err) } return members, root, nil } func validateUnsignedPackage(members map[string][]byte, root string) error { if len(members) != len(unsignedPackageMembers) || root == "" { return errors.New("unsigned archive does not contain the exact release file set") } var build buildInfo if err := decodeStrictJSON(members["BUILDINFO.json"], &build); err != nil { return fmt.Errorf("decode unsigned build information: %w", err) } if build.SchemaVersion != 1 || build.GOOS != "darwin" || build.GOARCH != "arm64" || !releaseVersionPattern.MatchString(build.Version) || root != "himesan-"+strings.TrimPrefix(build.Version, "v")+"-darwin-arm64" || !gitObjectPattern.MatchString(build.Commit) || !gitObjectPattern.MatchString(build.Tree) || !goVersionPattern.MatchString(build.GoVersion) || build.BinarySHA256 != digest(members["himesan"]) { return errors.New("unsigned build information does not match the archive") } var sbom spdxDocument if err := decodeStrictJSON(members["SBOM.spdx.json"], &sbom); err != nil { return fmt.Errorf("decode unsigned SBOM: %w", err) } if len(sbom.Packages) != 1 || len(sbom.Packages[0].Checksums) != 1 || sbom.Packages[0].Checksums[0].Algorithm != "SHA256" || sbom.Packages[0].Checksums[0].ChecksumValue != build.BinarySHA256 { return errors.New("unsigned SBOM does not match the native binary") } expectedNames := []string{"BUILDINFO.json", "LICENSE", "RELEASE.md", "SBOM.spdx.json", "himesan"} var expectedLines []string for _, name := range expectedNames { expectedLines = append(expectedLines, digest(members[name])+" "+name) } if string(members["SHA256SUMS"]) != strings.Join(expectedLines, "\n")+"\n" { return errors.New("unsigned package checksum manifest does not match its members") } return nil } func readRegularFile(path string, maximum int64) ([]byte, error) { info, err := os.Lstat(path) if err != nil { return nil, err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() < 0 || info.Size() > maximum { return nil, errors.New("must be a bounded regular file, not a symlink") } return os.ReadFile(path) } func decodeStrictJSON(contents []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(contents)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } return requireJSONEOF(decoder) } func replaceRegularFile(path string, contents []byte, mode os.FileMode) error { info, err := os.Lstat(path) if err != nil { return err } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return errors.New("replacement target must be a regular file, not a symlink") } temporary, err := os.CreateTemp(filepath.Dir(path), ".himesan-release-*") if err != nil { return err } temporaryPath := temporary.Name() success := false defer func() { _ = temporary.Close() if !success { _ = os.Remove(temporaryPath) } }() if _, err := temporary.Write(contents); err != nil { return err } if err := temporary.Sync(); err != nil { return err } if err := temporary.Close(); err != nil { return err } if err := os.Chmod(temporaryPath, mode); err != nil { return err } if err := os.Rename(temporaryPath, path); err != nil { return err } success = true return nil }