diff --git a/scripts/Set-CIGuestCredential.ps1 b/scripts/Set-CIGuestCredential.ps1 new file mode 100644 index 0000000..e999be9 --- /dev/null +++ b/scripts/Set-CIGuestCredential.ps1 @@ -0,0 +1,210 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Stores a CI guest-VM credential into the LocalSystem keyring vault. + +.DESCRIPTION + act_runner runs as the LocalSystem service account. Windows Credential + Manager / keyring vaults are per-user, so a credential added with + `cmdkey` or the Credential Manager UI under an interactive user is + invisible to the runner. The orchestrator then fails with: + + Credential '' not found in keyring. + + This script writes the credential into the SYSTEM vault using the + production venv's `keyring` itself (the exact backend + `ci_orchestrator` reads with `keyring.get_credential(target, None)`), + via a transient SYSTEM scheduled task. Using keyring for both write + and read avoids cmdkey/keyring format and persistence mismatches. + + Known-issue references: + plans/A1-closeout.md (KeyError: 'BuildVMGuest') + plans/implementation-plan-A-B.md (keyring under SYSTEM) + + The plaintext password unavoidably transits a temporary .py file. + The file is created with an Administrators/SYSTEM-only ACL, overwritten + with random bytes, then deleted. The password is never placed in a + scheduled-task argument or the command line. + +.PARAMETER Target + Credential target name. Must match the runner env + GITEA_CI_GUEST_CRED_TARGET. Default: BuildVMGuest + +.PARAMETER UserName + Guest login username in the form the transport expects (e.g. + 'WINBUILD\ci_build', '.\ci_build', or 'ci_build'). + +.PARAMETER Password + Guest password as a SecureString. If omitted, prompted securely. + +.PARAMETER VenvPython + Path to the production venv interpreter act_runner uses. Default: + CI_VENV_PYTHON env var, else F:\CI\python\venv\Scripts\python.exe + +.PARAMETER SkipVerify + Skip the post-write read-back verification (also run as SYSTEM). + +.EXAMPLE + .\Set-CIGuestCredential.ps1 -UserName 'WINBUILD\ci_build' + +.EXAMPLE + # Non-interactive (e.g. from another provisioning script) + $pw = Read-Host 'pw' -AsSecureString + .\Set-CIGuestCredential.ps1 -Target BuildVMGuest -UserName '.\ci_build' -Password $pw + +.EXAMPLE + .\Set-CIGuestCredential.ps1 -UserName '.\ci_build' -WhatIf +#> +[CmdletBinding(SupportsShouldProcess)] +param( + [ValidateNotNullOrEmpty()] + [string] $Target = 'BuildVMGuest', + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserName, + + [System.Security.SecureString] $Password, + + [string] $VenvPython = $(if ($env:CI_VENV_PYTHON) { $env:CI_VENV_PYTHON } + else { 'F:\CI\python\venv\Scripts\python.exe' }), + + [switch] $SkipVerify +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $VenvPython)) { + throw "venv interpreter not found: '$VenvPython'. Set -VenvPython or CI_VENV_PYTHON." +} + +if (-not $Password) { + $Password = Read-Host "Password for '$UserName'" -AsSecureString +} + +# --- Run a script block as SYSTEM via a transient scheduled task ---------- +function Invoke-AsSystem { + param( + [Parameter(Mandatory)] [string] $TaskName, + [Parameter(Mandatory)] [string] $Execute, + [Parameter(Mandatory)] [string] $Argument, + [int] $TimeoutSec = 30 + ) + $action = New-ScheduledTaskAction -Execute $Execute -Argument $Argument + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' ` + -LogonType ServiceAccount -RunLevel Highest + Register-ScheduledTask -TaskName $TaskName -Action $action ` + -Principal $principal -Force | Out-Null + try { + Start-ScheduledTask -TaskName $TaskName + $deadline = (Get-Date).AddSeconds($TimeoutSec) + do { + Start-Sleep -Milliseconds 500 + $info = Get-ScheduledTaskInfo -TaskName $TaskName + $state = (Get-ScheduledTask -TaskName $TaskName).State + } while ($state -eq 'Running' -and (Get-Date) -lt $deadline) + return $info.LastTaskResult + } + finally { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false ` + -ErrorAction SilentlyContinue + } +} + +# --- Harden + shred helper for the transient secret file ------------------ +function Remove-Securely { + param([Parameter(Mandatory)] [string] $Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + try { + $len = (Get-Item -LiteralPath $Path).Length + if ($len -gt 0) { + $rnd = New-Object byte[] $len + [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($rnd) + [IO.File]::WriteAllBytes($Path, $rnd) + } + } catch { } + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue +} + +$tmpDir = $env:TEMP +$setPy = Join-Path $tmpDir ("setcred_{0}.py" -f ([guid]::NewGuid().ToString('N'))) +$verPy = Join-Path $tmpDir ("vercred_{0}.py" -f ([guid]::NewGuid().ToString('N'))) +$verOut = Join-Path $tmpDir ("vercred_{0}.txt" -f ([guid]::NewGuid().ToString('N'))) + +if (-not $PSCmdlet.ShouldProcess("$Target ($UserName)", + "store credential in SYSTEM keyring vault")) { + return +} + +try { + # Decrypt SecureString only at the moment of writing the temp script. + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password) + $plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + + # Python literals: pass via triple-quoted raw-ish strings. Reject quotes + # that would break out of the literal rather than risk silent corruption. + if ($UserName -match "'''" -or $plain -match "'''") { + throw "UserName/Password contains a triple-quote sequence; unsupported." + } + $setBody = @" +import keyring +keyring.set_password(r'''$Target''', r'''$UserName''', r'''$plain''') +print('OK') +"@ + Set-Content -LiteralPath $setPy -Value $setBody -Encoding UTF8 + Remove-Variable plain -ErrorAction SilentlyContinue + + # Lock the secret file down to SYSTEM + Administrators before it runs. + $acl = New-Object Security.AccessControl.FileSecurity + $acl.SetAccessRuleProtection($true, $false) + foreach ($id in 'SYSTEM', 'Administrators') { + $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $id, 'FullControl', 'Allow'))) + } + Set-Acl -LiteralPath $setPy -AclObject $acl + + $rc = Invoke-AsSystem -TaskName 'CI-SetGuestCred' ` + -Execute $VenvPython -Argument "`"$setPy`"" + if ($rc -ne 0) { + throw "keyring.set_password task exited with code $rc." + } + Write-Host "Stored '$Target' (user '$UserName') in the SYSTEM vault." ` + -ForegroundColor Green +} +finally { + Remove-Securely -Path $setPy +} + +if ($SkipVerify) { return } + +try { + $verBody = @" +import keyring +c = keyring.get_credential(r'''$Target''', None) +open(r'''$verOut''', 'w', encoding='utf-8').write( + 'NONE' if c is None else 'OK:' + c.username) +"@ + Set-Content -LiteralPath $verPy -Value $verBody -Encoding UTF8 + Invoke-AsSystem -TaskName 'CI-VerifyGuestCred' ` + -Execute $VenvPython -Argument "`"$verPy`"" | Out-Null + + $result = if (Test-Path -LiteralPath $verOut) { + (Get-Content -LiteralPath $verOut -Raw).Trim() + } else { 'NONE' } + + if ($result -like 'OK:*') { + Write-Host "Verified: SYSTEM keyring returns user '$($result.Substring(3))'." ` + -ForegroundColor Green + } + else { + throw "Verification failed: SYSTEM keyring.get_credential('$Target', None) " + + "returned None. Credential did not persist." + } +} +finally { + Remove-Item -LiteralPath $verPy -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $verOut -Force -ErrorAction SilentlyContinue +}