diff --git a/internal/desktopapp/desktopapp.go b/internal/desktopapp/desktopapp.go index a74764c..0823aa5 100644 --- a/internal/desktopapp/desktopapp.go +++ b/internal/desktopapp/desktopapp.go @@ -246,6 +246,10 @@ func contextError(ctx context.Context) error { } func run(options Options, ctx context.Context, argv []string, timeout time.Duration) (process.Result, error) { + return runWithEnvironment(options, ctx, argv, nil, timeout) +} + +func runWithEnvironment(options Options, ctx context.Context, argv []string, environment map[string]string, timeout time.Duration) (process.Result, error) { if len(argv) == 0 { return process.Result{ExitCode: -1}, errors.New("desktop-app command is empty") } @@ -255,7 +259,7 @@ func run(options Options, ctx context.Context, argv []string, timeout time.Durat if options.Output != nil { options.Output(process.Output{Kind: "command", Args: append([]string(nil), argv...)}) } - return runnerFor(options).Run(ctx, argv, nil, timeout) + return runnerFor(options).Run(ctx, argv, environment, timeout) } func start(options Options, argv []string) error { @@ -670,6 +674,14 @@ func versionParts(value string) []int { } func installWindowsInstaller(ctx context.Context, options Options, status Status) (ActionResult, error) { + url := strings.TrimSpace(options.DownloadURL) + if url == "" { + url = WindowsInstallerURL + } + url, err := approvedDownloadURL(url, "get.microsoft.com") + if err != nil { + return ActionResult{}, fmt.Errorf("validate ChatGPT installer URL: %w", err) + } installer, err := os.CreateTemp("", "oneagent-desktop-agent-*.exe") if err != nil { return ActionResult{}, fmt.Errorf("create temporary ChatGPT installer: %w", err) @@ -685,16 +697,15 @@ func installWindowsInstaller(ctx context.Context, options Options, status Status _ = os.Remove(installerPath) } }() - url := strings.TrimSpace(options.DownloadURL) - if url == "" { - url = WindowsInstallerURL - } if err := downloadFile(ctx, options, url, installerPath); err != nil { return ActionResult{}, fmt.Errorf("download ChatGPT installer: %w", err) } if err := contextError(ctx); err != nil { return ActionResult{}, err } + if err := verifyWindowsInstaller(ctx, options, installerPath); err != nil { + return ActionResult{}, fmt.Errorf("verify downloaded ChatGPT installer with Authenticode: %w", err) + } if err := start(options, []string{installerPath}); err != nil { return ActionResult{}, fmt.Errorf("start ChatGPT installer: %w", err) } diff --git a/internal/desktopapp/desktopapp_test.go b/internal/desktopapp/desktopapp_test.go index c0c9055..02df31a 100644 --- a/internal/desktopapp/desktopapp_test.go +++ b/internal/desktopapp/desktopapp_test.go @@ -23,9 +23,10 @@ type probeRunner struct { } type scriptedRunner struct { - results []process.Result - calls [][]string - started [][]string + results []process.Result + calls [][]string + environments []map[string]string + started [][]string } type cancelRunner struct { @@ -67,8 +68,13 @@ func (r *cancelRunner) Start([]string, map[string]string) error { func (r *scriptedRunner) LookPath(string) (string, bool) { return "", false } -func (r *scriptedRunner) Run(_ context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) { +func (r *scriptedRunner) Run(_ context.Context, argv []string, environment map[string]string, _ time.Duration) (process.Result, error) { r.calls = append(r.calls, append([]string(nil), argv...)) + copyEnvironment := make(map[string]string, len(environment)) + for key, value := range environment { + copyEnvironment[key] = value + } + r.environments = append(r.environments, copyEnvironment) if len(r.results) == 0 { return process.Result{Args: argv, ExitCode: 0}, nil } @@ -276,7 +282,11 @@ func TestMacOpenInstallerDownloadsInsteadOfOpeningBrowser(t *testing.T) { } func TestWindowsInstallDownloadsAndStartsOfficialBootstrapperWithoutFilesystemScan(t *testing.T) { - runner := &scriptedRunner{results: []process.Result{{ExitCode: 0}, {ExitCode: 0}}} + runner := &scriptedRunner{results: []process.Result{ + {ExitCode: 0}, + {ExitCode: 0}, + {ExitCode: 0, Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Microsoft Corporation","Organization":"Microsoft Corporation","Subject":"CN=Microsoft Corporation, O=Microsoft Corporation","Issuer":"CN=Microsoft Marketplace CA G 024"}`}, + }} payload := []byte("official installer") downloader := &fakeDownloader{body: payload} var outputs []process.Output @@ -332,6 +342,102 @@ func TestWindowsInstallDoesNotOpenInstallerAfterCancellation(t *testing.T) { } } +func TestVerifyWindowsInstallerRequiresValidMicrosoftAuthenticode(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{{ + ExitCode: 0, + Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Microsoft Corporation","Organization":"Microsoft Corporation","Subject":"CN=Microsoft Corporation, O=Microsoft Corporation","Issuer":"CN=Microsoft Marketplace CA G 024"}`, + }}} + + if err := verifyWindowsInstaller(context.Background(), Options{Runner: runner}, `C:\Users\test\ChatGPT.exe`); err != nil { + t.Fatalf("verifyWindowsInstaller() error = %v", err) + } + if len(runner.calls) != 1 || runner.calls[0][0] != "powershell.exe" { + t.Fatalf("verification calls = %#v", runner.calls) + } + if got := runner.environments[0]["ONEAGENT_AUTHENTICODE_PATH"]; got != `C:\Users\test\ChatGPT.exe` { + t.Fatalf("Authenticode path environment = %q", got) + } +} + +func TestVerifyWindowsInstallerRejectsInvalidAuthenticodeStates(t *testing.T) { + for _, status := range []string{"NotSigned", "HashMismatch", "NotTrusted", "UnknownError"} { + t.Run(status, func(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{{ + ExitCode: 0, + Stdout: `{"Status":"` + status + `","StatusMessage":"signature failure","Publisher":"Microsoft Corporation","Organization":"Microsoft Corporation","Subject":"CN=Microsoft Corporation","Issuer":"CN=Microsoft Marketplace CA G 024"}`, + }}} + + err := verifyWindowsInstaller(context.Background(), Options{Runner: runner}, `C:\Users\test\ChatGPT.exe`) + if err == nil || !strings.Contains(err.Error(), status) { + t.Fatalf("verifyWindowsInstaller() error = %v, want status %q rejection", err, status) + } + }) + } +} + +func TestVerifyWindowsInstallerRejectsMissingSignerCertificate(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{{ + ExitCode: 0, + Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"","Organization":"","Subject":"","Issuer":""}`, + }}} + + err := verifyWindowsInstaller(context.Background(), Options{Runner: runner}, `C:\Users\test\ChatGPT.exe`) + if err == nil || !strings.Contains(err.Error(), "signer certificate") { + t.Fatalf("verifyWindowsInstaller() error = %v, want missing signer certificate rejection", err) + } +} + +func TestVerifyWindowsInstallerRejectsUnexpectedPublisher(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{{ + ExitCode: 0, + Stdout: `{"Status":"Valid","StatusMessage":"Signature verified.","Publisher":"Example Corporation","Organization":"Example Corporation","Subject":"CN=Example Corporation, O=Example Corporation","Issuer":"CN=Example CA"}`, + }}} + + err := verifyWindowsInstaller(context.Background(), Options{Runner: runner}, `C:\Users\test\ChatGPT.exe`) + if err == nil || !strings.Contains(err.Error(), "publisher") { + t.Fatalf("verifyWindowsInstaller() error = %v, want publisher rejection", err) + } +} + +func TestWindowsInstallDoesNotStartUnsignedInstaller(t *testing.T) { + runner := &scriptedRunner{results: []process.Result{ + {ExitCode: 0}, + {ExitCode: 0}, + {ExitCode: 0, Stdout: `{"Status":"NotSigned","StatusMessage":"The file is not digitally signed.","Publisher":"","Organization":"","Subject":"","Issuer":""}`}, + }} + downloader := &fakeDownloader{body: []byte("unsigned installer")} + + _, err := Install(context.Background(), Options{ + Platform: platform.For("windows", "amd64"), + Runner: runner, + Downloader: downloader, + }) + if err == nil || !strings.Contains(err.Error(), "Authenticode") { + t.Fatalf("Install() error = %v, want Authenticode rejection", err) + } + if len(runner.started) != 0 { + t.Fatalf("unsigned installer was started: %#v", runner.started) + } +} + +func TestWindowsInstallRejectsUnapprovedDownloadHost(t *testing.T) { + runner := &scriptedRunner{} + downloader := &fakeDownloader{body: []byte("installer")} + + _, err := Install(context.Background(), Options{ + Platform: platform.For("windows", "amd64"), + Runner: runner, + Downloader: downloader, + DownloadURL: "https://example.test/installer.exe", + }) + if err == nil || !strings.Contains(err.Error(), "validate ChatGPT installer URL") { + t.Fatalf("Install() error = %v, want URL validation failure", err) + } + if len(downloader.hits) != 0 || len(runner.started) != 0 { + t.Fatalf("unapproved URL was used: downloads=%#v starts=%#v", downloader.hits, runner.started) + } +} + func TestCompareVersionHandlesMissingComponents(t *testing.T) { if compareVersion("26.10.0", "26.9.99.0") <= 0 { t.Fatal("26.10.0 should be newer") diff --git a/internal/desktopapp/windows_verify.go b/internal/desktopapp/windows_verify.go new file mode 100644 index 0000000..dfe3ec6 --- /dev/null +++ b/internal/desktopapp/windows_verify.go @@ -0,0 +1,110 @@ +package desktopapp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + windowsExpectedSignerOrganization = "Microsoft Corporation" + windowsExpectedSignerPublisher = "Microsoft Corporation" +) + +type windowsAuthenticodeSignature struct { + Status string `json:"Status"` + StatusMessage string `json:"StatusMessage"` + Publisher string `json:"Publisher"` + Organization string `json:"Organization"` + Subject string `json:"Subject"` + Issuer string `json:"Issuer"` +} + +// verifyWindowsInstaller delegates trust evaluation to Windows' Authenticode +// verifier. A valid signature from an unexpected publisher is not sufficient: +// the downloaded bootstrapper must be the Microsoft-published installer used by +// Windows' official get.microsoft.com flow. +func verifyWindowsInstaller(ctx context.Context, options Options, installerPath string) error { + if strings.TrimSpace(installerPath) == "" { + return errors.New("Windows installer path is empty") + } + + const environmentKey = "ONEAGENT_AUTHENTICODE_PATH" + result, err := runWithEnvironment( + options, + ctx, + windowsAuthenticodeQuery(), + map[string]string{environmentKey: installerPath}, + installTimeout, + ) + if err != nil { + return fmt.Errorf("run Windows Authenticode verification: %w", err) + } + if result.ExitCode != 0 { + return commandFailure("run Windows Authenticode verification", result) + } + + signature, err := parseWindowsAuthenticodeSignature(result.Stdout) + if err != nil { + return fmt.Errorf("parse Windows Authenticode result: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(signature.Status), "Valid") { + status := strings.TrimSpace(signature.Status) + if status == "" { + status = "unknown" + } + message := strings.TrimSpace(signature.StatusMessage) + if message == "" { + return fmt.Errorf("Windows Authenticode status is %q", status) + } + return fmt.Errorf("Windows Authenticode status is %q: %s", status, message) + } + if strings.TrimSpace(signature.Subject) == "" || strings.TrimSpace(signature.Issuer) == "" { + return errors.New("Windows Authenticode result has no signer certificate") + } + if !strings.EqualFold(strings.TrimSpace(signature.Organization), windowsExpectedSignerOrganization) || + !strings.EqualFold(strings.TrimSpace(signature.Publisher), windowsExpectedSignerPublisher) { + return fmt.Errorf("Windows Authenticode publisher %q (organization %q) is not approved", signature.Publisher, signature.Organization) + } + + return nil +} + +func windowsAuthenticodeQuery() []string { + const script = `$signature = Get-AuthenticodeSignature -LiteralPath $env:ONEAGENT_AUTHENTICODE_PATH +$certificate = $signature.SignerCertificate +$organization = "" +$publisher = "" +$subject = "" +$issuer = "" +if ($null -ne $certificate) { + $publisher = [string]$certificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + $subject = [string]$certificate.Subject + $issuer = [string]$certificate.Issuer + $organization = ($subject -split "," | Where-Object { $_.TrimStart().StartsWith("O=") } | Select-Object -First 1) + if ($null -ne $organization) { $organization = $organization.Substring($organization.IndexOf("=") + 1).Trim() } +} +[pscustomobject]@{ + Status = [string]$signature.Status + StatusMessage = [string]$signature.StatusMessage + Publisher = $publisher + Organization = $organization + Subject = $subject + Issuer = $issuer +} | ConvertTo-Json -Compress` + return []string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script} +} + +func parseWindowsAuthenticodeSignature(output string) (windowsAuthenticodeSignature, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(output), "\ufeff") + if trimmed == "" { + return windowsAuthenticodeSignature{}, errors.New("Windows Authenticode returned no result") + } + var signature windowsAuthenticodeSignature + if err := json.Unmarshal([]byte(trimmed), &signature); err != nil { + return windowsAuthenticodeSignature{}, err + } + return signature, nil +}