feat(scripts): add Set-CIGuestCredential.ps1
Lint / pssa (push) Failing after 36s
Lint / python (push) Successful in 40s

Stores a guest-VM credential into the LocalSystem keyring vault via a
transient SYSTEM scheduled task, using the production venv's keyring
(the backend ci_orchestrator reads). Fixes the per-user vault mismatch
that made act_runner (LocalSystem) fail with "Credential not found in
keyring" for cmdkey/UI-stored credentials.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Simone
2026-05-17 11:56:40 +02:00
parent 5371ffe48a
commit 2ccc9009ff
+210
View File
@@ -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 '<target>' 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
}