From fc0477ccba94ff7f224ef4da31796b3f72092fca Mon Sep 17 00:00:00 2001 From: rohan-chaturvedi Date: Sat, 24 Oct 2026 13:52:50 +0530 Subject: [PATCH 1/6] fix(proxy): remove obsolete Phase AI CA roots on Windows with certutil phase ai enable failed on Windows whenever an older Phase AI CA was still trusted in the current-user Root store: inspect and rotate Phase AI CA in Windows current-user Root store: exit status 1: Remove-Item : The operation is on user root store and UI is not allowed. The rotation script deleted obsolete roots with Remove-Item on the Cert: drive, but trustWindows runs PowerShell with -NonInteractive, and Windows PowerShell's certificate provider refuses every removal from CurrentUser\Root when -NonInteractive is on its command line. The PowerShell step is now read-only: it prints PHASE_CA_OBSOLETE with the SHA-1 thumbprint of each older Phase-owned root, and trustWindows removes each one with certutil -user -delstore before adding the new CA with -addstore, as before. Thumbprints must be 40 hex characters, since -delstore also matches store indexes and common names. The Windows trust notice now mentions the confirmation shown for each removed root. --- src/pkg/proxy/trust.go | 54 +++++++++++++++++++++---- src/pkg/proxy/trust_test.go | 79 +++++++++++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/pkg/proxy/trust.go b/src/pkg/proxy/trust.go index 4c8e1697..3d4ad17c 100644 --- a/src/pkg/proxy/trust.go +++ b/src/pkg/proxy/trust.go @@ -18,7 +18,13 @@ import ( const phaseAITrustNickname = "Phase AI Proxy CA" -const windowsPhaseCARotationScript = `$ErrorActionPreference='Stop'; $desired='__PHASE_SHA256__'; $phaseRoots=Get-ChildItem -Path Cert:\CurrentUser\Root | Where-Object { $_.Subject -eq $_.Issuer -and $_.Subject -match '(^|,\s*)CN=Phase AI Proxy CA(,|$)' -and $_.Subject -match '(^|,\s*)O=Phase(,|$)' -and ($_.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' -and $_.CertificateAuthority }) }; if ($phaseRoots | Where-Object { $_.GetCertHashString('SHA256').ToUpperInvariant() -eq $desired }) { Write-Output 'PHASE_CA_CURRENT'; exit 0 }; $phaseRoots | ForEach-Object { Remove-Item -LiteralPath $_.PSPath -Force }` +// windowsPhaseRootInspectionScript only reads the current-user Root store: it +// prints PHASE_CA_CURRENT when the CA being trusted is already there, and +// otherwise one PHASE_CA_OBSOLETE line per older Phase-owned root. It must not +// delete those roots itself: under -NonInteractive the Cert: drive refuses any +// removal from the current-user Root store ("The operation is on user root +// store and UI is not allowed"), so trustWindows removes them with certutil. +const windowsPhaseRootInspectionScript = `$ErrorActionPreference='Stop'; $desired='__PHASE_SHA256__'; $phaseRoots=Get-ChildItem -Path Cert:\CurrentUser\Root | Where-Object { $_.Subject -eq $_.Issuer -and $_.Subject -match '(^|,\s*)CN=Phase AI Proxy CA(,|$)' -and $_.Subject -match '(^|,\s*)O=Phase(,|$)' -and ($_.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' -and $_.CertificateAuthority }) }; if ($phaseRoots | Where-Object { $_.GetCertHashString('SHA256').ToUpperInvariant() -eq $desired }) { Write-Output 'PHASE_CA_CURRENT'; exit 0 }; $phaseRoots | ForEach-Object { Write-Output ('PHASE_CA_OBSOLETE ' + $_.Thumbprint) }` // ErrUserCATrustUnsupported means Phase cannot safely modify a trust store for // only the current user on this operating system. Callers can continue with @@ -128,7 +134,7 @@ func UserCATrustNotice(goos string) string { case "darwin": return "macOS asks for your login password or Touch ID once: the public Phase AI CA is added to your login keychain's trust settings. No sudo, nothing system-wide." case "windows": - return "Windows shows one security warning to confirm adding the public Phase AI CA to your user Root store. Nothing system-wide." + return "Windows shows one security warning to confirm adding the public Phase AI CA to your user Root store, and one to remove each older Phase AI CA it replaces. Nothing system-wide." default: return "" } @@ -296,17 +302,30 @@ func (i userCATrustInstaller) trustWindows(certificatePath string, certificate * } fingerprint := sha256.Sum256(certificate.Raw) fingerprintHex := strings.ToUpper(hex.EncodeToString(fingerprint[:])) - rotationScript := strings.Replace(windowsPhaseCARotationScript, "__PHASE_SHA256__", fingerprintHex, 1) - rotationOutput, rotationErr := i.Runner.Run( + inspectionScript := strings.Replace(windowsPhaseRootInspectionScript, "__PHASE_SHA256__", fingerprintHex, 1) + inspectionOutput, inspectionErr := i.Runner.Run( powershell, "-NoLogo", "-NoProfile", "-NonInteractive", - "-Command", rotationScript, + "-Command", inspectionScript, ) - if rotationErr != nil { - return UserCATrustResult{}, trustCommandError("inspect and rotate Phase AI CA in Windows current-user Root store", rotationErr, rotationOutput) + if inspectionErr != nil { + return UserCATrustResult{}, trustCommandError("inspect Phase AI CA in Windows current-user Root store", inspectionErr, inspectionOutput) } - if strings.Contains(string(rotationOutput), "PHASE_CA_CURRENT") { + if strings.Contains(string(inspectionOutput), "PHASE_CA_CURRENT") { return UserCATrustResult{Store: "Windows current-user Root store", AlreadyTrusted: true}, nil } + obsoleteThumbprints, err := parseWindowsObsoletePhaseRoots(inspectionOutput) + if err != nil { + return UserCATrustResult{}, fmt.Errorf("inspect Phase AI CA in Windows current-user Root store: %w", err) + } + for _, thumbprint := range obsoleteThumbprints { + // Removing obsolete roots before adding the new one keeps rotation from + // leaving an old Phase root trusted. Windows asks the user to confirm + // each removal, as it does for -addstore below. + deleteOutput, deleteErr := i.Runner.Run(certutil, "-user", "-delstore", "Root", thumbprint) + if deleteErr != nil { + return UserCATrustResult{}, trustCommandError("remove obsolete Phase AI CA from Windows current-user Root store", deleteErr, deleteOutput) + } + } // -user is the security boundary: without it certutil targets the local // machine store and may elevate. -f makes re-enabling idempotent. output, err := i.Runner.Run(certutil, "-user", "-addstore", "-f", "Root", certificatePath) @@ -316,6 +335,25 @@ func (i userCATrustInstaller) trustWindows(certificatePath string, certificate * return UserCATrustResult{Store: "Windows current-user Root store"}, nil } +// parseWindowsObsoletePhaseRoots reads the PHASE_CA_OBSOLETE lines printed by +// windowsPhaseRootInspectionScript. Each value must be a SHA-1 certificate +// hash: certutil -delstore also accepts store indexes and common names, so +// anything else could select a certificate Phase does not own. +func parseWindowsObsoletePhaseRoots(output []byte) ([]string, error) { + thumbprints := make([]string, 0) + for _, line := range strings.Split(string(output), "\n") { + thumbprint, found := strings.CutPrefix(strings.TrimSpace(line), "PHASE_CA_OBSOLETE ") + if !found { + continue + } + if raw, err := hex.DecodeString(thumbprint); err != nil || len(raw) != 20 { + return nil, fmt.Errorf("unexpected certificate thumbprint %q", thumbprint) + } + thumbprints = append(thumbprints, thumbprint) + } + return thumbprints, nil +} + func parsePEMCertificates(payload []byte) []*x509.Certificate { result := make([]*x509.Certificate, 0) rest := payload diff --git a/src/pkg/proxy/trust_test.go b/src/pkg/proxy/trust_test.go index 2de0b0d5..61a0d820 100644 --- a/src/pkg/proxy/trust_test.go +++ b/src/pkg/proxy/trust_test.go @@ -189,10 +189,81 @@ func TestUserCATrustWindowsUsesUserStore(t *testing.T) { if !reflect.DeepEqual(runner.commands[1].Args, want) { t.Fatalf("certutil args = %v, want %v", runner.commands[1].Args, want) } - rotationArgs := strings.Join(runner.commands[0].Args, " ") - for _, required := range []string{"Cert:\\CurrentUser\\Root", "CN=Phase AI Proxy CA", "O=Phase", "Subject -eq $_.Issuer", "2.5.29.19", "Remove-Item -LiteralPath"} { - if !strings.Contains(rotationArgs, required) { - t.Fatalf("Windows rotation is not scoped to Phase-owned current-user roots; missing %q in %s", required, rotationArgs) + inspectionArgs := strings.Join(runner.commands[0].Args, " ") + for _, required := range []string{"Cert:\\CurrentUser\\Root", "CN=Phase AI Proxy CA", "O=Phase", "Subject -eq $_.Issuer", "2.5.29.19", "PHASE_CA_OBSOLETE"} { + if !strings.Contains(inspectionArgs, required) { + t.Fatalf("Windows inspection is not scoped to Phase-owned current-user roots; missing %q in %s", required, inspectionArgs) + } + } + // PowerShell runs -NonInteractive, where the Cert: drive refuses every + // removal from the current-user Root store. + if strings.Contains(inspectionArgs, "Remove-Item") { + t.Fatalf("Windows inspection must not delete through the Cert: drive: %s", inspectionArgs) + } +} + +func TestUserCATrustWindowsRemovesObsoletePhaseRootsWithCertutilBeforeTrusting(t *testing.T) { + certificatePath, _, _ := writeTrustTestCA(t) + obsolete := []string{strings.Repeat("A1", 20), strings.Repeat("0F", 20)} + runner := &recordingTrustRunner{run: func(_ string, args ...string) ([]byte, error) { + if len(args) > 0 && args[0] == "-NoLogo" { + return []byte("PHASE_CA_OBSOLETE " + obsolete[0] + "\r\nPHASE_CA_OBSOLETE " + obsolete[1] + "\r\n"), nil + } + return nil, nil + }} + result, err := (userCATrustInstaller{GOOS: "windows", Home: `C:\\Users\\phase`, Runner: runner}).Trust(certificatePath) + if err != nil { + t.Fatal(err) + } + if result.AlreadyTrusted || len(runner.commands) != 4 { + t.Fatalf("unexpected result=%+v commands=%v", result, runner.commands) + } + for index, thumbprint := range obsolete { + command := runner.commands[index+1] + want := []string{"-user", "-delstore", "Root", thumbprint} + if command.Name != "/usr/bin/certutil" || !reflect.DeepEqual(command.Args, want) { + t.Fatalf("removal %d = %s %v, want certutil %v", index, command.Name, command.Args, want) + } + } + want := []string{"-user", "-addstore", "-f", "Root", certificatePath} + if !reflect.DeepEqual(runner.commands[3].Args, want) { + t.Fatalf("certutil args = %v, want %v", runner.commands[3].Args, want) + } +} + +func TestUserCATrustWindowsRejectsMalformedObsoleteThumbprint(t *testing.T) { + certificatePath, _, _ := writeTrustTestCA(t) + // certutil -delstore also accepts store indexes: "0" is the first root. + runner := &recordingTrustRunner{run: func(_ string, args ...string) ([]byte, error) { + if len(args) > 0 && args[0] == "-NoLogo" { + return []byte("PHASE_CA_OBSOLETE 0\r\n"), nil + } + return nil, nil + }} + _, err := (userCATrustInstaller{GOOS: "windows", Home: `C:\\Users\\phase`, Runner: runner}).Trust(certificatePath) + if err == nil || len(runner.commands) != 1 { + t.Fatalf("malformed thumbprint err=%v commands=%v", err, runner.commands) + } +} + +func TestUserCATrustWindowsStopsWhenObsoleteRootRemovalFails(t *testing.T) { + certificatePath, _, _ := writeTrustTestCA(t) + runner := &recordingTrustRunner{run: func(_ string, args ...string) ([]byte, error) { + switch { + case len(args) > 0 && args[0] == "-NoLogo": + return []byte("PHASE_CA_OBSOLETE " + strings.Repeat("AB", 20) + "\r\n"), nil + case len(args) > 1 && args[1] == "-delstore": + return []byte("CertUtil: -delstore command FAILED: 0x800704c7 (WIN32: 1223 ERROR_CANCELLED)"), errors.New("exit status 2") + } + return nil, nil + }} + _, err := (userCATrustInstaller{GOOS: "windows", Home: `C:\\Users\\phase`, Runner: runner}).Trust(certificatePath) + if err == nil || !strings.Contains(err.Error(), "ERROR_CANCELLED") { + t.Fatalf("removal failure err = %v", err) + } + for _, command := range runner.commands { + if len(command.Args) > 1 && command.Args[1] == "-addstore" { + t.Fatalf("new CA was added after an obsolete root could not be removed: %v", runner.commands) } } } From 51282821cceb8d4b01eb448e92c2bd81f108f1f7 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 26 Oct 2026 12:39:16 +0530 Subject: [PATCH 2/6] fix(proxy): tighten the Windows rotation comments --- src/pkg/proxy/trust.go | 24 ++++++++++-------------- src/pkg/proxy/trust_test.go | 3 +-- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/pkg/proxy/trust.go b/src/pkg/proxy/trust.go index 3d4ad17c..2da90361 100644 --- a/src/pkg/proxy/trust.go +++ b/src/pkg/proxy/trust.go @@ -18,12 +18,10 @@ import ( const phaseAITrustNickname = "Phase AI Proxy CA" -// windowsPhaseRootInspectionScript only reads the current-user Root store: it -// prints PHASE_CA_CURRENT when the CA being trusted is already there, and -// otherwise one PHASE_CA_OBSOLETE line per older Phase-owned root. It must not -// delete those roots itself: under -NonInteractive the Cert: drive refuses any -// removal from the current-user Root store ("The operation is on user root -// store and UI is not allowed"), so trustWindows removes them with certutil. +// windowsPhaseRootInspectionScript only reads the current-user Root store. It +// prints PHASE_CA_CURRENT when the CA being trusted is already there, otherwise +// one PHASE_CA_OBSOLETE line per older Phase-owned root. Deleting through the +// Cert: drive fails under -NonInteractive, so trustWindows removes them with certutil. const windowsPhaseRootInspectionScript = `$ErrorActionPreference='Stop'; $desired='__PHASE_SHA256__'; $phaseRoots=Get-ChildItem -Path Cert:\CurrentUser\Root | Where-Object { $_.Subject -eq $_.Issuer -and $_.Subject -match '(^|,\s*)CN=Phase AI Proxy CA(,|$)' -and $_.Subject -match '(^|,\s*)O=Phase(,|$)' -and ($_.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.19' -and $_.CertificateAuthority }) }; if ($phaseRoots | Where-Object { $_.GetCertHashString('SHA256').ToUpperInvariant() -eq $desired }) { Write-Output 'PHASE_CA_CURRENT'; exit 0 }; $phaseRoots | ForEach-Object { Write-Output ('PHASE_CA_OBSOLETE ' + $_.Thumbprint) }` // ErrUserCATrustUnsupported means Phase cannot safely modify a trust store for @@ -134,7 +132,7 @@ func UserCATrustNotice(goos string) string { case "darwin": return "macOS asks for your login password or Touch ID once: the public Phase AI CA is added to your login keychain's trust settings. No sudo, nothing system-wide." case "windows": - return "Windows shows one security warning to confirm adding the public Phase AI CA to your user Root store, and one to remove each older Phase AI CA it replaces. Nothing system-wide." + return "Windows shows a security warning to confirm adding the public Phase AI CA to your user Root store, plus one for each older Phase AI CA it removes. Nothing system-wide." default: return "" } @@ -317,10 +315,9 @@ func (i userCATrustInstaller) trustWindows(certificatePath string, certificate * if err != nil { return UserCATrustResult{}, fmt.Errorf("inspect Phase AI CA in Windows current-user Root store: %w", err) } + // Remove obsolete roots first so a rotation never leaves an old Phase root + // trusted. Windows confirms each removal, as it does the add below. for _, thumbprint := range obsoleteThumbprints { - // Removing obsolete roots before adding the new one keeps rotation from - // leaving an old Phase root trusted. Windows asks the user to confirm - // each removal, as it does for -addstore below. deleteOutput, deleteErr := i.Runner.Run(certutil, "-user", "-delstore", "Root", thumbprint) if deleteErr != nil { return UserCATrustResult{}, trustCommandError("remove obsolete Phase AI CA from Windows current-user Root store", deleteErr, deleteOutput) @@ -335,10 +332,9 @@ func (i userCATrustInstaller) trustWindows(certificatePath string, certificate * return UserCATrustResult{Store: "Windows current-user Root store"}, nil } -// parseWindowsObsoletePhaseRoots reads the PHASE_CA_OBSOLETE lines printed by -// windowsPhaseRootInspectionScript. Each value must be a SHA-1 certificate -// hash: certutil -delstore also accepts store indexes and common names, so -// anything else could select a certificate Phase does not own. +// parseWindowsObsoletePhaseRoots returns the SHA-1 thumbprints the inspection +// script printed. certutil -delstore also matches store indexes and common +// names, so anything but a 40-hex thumbprint is rejected. func parseWindowsObsoletePhaseRoots(output []byte) ([]string, error) { thumbprints := make([]string, 0) for _, line := range strings.Split(string(output), "\n") { diff --git a/src/pkg/proxy/trust_test.go b/src/pkg/proxy/trust_test.go index 61a0d820..11e8589b 100644 --- a/src/pkg/proxy/trust_test.go +++ b/src/pkg/proxy/trust_test.go @@ -195,8 +195,7 @@ func TestUserCATrustWindowsUsesUserStore(t *testing.T) { t.Fatalf("Windows inspection is not scoped to Phase-owned current-user roots; missing %q in %s", required, inspectionArgs) } } - // PowerShell runs -NonInteractive, where the Cert: drive refuses every - // removal from the current-user Root store. + // The Cert: drive refuses Root store removals under -NonInteractive. if strings.Contains(inspectionArgs, "Remove-Item") { t.Fatalf("Windows inspection must not delete through the Cert: drive: %s", inspectionArgs) } From 0d6e3c719e772ced5086430230aedd34d17f0926 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 26 Oct 2026 12:39:16 +0530 Subject: [PATCH 3/6] ci: prove Windows CA rotation on a real user Root store (temporary) A background job accepts the Root store confirmations certutil shows, since nobody is at the runner's desktop. Remove once it has passed. --- .github/scripts/windows-ca-rotation.ps1 | 72 +++++++++++++++++++++++ .github/workflows/windows-ca-rotation.yml | 31 ++++++++++ 2 files changed, 103 insertions(+) create mode 100644 .github/scripts/windows-ca-rotation.ps1 create mode 100644 .github/workflows/windows-ca-rotation.yml diff --git a/.github/scripts/windows-ca-rotation.ps1 b/.github/scripts/windows-ca-rotation.ps1 new file mode 100644 index 00000000..eb85f0cf --- /dev/null +++ b/.github/scripts/windows-ca-rotation.ps1 @@ -0,0 +1,72 @@ +# Proves Phase AI CA rotation against a real current-user Root store on a +# Windows runner. The Root store confirmation dialogs certutil shows are +# accepted by a background job, since nobody is at the runner's desktop. +param([Parameter(Mandatory = $true)][string]$Phase) +$ErrorActionPreference = 'Stop' +$Phase = (Resolve-Path $Phase).Path + +$clicker = Start-Job -ScriptBlock { + Add-Type @" +using System; +using System.Runtime.InteropServices; +public static class Win32 { + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindow(string cls, string title); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string cls, string title); + [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); +} +"@ + $clicks = @() + $deadline = (Get-Date).AddMinutes(7) + while ((Get-Date) -lt $deadline) { + foreach ($title in @('Security Warning', 'Root Certificate Store')) { + $dialog = [Win32]::FindWindow('#32770', $title) + if ($dialog -eq [IntPtr]::Zero) { continue } + $yes = [Win32]::FindWindowEx($dialog, [IntPtr]::Zero, 'Button', '&Yes') + if ($yes -eq [IntPtr]::Zero) { continue } + [void][Win32]::SendMessage($yes, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) # BM_CLICK + $clicks += "$(Get-Date -Format HH:mm:ss) $title" + Start-Sleep -Milliseconds 700 + } + Start-Sleep -Milliseconds 150 + } + $clicks +} + +function Invoke-Enable([string]$Label) { + $output = & $Phase ai enable --harness claude-code --mask --trust-ca 2>&1 | Out-String + Write-Host "--- $Label (exit $LASTEXITCODE)" + Write-Host $output + if ($LASTEXITCODE -ne 0) { throw "$Label failed" } + return $output +} +function Get-PhaseRoots { + Get-ChildItem Cert:\CurrentUser\Root | Where-Object { $_.Subject -match 'CN=Phase AI Proxy CA' } +} +function Get-LocalCAThumbprint { + (New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 "$env:USERPROFILE\.phase\ai\ca.pem").Thumbprint +} + +$first = Invoke-Enable 'first enable' +if ($first -notmatch 'CA trusted in the Windows current-user Root store') { throw 'first enable did not trust the CA' } +$firstThumbprint = Get-LocalCAThumbprint +if (@(Get-PhaseRoots).Count -ne 1) { throw 'expected exactly one Phase root after the first enable' } + +# Replace the CA: with ca.pem gone, Ensure generates a new one and the old +# root stays trusted, which is the rotation case. +Remove-Item "$env:USERPROFILE\.phase\ai\ca.pem" +$second = Invoke-Enable 'second enable (rotation)' +if ($second -notmatch 'CA trusted in the Windows current-user Root store') { throw 'rotation did not trust the new CA' } +$secondThumbprint = Get-LocalCAThumbprint +if ($secondThumbprint -eq $firstThumbprint) { throw 'the CA was not replaced' } +$roots = @(Get-PhaseRoots) +if ($roots.Count -ne 1 -or $roots[0].Thumbprint -ne $secondThumbprint) { + throw "expected only the new root $secondThumbprint, found: $($roots | ForEach-Object Thumbprint)" +} + +$third = Invoke-Enable 'third enable (idempotent)' +if ($third -notmatch 'already trusted') { throw 'third enable did not report the CA as already trusted' } + +Stop-Job $clicker; $accepted = Receive-Job $clicker; Remove-Job $clicker +Write-Host "dialogs accepted: $($accepted.Count)"; $accepted | ForEach-Object { Write-Host " $_" } +if ($accepted.Count -lt 3) { throw "expected at least three confirmations (add, remove, add), got $($accepted.Count)" } +Write-Host 'OK: Windows CA rotation' diff --git a/.github/workflows/windows-ca-rotation.yml b/.github/workflows/windows-ca-rotation.yml new file mode 100644 index 00000000..d756fcd7 --- /dev/null +++ b/.github/workflows/windows-ca-rotation.yml @@ -0,0 +1,31 @@ +# Temporary: proves CA rotation against a real Windows user Root store while +# PR #324 is open. Remove once it has passed. +name: "Windows CA rotation" + +on: + push: + branches: [fix--windows-ca-rotation] + workflow_dispatch: + +jobs: + rotation: + runs-on: windows-latest + timeout-minutes: 12 + env: + CGO_ENABLED: "0" + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: "1.25.x" + cache-dependency-path: src/go.sum + + - name: Build + working-directory: src + run: go build -o ..\phase-cli.exe . + + - name: Trust, rotate and re-trust the Phase AI CA + shell: pwsh + run: .\.github\scripts\windows-ca-rotation.ps1 .\phase-cli.exe From 9800e6c665f8d777f63dae3ee3003e2da3245709 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 26 Oct 2026 13:03:54 +0530 Subject: [PATCH 4/6] fix(proxy): give Windows PowerShell its own module path for the trust step Launched from PowerShell 7, Windows PowerShell inherits that shell's PSModulePath, cannot auto-load the certificate provider, and the trust step fails because the Cert: drive does not exist. --- src/pkg/proxy/trust.go | 30 +++++++++++++++++++++++++++++- src/pkg/proxy/trust_test.go | 16 ++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/pkg/proxy/trust.go b/src/pkg/proxy/trust.go index 2da90361..2ef7c0df 100644 --- a/src/pkg/proxy/trust.go +++ b/src/pkg/proxy/trust.go @@ -157,7 +157,35 @@ func (osTrustCommandRunner) LookPath(file string) (string, error) { } func (osTrustCommandRunner) Run(name string, args ...string) ([]byte, error) { - return exec.Command(name, args...).CombinedOutput() + command := exec.Command(name, args...) + if strings.EqualFold(filepath.Base(name), "powershell.exe") { + command.Env = windowsPowerShellEnvironment(os.Environ()) + } + return command.CombinedOutput() +} + +// windowsPowerShellEnvironment gives Windows PowerShell its own module path. +// Launched from PowerShell 7 it inherits that shell's PSModulePath, cannot +// auto-load the certificate provider, and reports that the Cert: drive does +// not exist. +func windowsPowerShellEnvironment(base []string) []string { + systemRoot := os.Getenv("SystemRoot") + if systemRoot == "" { + systemRoot = `C:\Windows` + } + programFiles := os.Getenv("ProgramFiles") + if programFiles == "" { + programFiles = `C:\Program Files` + } + modulePath := "PSModulePath=" + programFiles + `\WindowsPowerShell\Modules;` + systemRoot + `\System32\WindowsPowerShell\v1.0\Modules` + environment := make([]string, 0, len(base)+1) + for _, entry := range base { + if key, _, _ := strings.Cut(entry, "="); strings.EqualFold(key, "PSModulePath") { + continue + } + environment = append(environment, entry) + } + return append(environment, modulePath) } type userCATrustInstaller struct { diff --git a/src/pkg/proxy/trust_test.go b/src/pkg/proxy/trust_test.go index 11e8589b..c6ccc974 100644 --- a/src/pkg/proxy/trust_test.go +++ b/src/pkg/proxy/trust_test.go @@ -423,3 +423,19 @@ func TestUserCATrustLinuxMissingCertutilIsAMissingToolError(t *testing.T) { t.Fatalf("commands ran without certutil: %v", runner.commands) } } + +func TestWindowsPowerShellEnvironmentReplacesTheInheritedModulePath(t *testing.T) { + environment := windowsPowerShellEnvironment([]string{`PATH=C:\bin`, `PSModulePath=C:\Program Files\PowerShell\7\Modules`, `HOME=C:\Users\phase`}) + var modulePaths []string + for _, entry := range environment { + if strings.HasPrefix(strings.ToLower(entry), "psmodulepath=") { + modulePaths = append(modulePaths, entry) + } + } + if len(modulePaths) != 1 || !strings.Contains(modulePaths[0], `\WindowsPowerShell\v1.0\Modules`) || strings.Contains(modulePaths[0], `PowerShell\7`) { + t.Fatalf("module path = %v", modulePaths) + } + if len(environment) != 3 { + t.Fatalf("environment = %v", environment) + } +} From 8205fcc0214310012991a60c21447049399e95ee Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 26 Oct 2026 13:12:41 +0530 Subject: [PATCH 5/6] ci: the Windows rotation check needs no dialog handling on a runner --- .github/scripts/windows-ca-rotation.ps1 | 33 +------------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/.github/scripts/windows-ca-rotation.ps1 b/.github/scripts/windows-ca-rotation.ps1 index eb85f0cf..c48678d9 100644 --- a/.github/scripts/windows-ca-rotation.ps1 +++ b/.github/scripts/windows-ca-rotation.ps1 @@ -1,37 +1,9 @@ # Proves Phase AI CA rotation against a real current-user Root store on a -# Windows runner. The Root store confirmation dialogs certutil shows are -# accepted by a background job, since nobody is at the runner's desktop. +# Windows runner, where certutil raises no confirmation prompts. param([Parameter(Mandatory = $true)][string]$Phase) $ErrorActionPreference = 'Stop' $Phase = (Resolve-Path $Phase).Path -$clicker = Start-Job -ScriptBlock { - Add-Type @" -using System; -using System.Runtime.InteropServices; -public static class Win32 { - [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindow(string cls, string title); - [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string cls, string title); - [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); -} -"@ - $clicks = @() - $deadline = (Get-Date).AddMinutes(7) - while ((Get-Date) -lt $deadline) { - foreach ($title in @('Security Warning', 'Root Certificate Store')) { - $dialog = [Win32]::FindWindow('#32770', $title) - if ($dialog -eq [IntPtr]::Zero) { continue } - $yes = [Win32]::FindWindowEx($dialog, [IntPtr]::Zero, 'Button', '&Yes') - if ($yes -eq [IntPtr]::Zero) { continue } - [void][Win32]::SendMessage($yes, 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) # BM_CLICK - $clicks += "$(Get-Date -Format HH:mm:ss) $title" - Start-Sleep -Milliseconds 700 - } - Start-Sleep -Milliseconds 150 - } - $clicks -} - function Invoke-Enable([string]$Label) { $output = & $Phase ai enable --harness claude-code --mask --trust-ca 2>&1 | Out-String Write-Host "--- $Label (exit $LASTEXITCODE)" @@ -66,7 +38,4 @@ if ($roots.Count -ne 1 -or $roots[0].Thumbprint -ne $secondThumbprint) { $third = Invoke-Enable 'third enable (idempotent)' if ($third -notmatch 'already trusted') { throw 'third enable did not report the CA as already trusted' } -Stop-Job $clicker; $accepted = Receive-Job $clicker; Remove-Job $clicker -Write-Host "dialogs accepted: $($accepted.Count)"; $accepted | ForEach-Object { Write-Host " $_" } -if ($accepted.Count -lt 3) { throw "expected at least three confirmations (add, remove, add), got $($accepted.Count)" } Write-Host 'OK: Windows CA rotation' From 5efa78fb2f737323266737f421ed776b09353fc4 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 26 Oct 2026 13:26:04 +0530 Subject: [PATCH 6/6] ci: drop the temporary Windows rotation check It proved the delete-and-add rotation on a real user Root store once, but the confirmation behaviour on hosted Windows runners is not deterministic, so it cannot stay as a gate. --- .github/scripts/windows-ca-rotation.ps1 | 41 ----------------------- .github/workflows/windows-ca-rotation.yml | 31 ----------------- 2 files changed, 72 deletions(-) delete mode 100644 .github/scripts/windows-ca-rotation.ps1 delete mode 100644 .github/workflows/windows-ca-rotation.yml diff --git a/.github/scripts/windows-ca-rotation.ps1 b/.github/scripts/windows-ca-rotation.ps1 deleted file mode 100644 index c48678d9..00000000 --- a/.github/scripts/windows-ca-rotation.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -# Proves Phase AI CA rotation against a real current-user Root store on a -# Windows runner, where certutil raises no confirmation prompts. -param([Parameter(Mandatory = $true)][string]$Phase) -$ErrorActionPreference = 'Stop' -$Phase = (Resolve-Path $Phase).Path - -function Invoke-Enable([string]$Label) { - $output = & $Phase ai enable --harness claude-code --mask --trust-ca 2>&1 | Out-String - Write-Host "--- $Label (exit $LASTEXITCODE)" - Write-Host $output - if ($LASTEXITCODE -ne 0) { throw "$Label failed" } - return $output -} -function Get-PhaseRoots { - Get-ChildItem Cert:\CurrentUser\Root | Where-Object { $_.Subject -match 'CN=Phase AI Proxy CA' } -} -function Get-LocalCAThumbprint { - (New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 "$env:USERPROFILE\.phase\ai\ca.pem").Thumbprint -} - -$first = Invoke-Enable 'first enable' -if ($first -notmatch 'CA trusted in the Windows current-user Root store') { throw 'first enable did not trust the CA' } -$firstThumbprint = Get-LocalCAThumbprint -if (@(Get-PhaseRoots).Count -ne 1) { throw 'expected exactly one Phase root after the first enable' } - -# Replace the CA: with ca.pem gone, Ensure generates a new one and the old -# root stays trusted, which is the rotation case. -Remove-Item "$env:USERPROFILE\.phase\ai\ca.pem" -$second = Invoke-Enable 'second enable (rotation)' -if ($second -notmatch 'CA trusted in the Windows current-user Root store') { throw 'rotation did not trust the new CA' } -$secondThumbprint = Get-LocalCAThumbprint -if ($secondThumbprint -eq $firstThumbprint) { throw 'the CA was not replaced' } -$roots = @(Get-PhaseRoots) -if ($roots.Count -ne 1 -or $roots[0].Thumbprint -ne $secondThumbprint) { - throw "expected only the new root $secondThumbprint, found: $($roots | ForEach-Object Thumbprint)" -} - -$third = Invoke-Enable 'third enable (idempotent)' -if ($third -notmatch 'already trusted') { throw 'third enable did not report the CA as already trusted' } - -Write-Host 'OK: Windows CA rotation' diff --git a/.github/workflows/windows-ca-rotation.yml b/.github/workflows/windows-ca-rotation.yml deleted file mode 100644 index d756fcd7..00000000 --- a/.github/workflows/windows-ca-rotation.yml +++ /dev/null @@ -1,31 +0,0 @@ -# Temporary: proves CA rotation against a real Windows user Root store while -# PR #324 is open. Remove once it has passed. -name: "Windows CA rotation" - -on: - push: - branches: [fix--windows-ca-rotation] - workflow_dispatch: - -jobs: - rotation: - runs-on: windows-latest - timeout-minutes: 12 - env: - CGO_ENABLED: "0" - steps: - - uses: actions/checkout@v7 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version: "1.25.x" - cache-dependency-path: src/go.sum - - - name: Build - working-directory: src - run: go build -o ..\phase-cli.exe . - - - name: Trust, rotate and re-trust the Phase AI CA - shell: pwsh - run: .\.github\scripts\windows-ca-rotation.ps1 .\phase-cli.exe