Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions internal/desktopapp/desktopapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
116 changes: 111 additions & 5 deletions internal/desktopapp/desktopapp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
110 changes: 110 additions & 0 deletions internal/desktopapp/windows_verify.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading