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