feat(automation): support qualified single-user Windows operation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
#Requires -Version 7.2
|
||||
<# The owner runs NewKey/Sign on a separate Windows identity or signing machine.
|
||||
Only the public certificate reaches the runner. Never export the private key. #>
|
||||
<# The owner signs from a trusted operator terminal, with workers stopped.
|
||||
Same-user unattended use additionally requires native signing-key denial probes.
|
||||
Dedicated-runner installations may keep the key under a separate identity. #>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('NewKey','ExportPublicKey','Sign','Verify')][string]$Action,
|
||||
@@ -15,6 +16,7 @@ param(
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {
|
||||
if ($Action -ne 'Verify' -and $env:JOBBJAKT_ORCHESTRATED) { throw 'Autonomous workers cannot invoke owner signing actions' }
|
||||
if ($Action -eq 'NewKey') {
|
||||
$certificate = New-SelfSignedCertificate -Subject 'CN=Jobjakt Roadmap Owner' -CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyAlgorithm RSA -KeyLength 3072 -HashAlgorithm SHA256 -KeyExportPolicy NonExportable `
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#Requires -Version 7.2
|
||||
param([ValidateSet('CreateKey','RemoveKey','Probe','TestOwnerKey')][string]$Action,[string]$Tag,[string]$Thumbprint,[string]$PlanPath)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($Action -eq 'CreateKey') {
|
||||
$cert = New-SelfSignedCertificate -Subject "CN=Jobjakt-SYNTHETIC-$Tag" -CertStoreLocation Cert:\CurrentUser\My `
|
||||
-KeyAlgorithm RSA -KeyLength 2048 -KeyExportPolicy NonExportable -KeyUsage DigitalSignature -NotAfter (Get-Date).AddHours(2)
|
||||
try {
|
||||
$rsa = [Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
|
||||
$null = $rsa.SignData([Text.Encoding]::UTF8.GetBytes('synthetic-positive-control'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
@{thumbprint=$cert.Thumbprint;keyName=$rsa.Key.KeyName;tag=$Tag} | ConvertTo-Json -Compress
|
||||
} catch {
|
||||
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -DeleteKey
|
||||
throw
|
||||
} finally { if ($rsa) { $rsa.Dispose() } }
|
||||
exit 0
|
||||
}
|
||||
if ($Action -eq 'TestOwnerKey') {
|
||||
$cert=Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint"
|
||||
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
|
||||
try {
|
||||
$null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('positive-control-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1)
|
||||
@{keyName=$rsa.Key.KeyName;certificateSha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($cert.RawData)).ToLowerInvariant()} | ConvertTo-Json -Compress
|
||||
} finally { $rsa.Dispose() }
|
||||
exit 0
|
||||
}
|
||||
if ($Action -eq 'RemoveKey') {
|
||||
$cert = Get-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint"
|
||||
if ($cert.Subject -cne "CN=Jobjakt-SYNTHETIC-$Tag") { throw 'Refusing to remove any non-canary certificate' }
|
||||
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$Thumbprint" -DeleteKey
|
||||
exit 0
|
||||
}
|
||||
$plan = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json
|
||||
$records = @()
|
||||
foreach ($item in $plan.files) {
|
||||
$readDenied=$false; $writeDenied=$false
|
||||
try { $f=[IO.File]::Open($item.path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); $f.Dispose() } catch { $readDenied=$true }
|
||||
try { $f=[IO.File]::Open($item.path,[IO.FileMode]::Open,[IO.FileAccess]::Write,[IO.FileShare]::ReadWrite); $f.Dispose() } catch { $writeDenied=$true }
|
||||
$records += @{category=$item.category;readDenied=$readDenied;writeDenied=$writeDenied}
|
||||
}
|
||||
function Can-SignCertificate([string]$CertificateThumbprint) {
|
||||
if (-not $CertificateThumbprint) { return $false }
|
||||
try {
|
||||
$cert=Get-Item -LiteralPath "Cert:\CurrentUser\My\$CertificateThumbprint"
|
||||
$rsa=[Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert)
|
||||
try { $null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('sandbox-canary-only-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1); return $true }
|
||||
finally { if ($rsa) { $rsa.Dispose() } }
|
||||
} catch { return $false }
|
||||
}
|
||||
$canSign = Can-SignCertificate $plan.key.thumbprint
|
||||
try {
|
||||
$key=[Security.Cryptography.CngKey]::Open($plan.key.keyName)
|
||||
$rsa=[Security.Cryptography.RSACng]::new($key)
|
||||
try { $null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('sandbox-canary-only-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1); $canSign=$true }
|
||||
finally { $rsa.Dispose(); $key.Dispose() }
|
||||
} catch {}
|
||||
$ownerCanSign = Can-SignCertificate $plan.ownerThumbprint
|
||||
if ($plan.ownerKeyName) {
|
||||
try {
|
||||
$key=[Security.Cryptography.CngKey]::Open($plan.ownerKeyName)
|
||||
$rsa=[Security.Cryptography.RSACng]::new($key)
|
||||
try { $null=$rsa.SignData([Text.Encoding]::UTF8.GetBytes('sandbox-canary-only-no-approval'),[Security.Cryptography.HashAlgorithmName]::SHA256,[Security.Cryptography.RSASignaturePadding]::Pkcs1); $ownerCanSign=$true }
|
||||
finally { $rsa.Dispose(); $key.Dispose() }
|
||||
} catch {}
|
||||
}
|
||||
$networkDenied=$false
|
||||
$client=[Net.Sockets.TcpClient]::new()
|
||||
try { $connect=$client.ConnectAsync('127.0.0.1',[int]$plan.port); if (-not $connect.Wait(2000)) { $networkDenied=$true } else { $networkDenied=-not $client.Connected } }
|
||||
catch { $networkDenied=$true }
|
||||
finally { $client.Dispose() }
|
||||
$positive=([IO.File]::ReadAllText($PlanPath).Length -gt 0)
|
||||
$positivePath=Join-Path (Split-Path $PlanPath) 'positive.txt'
|
||||
try { [IO.File]::WriteAllText($positivePath,'synthetic'); $positive=$positive -and [IO.File]::ReadAllText($positivePath).Equals('synthetic') }
|
||||
catch { $positive=$false }
|
||||
@{positiveControl=$positive;files=$records;networkDenied=$networkDenied;signingKeyDenied=(-not $canSign);
|
||||
ownerKeyDenied=([bool]$plan.ownerThumbprint -and -not $ownerCanSign)} | ConvertTo-Json -Depth 8 -Compress
|
||||
@@ -0,0 +1,26 @@
|
||||
#Requires -Version 7.2
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ControlRoot = (Join-Path ([Environment]::GetFolderPath('UserProfile')) '.jobjakt-backlog/single-user-local'),
|
||||
[string]$Name = 'Jobjakt development backlog',
|
||||
[switch]$Install
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
$root=[IO.Path]::GetFullPath($ControlRoot)
|
||||
$config=Get-Content -LiteralPath (Join-Path $root 'config.json') -Raw | ConvertFrom-Json
|
||||
$check='import sys,json; from pathlib import Path; root=Path(sys.argv[1]); sys.path.insert(0,str(root)); import local_mode; c=json.loads((root/"config.json").read_text()); local_mode.require_unattended(root,c); local_mode.validate_identity(c,local_mode.detect_identity()[1])'
|
||||
& $config.pythonExecutable -c $check $root
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Task registration requires current native filesystem/network/owner-key qualification; no task created' }
|
||||
if (-not $Install) {
|
||||
Write-Output "Preview: limited task '$Name', every 15 minutes and at logon, command start (preserves manual pause). Add -Install to register."
|
||||
exit 0
|
||||
}
|
||||
if (-not [Environment]::UserInteractive -or $env:JOBBJAKT_ORCHESTRATED) { throw 'Register from your operator terminal' }
|
||||
$action=New-ScheduledTaskAction -Execute $config.powershellExecutable -WorkingDirectory $root `
|
||||
-Argument ('-NoProfile -WindowStyle Hidden -File "'+(Join-Path $root 'Run-JobjaktBacklog.ps1')+'" -Command start -ControlRoot "'+$root+'"')
|
||||
$repeat=New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 15)
|
||||
$logon=New-ScheduledTaskTrigger -AtLogOn -User $config.windowsUsername
|
||||
$principal=New-ScheduledTaskPrincipal -UserId $config.windowsUsername -LogonType Interactive -RunLevel Limited
|
||||
$settings=New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 8)
|
||||
Register-ScheduledTask -TaskName $Name -Action $action -Trigger @($repeat,$logon) -Principal $principal -Settings $settings | Out-Null
|
||||
Write-Output 'Registered for the existing login. A locked desktop is supported; logged-out execution needs separate Task Scheduler credential setup. No wake/sleep/security setting changed.'
|
||||
@@ -1,22 +1,38 @@
|
||||
#Requires -Version 7.2
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('init','validate','dry-run','start','resume','status','pause','retry','current','logs','recover','configure')][string]$Command,
|
||||
[Parameter(Mandatory)][string]$ControlRoot,
|
||||
[Parameter(Mandatory)][ValidateSet('init','validate','dry-run','start','resume','status','pause','retry','current','logs','recover','configure','setup','auth','auth-status','qualify','approve','prepare','key','enable-unattended')][string]$Command,
|
||||
[string]$ControlRoot,
|
||||
[string]$Repository,
|
||||
[string]$Stage,
|
||||
[string]$Manifest,
|
||||
[string]$Python = 'python'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $ControlRoot) {
|
||||
$ControlRoot = if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'config.json')) { $PSScriptRoot } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.jobjakt-backlog/single-user-local' }
|
||||
}
|
||||
$root = [IO.Path]::GetFullPath($ControlRoot)
|
||||
if ($Command -eq 'init') {
|
||||
if ($Command -in @('init','setup')) {
|
||||
$engine = Join-Path $PSScriptRoot '../automation/controller.py'
|
||||
if (-not (Test-Path -LiteralPath $engine)) { $engine = Join-Path $root 'controller.py' }
|
||||
if (-not $Repository) {
|
||||
if (Test-Path -LiteralPath (Join-Path $root 'config.json')) {
|
||||
$Repository = (Get-Content -LiteralPath (Join-Path $root 'config.json') -Raw | ConvertFrom-Json).repository
|
||||
} else {
|
||||
$Repository = & git -C $PSScriptRoot rev-parse --show-toplevel
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Specify the isolated worker repository with -Repository' }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$engine = Join-Path $root 'controller.py'
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $engine -PathType Leaf)) { throw "Controller missing: $engine" }
|
||||
$arguments = @($engine, $Command, '--control-root', $root)
|
||||
if ($Command -notin @('init','setup') -and (Test-Path -LiteralPath (Join-Path $root 'config.json'))) {
|
||||
$config = Get-Content -LiteralPath (Join-Path $root 'config.json') -Raw | ConvertFrom-Json
|
||||
if ($Python -eq 'python' -and $config.pythonExecutable) { $Python = $config.pythonExecutable }
|
||||
}
|
||||
if ($Repository) { $arguments += @('--repository', $Repository) }
|
||||
if ($Stage) { $arguments += @('--stage', $Stage) }
|
||||
if ($Manifest) { $arguments += @('--manifest', $Manifest) }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Dot-source after setup. This changes only the current terminal, never your profile.
|
||||
function global:Jobjakt {
|
||||
$control = Join-Path ([Environment]::GetFolderPath('UserProfile')) '.jobjakt-backlog/single-user-local'
|
||||
$config = Get-Content -LiteralPath (Join-Path $control 'config.json') -Raw | ConvertFrom-Json
|
||||
& $config.powershellExecutable -NoProfile -File (Join-Path $control 'Run-JobjaktBacklog.ps1') @args
|
||||
}
|
||||
Reference in New Issue
Block a user