From ca5af2debbf4fcc742c939151d07231834d55d4e Mon Sep 17 00:00:00 2001 From: yujiezhang-ops Date: Wed, 5 Aug 2026 12:06:50 +0800 Subject: [PATCH] fix: verify macOS desktop installer identity Require an approved HTTPS source, a valid strict code signature, the verified OpenAI Team ID and Developer ID authority, and a notarized Gatekeeper assessment before copying the downloaded app.\n\nRefs #26 --- internal/desktopapp/desktopapp.go | 8 ++- internal/desktopapp/desktopapp_test.go | 93 ++++++++++++++++++++++++++ internal/desktopapp/download_policy.go | 31 +++++++++ internal/desktopapp/macos_verify.go | 55 +++++++++++++++ 4 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 internal/desktopapp/download_policy.go create mode 100644 internal/desktopapp/macos_verify.go diff --git a/internal/desktopapp/desktopapp.go b/internal/desktopapp/desktopapp.go index 1fc15b31..a74764cf 100644 --- a/internal/desktopapp/desktopapp.go +++ b/internal/desktopapp/desktopapp.go @@ -351,7 +351,10 @@ func plutilValue(ctx context.Context, options Options, plist, key string) (strin } func installMacOS(ctx context.Context, options Options, replacePath string) (ActionResult, error) { - url := macDownloadURL(options) + url, err := approvedDownloadURL(macDownloadURL(options), "persistent.oaistatic.com") + if err != nil { + return ActionResult{}, fmt.Errorf("validate ChatGPT installer URL: %w", err) + } tempDir, err := os.MkdirTemp("", "oneagent-desktop-agent-") if err != nil { return ActionResult{}, fmt.Errorf("create temporary installer directory: %w", err) @@ -386,6 +389,9 @@ func installMacOS(ctx context.Context, options Options, replacePath string) (Act if metadata.bundleID != CodexBundleID { return ActionResult{}, fmt.Errorf("downloaded app has unexpected bundle identifier %q", metadata.bundleID) } + if err := verifyMacOSApp(ctx, options, appPath); err != nil { + return ActionResult{}, fmt.Errorf("verify downloaded ChatGPT app: %w", err) + } appName := filepath.Base(appPath) destinations := make([]string, 0, 2) if replacePath != "" { diff --git a/internal/desktopapp/desktopapp_test.go b/internal/desktopapp/desktopapp_test.go index 130cac9a..c0c90550 100644 --- a/internal/desktopapp/desktopapp_test.go +++ b/internal/desktopapp/desktopapp_test.go @@ -355,3 +355,96 @@ func TestProfileAgentIDKeepsChatGPTOnCodexAndScopesOtherApps(t *testing.T) { t.Fatal("whitespace around a non-shared desktop ID changed its ownership") } } + +func TestVerifyMacOSAppRequiresExpectedTeamAndNotarization(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{ + {ExitCode: 0}, + { + ExitCode: 0, + Stderr: strings.Join([]string{ + "Identifier=com.openai.codex", + "Authority=Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)", + "TeamIdentifier=2DC432GLL2", + }, "\n"), + }, + {ExitCode: 0, Stdout: "ChatGPT.app: accepted\nsource=Notarized Developer ID"}, + }} + + if err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app"); err != nil { + t.Fatalf("verifyMacOSApp() error = %v", err) + } + if len(runner.calls) != 3 { + t.Fatalf("verification calls = %#v, want codesign verify, codesign details, spctl", runner.calls) + } +} + +func TestVerifyMacOSAppRejectsUnsignedBundleBeforeIdentityChecks(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{{ExitCode: 1, Stderr: "code object is not signed at all"}}} + + err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app") + + if err == nil || !strings.Contains(err.Error(), "code signature") { + t.Fatalf("verifyMacOSApp() error = %v, want code-signature failure", err) + } + if len(runner.calls) != 1 { + t.Fatalf("verification continued after signature failure: %#v", runner.calls) + } +} + +func TestApprovedDownloadURLRequiresHTTPSAndAllowedHost(t *testing.T) { + for _, test := range []struct { + name string + url string + want bool + }{ + {name: "official", url: MacDownloadURL, want: true}, + {name: "http", url: "http://persistent.oaistatic.com/codex-app-prod/ChatGPT.dmg", want: false}, + {name: "wrong host", url: "https://example.test/ChatGPT.dmg", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := approvedDownloadURL(test.url, "persistent.oaistatic.com") + if (err == nil) != test.want { + t.Fatalf("approvedDownloadURL(%q) error = %v, want allowed=%v", test.url, err, test.want) + } + }) + } +} + +func TestVerifyMacOSAppRejectsWrongTeamID(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{ + {ExitCode: 0}, + { + ExitCode: 0, + Stderr: "Identifier=com.openai.codex\nAuthority=Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)\nTeamIdentifier=WRONGTEAM", + }, + }} + + err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app") + + if err == nil || !strings.Contains(err.Error(), "TeamIdentifier") { + t.Fatalf("verifyMacOSApp() error = %v, want TeamIdentifier failure", err) + } + if len(runner.calls) != 2 { + t.Fatalf("verification continued after identity failure: %#v", runner.calls) + } +} + +func TestVerifyMacOSAppRejectsNonNotarizedDeveloperIDSource(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{ + {ExitCode: 0}, + { + ExitCode: 0, + Stderr: "Identifier=com.openai.codex\nAuthority=Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)\nTeamIdentifier=2DC432GLL2", + }, + {ExitCode: 0, Stdout: "ChatGPT.app: accepted\nsource=Developer ID"}, + }} + + err := verifyMacOSApp(context.Background(), Options{Runner: runner}, "/Applications/ChatGPT.app") + + if err == nil || !strings.Contains(err.Error(), "notarized Developer ID") { + t.Fatalf("verifyMacOSApp() error = %v, want notarization failure", err) + } + if len(runner.calls) != 3 { + t.Fatalf("verification continued after Gatekeeper failure: %#v", runner.calls) + } +} diff --git a/internal/desktopapp/download_policy.go b/internal/desktopapp/download_policy.go new file mode 100644 index 00000000..073b7f39 --- /dev/null +++ b/internal/desktopapp/download_policy.go @@ -0,0 +1,31 @@ +package desktopapp + +import ( + "fmt" + "net/url" + "strings" +) + +// approvedDownloadURL keeps the installer trust boundary on HTTPS endpoints +// owned by the vendor. Signature verification remains mandatory after download; +// an allowlisted host alone does not authenticate the bytes. +func approvedDownloadURL(raw string, allowedHosts ...string) (string, error) { + value := strings.TrimSpace(raw) + parsed, err := url.Parse(value) + if err != nil { + return "", fmt.Errorf("parse download URL: %w", err) + } + if parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil { + return "", fmt.Errorf("download URL must be an HTTPS URL without credentials") + } + if port := parsed.Port(); port != "" && port != "443" { + return "", fmt.Errorf("download URL must use HTTPS port 443") + } + host := strings.ToLower(parsed.Hostname()) + for _, allowed := range allowedHosts { + if host == strings.ToLower(strings.TrimSpace(allowed)) { + return parsed.String(), nil + } + } + return "", fmt.Errorf("download URL host %q is not approved", host) +} diff --git a/internal/desktopapp/macos_verify.go b/internal/desktopapp/macos_verify.go new file mode 100644 index 00000000..e1b50d45 --- /dev/null +++ b/internal/desktopapp/macos_verify.go @@ -0,0 +1,55 @@ +package desktopapp + +import ( + "context" + "errors" + "fmt" + "strings" +) + +const ( + MacExpectedTeamID = "2DC432GLL2" + MacExpectedAuthority = "Developer ID Application: OpenAI OpCo, LLC (2DC432GLL2)" + MacExpectedSpctlSource = "source=Notarized Developer ID" +) + +func verifyMacOSApp(ctx context.Context, options Options, appPath string) error { + result, err := run(options, ctx, []string{"/usr/bin/codesign", "--verify", "--deep", "--strict", "--verbose=2", appPath}, installTimeout) + if err != nil { + return fmt.Errorf("verify macOS code signature: %w", err) + } + if result.ExitCode != 0 { + return commandFailure("verify macOS code signature", result) + } + + result, err = run(options, ctx, []string{"/usr/bin/codesign", "-dv", "--verbose=4", appPath}, installTimeout) + if err != nil { + return fmt.Errorf("read macOS signing identity: %w", err) + } + if result.ExitCode != 0 { + return commandFailure("read macOS signing identity", result) + } + details := result.Stdout + "\n" + result.Stderr + for _, required := range []string{ + "Identifier=" + CodexBundleID, + "TeamIdentifier=" + MacExpectedTeamID, + "Authority=" + MacExpectedAuthority, + } { + if !strings.Contains(details, required) { + return fmt.Errorf("macOS signature is missing expected identity %q", required) + } + } + + result, err = run(options, ctx, []string{"/usr/sbin/spctl", "--assess", "--type", "execute", "--verbose=4", appPath}, installTimeout) + if err != nil { + return fmt.Errorf("assess macOS app with Gatekeeper: %w", err) + } + if result.ExitCode != 0 { + return commandFailure("assess macOS app with Gatekeeper", result) + } + if !strings.Contains(result.Stdout+"\n"+result.Stderr, MacExpectedSpctlSource) { + return errors.New("macOS app is not accepted as notarized Developer ID software") + } + + return nil +}