PublicBootstrap.psm1
|
<#
.SYNOPSIS Secure bootstrap – downloads and executes private workflows. Uses MSAL.PS cmdlets for authentication (Interactive by default, DeviceCode optional). Version: 5.0 #> # Embedded public key – replace with your flattened XML $ManifestPublicKeyXml = @" <RSAKeyValue><Modulus>6JcAAw1jwc5n8O6ADX+QEgPZo05r0KFpPoXIWMn62JP6IacmJr8XFXawU2q8mBI0GYGPQzXEDPT2dUYbCEAvGnCaEFefNGmhlQmA7dwSWE8zuO2sMFnd90SkE6y7c0/VeBUUaw7B/A//Hm2lQY4vke3Y4ZVupXS8itMf6ILP/ay4MkvhB/+LBMISn/vkCLGdsaDaVCpWBWvV4PO48wso3072V1Gh8m60x7eUhSDLfQu25m9vBnnZ2W3xr8N1I9f7gcwb54XP97oXmlw98A/B2N/648hHs/Daim6VJ4YXaSwPzsEyybdqljC325Eb90omJFof6ZmmW1kk923Ph47mLQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue> "@ # --- Logging Configuration --- $script:LogDir = "C:\Windows\Temp\WorkflowLogs" $script:EventLogFile = Join-Path $script:LogDir "WorkflowEvents.log" $script:TranscriptFile = Join-Path $script:LogDir "WorkflowTranscript.log" function Write-WorkflowEvent { param([string]$Level = "INFO", [string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $entry = "[$timestamp] [$Level] $Message" Write-Host $entry -ForegroundColor $(if ($Level -eq "ERROR") { "Red" } elseif ($Level -eq "WARNING") { "Yellow" } else { "Gray" }) Add-Content -Path $script:EventLogFile -Value $entry -Force } function Start-Workflow { [CmdletBinding()] param( [string]$WorkflowName = "default", [string]$Tenant, [string]$StorageAccount, [string]$ManifestUrl, [string]$CsvPath, [ValidateSet("Interactive", "DeviceCode")] [string]$AuthMethod = "Interactive" ) New-Item -ItemType Directory -Path $script:LogDir -Force | Out-Null try { Start-Transcript -Path $script:TranscriptFile -Append -Force -ErrorAction Stop } catch { Write-Warning "Transcript error: $_" } Write-WorkflowEvent -Level "INFO" -Message "=== Workflow started (AuthMethod: $AuthMethod) ===" try { if (-not (Test-IsElevated)) { throw "Must run as Administrator or SYSTEM." } if (-not $Tenant) { $Tenant = Read-Host "Enter tenant" } if ($Tenant -notmatch '\.onmicrosoft\.com$') { $TenantFQDN = "$Tenant.onmicrosoft.com" } else { $TenantFQDN = $Tenant } Write-WorkflowEvent -Level "INFO" -Message "Tenant: $TenantFQDN" if (-not $StorageAccount) { $StorageAccount = $Tenant } if ($StorageAccount -notmatch '^[a-z0-9]{3,24}$') { throw "Invalid storage account name." } Write-WorkflowEvent -Level "INFO" -Message "Storage Account: $StorageAccount" if (-not $ManifestUrl) { $ManifestUrl = "https://${StorageAccount}.blob.core.windows.net/config/${WorkflowName}.json" } Write-WorkflowEvent -Level "INFO" -Message "Manifest URL: $ManifestUrl" if (-not $CsvPath) { $CsvPath = "C:\Data-$($StorageAccount)\software.csv" } Write-WorkflowEvent -Level "INFO" -Message "CSV path: $CsvPath" Write-Host "Downloading manifest..." -ForegroundColor Cyan try { $response = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $raw = $response.Content $bytes = [System.Text.Encoding]::UTF8.GetBytes($raw) if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $bytes = $bytes[3..($bytes.Length - 1)] } $manifest = [System.Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json } catch { throw "Manifest download failed: $_" } $manifestCopy = $manifest.PSObject.Copy() $manifestCopy.PSObject.Properties.Remove('Signature') $canonicalJson = ConvertTo-CanonicalJson -InputObject $manifestCopy if (-not (Test-ManifestSignatureCanonical -ManifestJson $manifest -CanonicalJson $canonicalJson -PublicKeyXml $ManifestPublicKeyXml)) { throw "Invalid manifest signature." } Write-Host "Manifest signature valid." -ForegroundColor Green $ClientID = $manifest.ClientID $ScriptBaseUrl = $manifest.ScriptBaseUrl $WebhookUrl = $manifest.WebhookUrl if (-not $ClientID -or -not $ScriptBaseUrl) { throw "Manifest missing ClientID or ScriptBaseUrl." } $DefaultScriptName = "default.psm1" $FilesBaseUrl = "https://${StorageAccount}.blob.core.windows.net/private/files/" Write-WorkflowEvent -Level "INFO" -Message "ClientID: $ClientID, ScriptBaseUrl: $ScriptBaseUrl, FilesBaseUrl: $FilesBaseUrl" Test-WindowsVersion -MinimumVersion "10.0.19041" if (-not (Test-AmsiEnabled)) { Write-Warning "AMSI disabled - continuing." } # --- Ensure MSAL.PS is installed --- if (-not (Get-Module -ListAvailable -Name MSAL.PS)) { Write-Host "MSAL.PS module not found. Installing..." -ForegroundColor Yellow Install-Module -Name MSAL.PS -Scope CurrentUser -Force -ErrorAction Stop } try { Import-Module MSAL.PS -Force -ErrorAction Stop $useMsal = $true } catch { Write-Warning "MSAL.PS import failed: $_ – falling back to REST Device Code." $useMsal = $false } # --- Get Storage token --- Write-Host "Starting authentication ($AuthMethod)..." -ForegroundColor Cyan $storageToken = $null $tokenParams = @{ ClientId = $ClientID Authority = "https://login.microsoftonline.com/$TenantFQDN" Scopes = "https://storage.azure.com/.default" } if ($useMsal) { try { if ($AuthMethod -eq "Interactive") { $storageToken = (Get-MsalToken @tokenParams -Interactive).AccessToken } else { $storageToken = (Get-MsalToken @tokenParams -DeviceCode).AccessToken } } catch { Write-Warning "MSAL.PS authentication failed: $_ – falling back to REST Device Code." $useMsal = $false } } # Fallback: REST-based Device Code if (-not $storageToken) { Write-Host "Falling back to REST Device Code flow..." -ForegroundColor Yellow $storageToken = Get-DeviceCodeTokenRest -TenantFQDN $TenantFQDN -ClientID $ClientID -Resource "https://storage.azure.com" } if (-not $storageToken) { throw "Authentication failed for Storage." } Write-Host "Authentication OK." -ForegroundColor Green $userUPN = Get-UserIdentityFromToken -AccessToken $storageToken Write-WorkflowEvent -Level "INFO" -Message "Authenticated user UPN: $userUPN" # --- Derive domain and select private module --- $domain = if ($userUPN -and $userUPN -match '@') { ($userUPN -split '@')[1] } else { $null } $scriptUrl = $null if ($domain) { $domainScriptName = "$domain.psm1" $domainScriptUrl = $ScriptBaseUrl.TrimEnd('/') + '/' + $domainScriptName Write-WorkflowEvent -Level "INFO" -Message "Checking for domain-specific script: $domainScriptUrl" try { $headRequest = Invoke-WebRequest -Uri $domainScriptUrl -Method Head -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop if ($headRequest.StatusCode -eq 200) { $scriptUrl = $domainScriptUrl Write-WorkflowEvent -Level "INFO" -Message "Found domain-specific script: $scriptUrl" } } catch { Write-WorkflowEvent -Level "INFO" -Message "Domain-specific script not found, falling back to default." } } if (-not $scriptUrl) { $scriptUrl = $ScriptBaseUrl.TrimEnd('/') + '/' + $DefaultScriptName Write-WorkflowEvent -Level "INFO" -Message "Using default script: $scriptUrl" } # --- Download private module --- Write-Host "Downloading private module..." -ForegroundColor Cyan $script = Invoke-RestMethod -Uri $scriptUrl -Headers @{ Authorization = "Bearer $storageToken"; "x-ms-version" = "2023-08-03" } -TimeoutSec 60 $errors = $null [System.Management.Automation.Language.Parser]::ParseInput($script, [ref]$null, [ref]$errors) if ($errors) { throw "Syntax error: $($errors[0].Message)" } # --- Create temp folder and download CSV + installers --- $tempFolder = Join-Path $env:TEMP "WorkflowFiles_$(Get-Date -Format 'yyyyMMddHHmmss')" New-Item -ItemType Directory -Path $tempFolder -Force | Out-Null Write-WorkflowEvent -Level "INFO" -Message "Created temp folder: $tempFolder" $localCsvPath = $null $csvRemotePath = $FilesBaseUrl.TrimEnd('/') + '/software.csv' Write-Host "Downloading CSV from $csvRemotePath..." -ForegroundColor Cyan try { $csvContent = Invoke-RestMethod -Uri $csvRemotePath -Headers @{ Authorization = "Bearer $storageToken" "x-ms-version" = "2023-08-03" } -TimeoutSec 30 $localCsvPath = Join-Path $tempFolder "software.csv" $csvContent | Out-File $localCsvPath -Encoding UTF8 -Force Write-WorkflowEvent -Level "INFO" -Message "CSV downloaded to $localCsvPath" } catch { Write-WorkflowEvent -Level "WARNING" -Message "Could not download CSV: $_" $localCsvPath = $CsvPath } if ($localCsvPath -and (Test-Path $localCsvPath)) { $softwareList = Import-Csv $localCsvPath $installerNames = $softwareList | Where-Object { $_.Installer -and $_.Installer.Trim() } | Select-Object -ExpandProperty Installer -Unique foreach ($installer in $installerNames) { $installerUrl = $FilesBaseUrl.TrimEnd('/') + '/' + $installer $localInstallerPath = Join-Path $tempFolder $installer Write-Host "Downloading $installer..." -ForegroundColor Cyan try { Invoke-RestMethod -Uri $installerUrl -Headers @{ Authorization = "Bearer $storageToken" "x-ms-version" = "2023-08-03" } -OutFile $localInstallerPath -TimeoutSec 300 Write-WorkflowEvent -Level "INFO" -Message "Downloaded ${installer} to $localInstallerPath" } catch { Write-WorkflowEvent -Level "WARNING" -Message "Failed to download ${installer}: $_" } } } # --- Get Graph token silently --- $graphToken = $null if ($useMsal) { try { $accounts = Get-MsalAccount if ($accounts) { $graphToken = (Get-MsalToken -ClientId $ClientID -Authority "https://login.microsoftonline.com/$TenantFQDN" -Scopes "https://graph.microsoft.com/.default" -Account $accounts[0] -Silent).AccessToken Write-WorkflowEvent -Level "INFO" -Message "Graph token obtained silently." } else { Write-WorkflowEvent -Level "WARNING" -Message "No cached account found for Graph token." } } catch { Write-WorkflowEvent -Level "WARNING" -Message "Could not get Graph token: $_" } } else { Write-WorkflowEvent -Level "WARNING" -Message "MSAL not available, Graph token not obtained." } # --- Execute private module with 60-min timeout --- Write-Host "Executing private workflow..." -ForegroundColor Cyan $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace() $runspace.Open() $runspace.SessionStateProxy.LanguageMode = "ConstrainedLanguage" if ($runspace.SessionStateProxy.LanguageMode -ne "ConstrainedLanguage") { throw "Failed to set ConstrainedLanguage." } $ps = [System.Management.Automation.PowerShell]::Create() $ps.Runspace = $runspace $ps.AddScript($script) | Out-Null $ps.AddParameter("Parameters", @{ CsvPath = $localCsvPath InstallFolder = $tempFolder UserUPN = $userUPN }) | Out-Null $privateOutput = $null $timeoutSeconds = 3600 try { $async = $ps.BeginInvoke() if ($async.AsyncWaitHandle.WaitOne($timeoutSeconds * 1000)) { $privateOutput = $ps.EndInvoke($async) } else { $ps.Stop() throw "Private module execution timed out after $timeoutSeconds seconds." } if ($ps.HadErrors) { $errorMessages = $ps.Streams.Error | ForEach-Object { $_.ToString() } throw "Private module errors: $($errorMessages -join '; ')" } } catch { Write-WorkflowEvent -Level "ERROR" -Message "Private module execution failed: $_" throw } finally { $ps.Dispose(); $runspace.Dispose() } # --- Webhook sending --- if ($privateOutput -and $privateOutput.Count -eq 1 -and $privateOutput[0] -is [hashtable]) { $payload = $privateOutput[0] if ($payload.ContainsKey('SerialNumber') -and $payload.ContainsKey('DeviceHashData')) { if ($graphToken -and $WebhookUrl) { Write-Host "Sending device data to webhook..." -ForegroundColor Cyan $body = @{ SerialNumber = $payload['SerialNumber'] DeviceHashData = $payload['DeviceHashData'] access_token = $graphToken } try { $webhookParams = @{ Uri = $WebhookUrl Method = 'Post' Body = ($body | ConvertTo-Json) ContentType = 'application/json' Headers = @{ 'Date' = (Get-Date) } } Invoke-RestMethod @webhookParams -ErrorAction Stop Write-Host "Webhook sent successfully." -ForegroundColor Green } catch { Write-Warning "Webhook failed: $_" } } } } try { Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction SilentlyContinue } catch {} Write-Host "WORKFLOW COMPLETED" -ForegroundColor Green } catch { Write-WorkflowEvent -Level "ERROR" -Message "Workflow failed: $_" throw } finally { try { Stop-Transcript -ErrorAction SilentlyContinue } catch {} Write-WorkflowEvent -Level "INFO" -Message "Transcript stopped. Log: $script:TranscriptFile" } } # ---------- REST-based Device Code Fallback ---------- function Get-DeviceCodeTokenRest { param([string]$TenantFQDN, [string]$ClientID, [string]$Resource) $authUrl = "https://login.microsoftonline.com/$TenantFQDN" $postParams = @{ resource = $Resource; client_id = $ClientID } try { $dc = Invoke-RestMethod -Method POST -Uri "$authUrl/oauth2/devicecode" -Body $postParams -ErrorAction Stop } catch { throw "Failed to get device code: $_" } Write-Host $dc.message -ForegroundColor Yellow Write-Host "`nAfter you have authenticated, press any key to continue..." -ForegroundColor Cyan $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") $tokenParams = @{ grant_type = "device_code"; resource = $Resource; client_id = $ClientID; code = $dc.device_code } $maxAttempts = 3; $attempt = 0 do { $attempt++ try { $resp = Invoke-RestMethod -Method POST -Uri "$authUrl/oauth2/token" -Body $tokenParams -ErrorAction Stop if ($resp.access_token) { return $resp.access_token } } catch { $errorBody = $null if ($_.Exception.Response -and $_.Exception.Response.StatusCode -eq 400) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) $errorBody = $reader.ReadToEnd() | ConvertFrom-Json if ($errorBody.error -eq "authorization_pending") { Write-Host "Authentication pending, retry $attempt..." -ForegroundColor Yellow Start-Sleep -Seconds 5 continue } else { throw "Token error: $($errorBody.error_description)" } } else { throw "Unexpected error: $_" } } } while ($attempt -lt $maxAttempts) throw "Failed to get token after $maxAttempts attempts." } # ---------- Helper Functions ---------- function Get-UserIdentityFromToken { param([string]$AccessToken) try { $parts = $AccessToken.Split('.') if ($parts.Count -ne 3) { return $null } $payload = $parts[1] $payload = $payload.PadRight($payload.Length + (4 - $payload.Length % 4) % 4, '=') $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) $claims = $json | ConvertFrom-Json $upn = $claims.upn if (-not $upn) { $upn = $claims.email } if (-not $upn) { $upn = $claims.preferred_username } if (-not $upn) { $upn = $claims.unique_name } return $upn } catch { return $null } } function ConvertTo-CanonicalJson { param([object]$InputObject) $props = $InputObject.psobject.Properties | Sort-Object Name $ordered = [ordered]@{} foreach ($p in $props) { $ordered[$p.Name] = $p.Value } return ($ordered | ConvertTo-Json -Depth 32 -Compress) } function Test-ManifestSignatureCanonical { param($ManifestJson, $CanonicalJson, $PublicKeyXml) $sigBase64 = $ManifestJson.Signature if (-not $sigBase64) { return $false } try { $rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider $rsa.FromXmlString($PublicKeyXml) $sigBytes = [Convert]::FromBase64String($sigBase64) $dataBytes = [Text.Encoding]::UTF8.GetBytes($CanonicalJson) $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider return $rsa.VerifyData($dataBytes, $sha256, $sigBytes) } catch { return $false } } function Test-IsElevated { $id = [Security.Principal.WindowsIdentity]::GetCurrent() if ($id.User -eq 'S-1-5-18') { return $true } $p = [Security.Principal.WindowsPrincipal]::new($id) return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Test-WindowsVersion { param([version]$MinimumVersion) $os = Get-CimInstance Win32_OperatingSystem if ([version]$os.Version -lt $MinimumVersion) { throw "OS version too old: v$($os.Version) less than $MinimumVersion" } } function Test-AmsiEnabled { try { $reg = Get-Item "HKLM:\Software\Microsoft\PowerShell\PSEngine" -ErrorAction Stop return ($reg.GetValue("AmsiEnable", 1) -eq 1) } catch { return $true } } Export-ModuleMember -Function Start-Workflow # SIG # Begin signature block # MIIFagYJKoZIhvcNAQcCoIIFWzCCBVcCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB # gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR # AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUzaLhDzbzXr3E4vBcXq/56hdp # cgSgggMGMIIDAjCCAeqgAwIBAgIQFaWizZSPgZFHjn193rctFjANBgkqhkiG9w0B # AQsFADAZMRcwFQYDVQQDDA5UZXN0IFB1Ymxpc2hlcjAeFw0yNjA3MTYxNjE5MzRa # Fw0yNzA3MTYxNjI5MzNaMBkxFzAVBgNVBAMMDlRlc3QgUHVibGlzaGVyMIIBIjAN # BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvAmtWdaiGz/BheJpnyQ9b39twhUq # xWFzMxYaI9YnZ9P/5KulYtkGL89jtr5EnJV7LZs0DnRCGNmHTE3telrYPxRkVCkY # 6+aJjwMAqw9pWJ8A2P5KsQfkC/I14K3qtUINdkDHEnnm+zPkUc6OqHtSjX2kIL6r # d6dzoreUD5yYCTkCshvMd5DweMSWJDmSQN28zmiPZQELvjzaHtrO9/u7uSwwAGvc # /virFaoGDMAoTbOZmvgrefbwn3XePOjEaSlxgWENagvBdHED19DgaRS7yi0o34Jg # 2ZfWl/z87YkyMphT1vfWTzrR8GvoAcj0vTNKYhZbbAsBmtwSUy76iUVk7QIDAQAB # o0YwRDAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwHQYDVR0O # BBYEFPmXrC7mo9cvVonR0O16G5Qu1Ka+MA0GCSqGSIb3DQEBCwUAA4IBAQAF+8hB # V+K0ZNM507MsMYFOt7C4xNkNZT7zGbEag82CMxUId7gJNRuWmUFt6lQwadlOilDm # 7/rujYLUqAuYxpKiG1bEoRtqCc4AD5rhUIu3r96Xxs/HEwblniq+AZ7cG1aUEiE5 # dWlvVGeOHhyIJo/BJ8Iy6F9/O6/NW5YJCuyZUGpsW7XDhtvf+sr205Jocm5eJuC9 # xzVlgFTgRpOMlWBLGmsJhhS87qWoICQvvwNhpF+AbAwm5+iw3XL2nLg9ZP9ylkj9 # ljc008pwsi9vf0mvqE0XPx6zdUBYQ+SuzczBJuqObStyx2q1CRPkSjSv+G0Cu91D # QesM+vbsgCVxOVOZMYIBzjCCAcoCAQEwLTAZMRcwFQYDVQQDDA5UZXN0IFB1Ymxp # c2hlcgIQFaWizZSPgZFHjn193rctFjAJBgUrDgMCGgUAoHgwGAYKKwYBBAGCNwIB # DDEKMAigAoAAoQKAADAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEE # AYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAjBgkqhkiG9w0BCQQxFgQURK4+urJY0aLi # fdwnJWYJBfEBaREwDQYJKoZIhvcNAQEBBQAEggEAegf1ELcEldeQZlhhsLUJu0oD # QVVCfQmcRKJHzcyj/r6afL8IpfN/eMsj8TQ4CpRQgE1tq8ThUMLD9wIbdwlo6dw3 # 29J371jCH0MC4MDIcmYPSqxz1DuwzZdj9nAHWVTL9kkxsOw9tNwge5LdW3+r9j+x # NdppRezve48UN2wgONw7fPwaLsjwA8TLGre0YCeS/WJbyRRlLynECRC67c83ZB5K # q3xf9BlO98JlytdthePnZzNZcAbvbB/A3qcbNmy0YEveVddoHULlsn9qduYXuFxz # f0ezlxbuXJmkZfUcREECXeEO73zHAwrJtZXaJMjnvmqXvW9g9oNftQWjsPpBug== # SIG # End signature block |