PublicBootstrap.psm1

<#
.SYNOPSIS
Secure bootstrap – downloads and executes private scripts/workflows.
Bootstraps PS7 from Azure Blob if running under PS5.1.
Uses AzCopy with PSCRED as per MS official guidance.
Graph token fetched on‑demand with multiple fallbacks.
Manifest uses "ScriptHashes" with full filenames (including .ps1).
Version: 10.103 (Graph scope on login, multiple fallbacks)
#>


Set-StrictMode -Version Latest

$ManifestPublicKeyXml = @"
<RSAKeyValue><Modulus>6JcAAw1jwc5n8O6ADX+QEgPZo05r0KFpPoXIWMn62JP6IacmJr8XFXawU2q8mBI0GYGPQzXEDPT2dUYbCEAvGnCaEFefNGmhlQmA7dwSWE8zuO2sMFnd90SkE6y7c0/VeBUUaw7B/A//Hm2lQY4vke3Y4ZVupXS8itMf6ILP/ay4MkvhB/+LBMISn/vkCLGdsaDaVCpWBWvV4PO48wso3072V1Gh8m60x7eUhSDLfQu25m9vBnnZ2W3xr8N1I9f7gcwb54XP97oXmlw98A/B2N/648hHs/Daim6VJ4YXaSwPzsEyybdqljC325Eb90omJFof6ZmmW1kk923Ph47mLQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>
"@


$script:ModulePath = $MyInvocation.MyCommand.Path
if (-not $script:ModulePath) { $script:ModulePath = $PSCommandPath }
$script:LogDir = "C:\Windows\Temp\WorkflowLogs"
$script:EventLogFile = Join-Path $script:LogDir "WorkflowEvents.log"
$script:TranscriptFile = Join-Path $script:LogDir "WorkflowTranscript.log"
$script:BootstrapErrorLog = Join-Path $script:LogDir "bootstrap_error.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" })
    try {
        if (-not (Test-Path $script:LogDir)) {
            New-Item -ItemType Directory -Path $script:LogDir -Force -ErrorAction Stop | Out-Null
        }
        Add-Content -Path $script:EventLogFile -Value $entry -Force -ErrorAction Stop
    } catch { }
}

# ---------- Helper functions ----------
function Test-IsElevated {
    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
    if ($id.User.Value -eq 'S-1-5-18') { return $true }
    $p = New-Object Security.Principal.WindowsPrincipal($id)
    return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Install-AzCopy {
    $tempDir = Join-Path $env:TEMP "AzCopyInstall"
    New-Item -ItemType Directory -Path $tempDir -Force -ErrorAction SilentlyContinue | Out-Null
    $zipPath = Join-Path $tempDir "azcopy.zip"
    Write-Host "Downloading AzCopy..." -ForegroundColor Yellow
    try {
        Invoke-WebRequest -Uri "https://aka.ms/downloadazcopy-v10-windows" -OutFile $zipPath -UseBasicParsing -ErrorAction Stop
        Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force
        $exe = Get-ChildItem -Path $tempDir -Recurse -File -Filter "azcopy.exe" -ErrorAction Stop | Select-Object -First 1
        $targetDir = "$env:ProgramFiles\AzCopy"
        New-Item -ItemType Directory -Path $targetDir -Force -ErrorAction SilentlyContinue | Out-Null
        Copy-Item -Path $exe.FullName -Destination "$targetDir\azcopy.exe" -Force
        return "$targetDir\azcopy.exe"
    } catch {
        Write-Warning "AzCopy install failed: $_"
        return $null
    }
}

function Invoke-AzCopyWithPSCRED {
    param($SourceUrl, $DestinationPath, $TenantId)

    $azCopyPath = $null
    $cmd = Get-Command azcopy -ErrorAction SilentlyContinue
    if ($cmd) { $azCopyPath = $cmd.Source }
    if (-not $azCopyPath -or -not (Test-Path $azCopyPath)) {
        $azCopyPath = (Get-ChildItem "C:\Program Files\AzCopy\azcopy.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).FullName
    }
    if (-not $azCopyPath -or -not (Test-Path $azCopyPath)) {
        $azCopyPath = (Get-ChildItem "C:\azcopy\azcopy.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).FullName
    }
    if (-not $azCopyPath -or -not (Test-Path $azCopyPath)) {
        $azCopyPath = Install-AzCopy
        if (-not $azCopyPath) { return $false }
    }
    $version = & $azCopyPath --version 2>$null
    if ($LASTEXITCODE -ne 0 -or $version -notmatch '10\.') { return $false }

    Remove-Item Env:AZCOPY_AUTO_LOGIN_TYPE -ErrorAction SilentlyContinue
    Remove-Item Env:AZCOPY_TENANT_ID -ErrorAction SilentlyContinue

    Write-WorkflowEvent -Level "INFO" -Message "AzCopy downloading from URL: $SourceUrl"
    Write-WorkflowEvent -Level "INFO" -Message "AzCopy destination: $DestinationPath"

    $env:AZCOPY_AUTO_LOGIN_TYPE = "PSCRED"
    if ($TenantId) { $env:AZCOPY_TENANT_ID = $TenantId }
    Write-WorkflowEvent -Level "INFO" -Message "AzCopy using PSCRED with tenant $TenantId"

    $output = & $azCopyPath copy $SourceUrl $DestinationPath 2>&1
    $exit = $LASTEXITCODE

    Remove-Item Env:AZCOPY_AUTO_LOGIN_TYPE -ErrorAction SilentlyContinue
    Remove-Item Env:AZCOPY_TENANT_ID -ErrorAction SilentlyContinue

    if ($exit -ne 0) {
        Write-WorkflowEvent -Level "ERROR" -Message "AzCopy failed (exit $exit): $($output -join '; ')"
        return $false
    }
    return $true
}

function Find-PS7ZipBlob {
    param($StorageAccount, $StorageToken, $ContainerName = "ps7")
    $url = "https://${StorageAccount}.blob.core.windows.net/${ContainerName}?restype=container&comp=list"
    try {
        $headers = @{ "Authorization" = "Bearer $StorageToken"; "x-ms-version" = "2023-08-03" }
        $resp = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -ErrorAction Stop
        $content = $resp.Content
        $matches = [regex]::Matches($content, '<Name>(.*?)</Name>')
        foreach ($m in $matches) {
            $name = $m.Groups[1].Value
            if ($name -match '\.zip$') {
                return "https://${StorageAccount}.blob.core.windows.net/${ContainerName}/${name}"
            }
        }
    } catch { Write-WorkflowEvent -Level "ERROR" -Message "Find PS7 zip failed: $_" }
    return $null
}

function Ensure-PowerShell7 {
    param($TenantFQDN, $StorageAccount, $AuthMethod, $Ps7Container = "ps7")
    if ($PSVersionTable.PSVersion.Major -ge 7) { return $true }
    Write-WorkflowEvent -Level "INFO" -Message "PS7 not detected. Installing from Azure Blob..."
    $installDir = Join-Path $env:LOCALAPPDATA "Programs\PowerShell\7"
    $pwshExe = Join-Path $installDir "pwsh.exe"
    if (Test-Path $pwshExe) {
        $env:Path = "$installDir;$env:Path"
        return $true
    }
    Write-WorkflowEvent -Level "INFO" -Message "Authenticating to get storage token..."
    $authSuccess = Initialize-AzAuthentication -TenantFQDN $TenantFQDN -AuthMethod $AuthMethod
    if (-not $authSuccess) { return $false }
    try {
        $obj = Get-AzAccessToken -ResourceUrl "https://storage.azure.com/" -ErrorAction Stop
        $raw = $obj.Token
        if ($raw -is [Security.SecureString]) {
            $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($raw)
            try { $token = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
        } else { $token = [string]$raw }
        $token = $token.Trim()
    } catch { Write-WorkflowEvent -Level "ERROR" -Message "Storage token failed: $_"; return $false }
    $zipUrl = Find-PS7ZipBlob -StorageAccount $StorageAccount -StorageToken $token -ContainerName $Ps7Container
    if (-not $zipUrl) { Write-WorkflowEvent -Level "ERROR" -Message "No PS7 zip found."; return $false }
    $tempZip = Join-Path $env:TEMP "pwsh7_$(Get-Random).zip"
    Write-WorkflowEvent -Level "INFO" -Message "Downloading PS7 from $zipUrl..."
    try {
        $headers = @{ "Authorization" = "Bearer $token"; "x-ms-version" = "2023-08-03" }
        Invoke-WebRequest -Uri $zipUrl -Headers $headers -OutFile $tempZip -UseBasicParsing -ErrorAction Stop
    } catch { Write-WorkflowEvent -Level "ERROR" -Message "Download PS7 failed: $_"; return $false }
    if (-not (Test-Path $tempZip) -or (Get-Item $tempZip).Length -eq 0) { return $false }
    Write-WorkflowEvent -Level "INFO" -Message "Extracting PS7 to $installDir..."
    try {
        if (Test-Path $installDir) { Remove-Item -Path $installDir -Recurse -Force -ErrorAction Stop }
        Expand-Archive -Path $tempZip -DestinationPath $installDir -Force -ErrorAction Stop
        if (-not (Test-Path $pwshExe)) { return $false }
        $env:Path = "$installDir;$env:Path"
        Write-WorkflowEvent -Level "INFO" -Message "PS7 installed."
    } catch { Write-WorkflowEvent -Level "ERROR" -Message "Extract PS7 failed: $_"; return $false }
    finally { Remove-Item $tempZip -Force -ErrorAction SilentlyContinue }
    return $true
}

function Invoke-WithPowerShell7 {
    param($ScriptPath, [hashtable]$ParamHash)
    $pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
    if (-not $pwshExe) { $pwshExe = Join-Path $env:LOCALAPPDATA "Programs\PowerShell\7\pwsh.exe" }
    if (-not (Test-Path $pwshExe)) { throw "pwsh.exe not found." }
    $tempScript = Join-Path $env:TEMP "WorkflowBootstrap.ps1"
    $logDir = $script:LogDir
    $eventLogFile = $script:EventLogFile
    $transcriptFile = $script:TranscriptFile
    $bootstrapErrorLog = $script:BootstrapErrorLog
    $cleanParams = @{}
    foreach ($k in $ParamHash.Keys) {
        $v = $ParamHash[$k]
        if ($v -is [System.Management.Automation.SwitchParameter]) { $v = [bool]$v }
        $cleanParams[$k] = $v
    }
    $paramsJson = $cleanParams | ConvertTo-Json -Compress -Depth 10
    $paramsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($paramsJson))

    $scriptContent = @'
$ErrorActionPreference = 'Stop'
$logDir = '__LOGDIR__'
$eventLogFile = '__EVENTLOGFILE__'
$transcriptFile = '__TRANSCRIPTFILE__'
$bootstrapErrorLog = '__BOOTSTRAPERRORLOG__'
$paramsBase64 = '__PARAMS_BASE64__'
$ScriptPath = '__SCRIPTPATH__'
 
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
try { Start-Transcript -Path $transcriptFile -Append -Force -ErrorAction SilentlyContinue } catch { Add-Content -Path $bootstrapErrorLog -Value "[$(Get-Date)] Transcript start failed: $_" -Force }
try {
    Import-Module $ScriptPath -Force -ErrorAction Stop
    $params = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($paramsBase64)) | ConvertFrom-Json -AsHashtable
    if ($params.ContainsKey('KeepTempFiles') -and $params['KeepTempFiles'] -isnot [bool]) { $params['KeepTempFiles'] = [bool]$params['KeepTempFiles'] }
    Start-Workflow @params
} catch {
    $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ERROR: $_`n$($_.ScriptStackTrace)"
    Add-Content -Path $eventLogFile -Value $entry -Force
    Add-Content -Path $bootstrapErrorLog -Value $entry -Force
} finally {
    try { Stop-Transcript -ErrorAction SilentlyContinue } catch {}
}
'@

    $scriptContent = $scriptContent -replace '__LOGDIR__', $logDir `
                                   -replace '__EVENTLOGFILE__', $eventLogFile `
                                   -replace '__TRANSCRIPTFILE__', $transcriptFile `
                                   -replace '__BOOTSTRAPERRORLOG__', $bootstrapErrorLog `
                                   -replace '__PARAMS_BASE64__', $paramsBase64 `
                                   -replace '__SCRIPTPATH__', $ScriptPath

    $scriptContent | Out-File $tempScript -Encoding UTF8 -Force
    Write-WorkflowEvent -Level "INFO" -Message "Relaunching to PS7..."
    $process = Start-Process -FilePath $pwshExe `
        -ArgumentList @('-ExecutionPolicy', 'Bypass', '-File', $tempScript) `
        -WindowStyle Hidden `
        -Wait `
        -PassThru
    if ($process.ExitCode -ne 0) {
        $err = if (Test-Path $script:BootstrapErrorLog) { Get-Content $script:BootstrapErrorLog -Raw } else { "No error log." }
        throw "Relaunch failed (exit $($process.ExitCode)): $err"
    }
    Remove-Item $tempScript -Force -ErrorAction SilentlyContinue
}

function Invoke-PowerShell7Bootstrap {
    param($ParamHash)
    Write-WorkflowEvent -Level "INFO" -Message "Bootstrap to PS7..."
    $Tenant = if ($ParamHash.ContainsKey('Tenant')) { $ParamHash['Tenant'] } else { $null }
    $StorageAccount = if ($ParamHash.ContainsKey('StorageAccount')) { $ParamHash['StorageAccount'] } else { $null }
    $AuthMethod = if ($ParamHash.ContainsKey('AuthMethod')) { $ParamHash['AuthMethod'] } else { "Interactive" }
    $Ps7Container = if ($ParamHash.ContainsKey('Ps7Container')) { $ParamHash['Ps7Container'] } else { "ps7" }
    $TenantFQDN = if ($Tenant -and $Tenant -match '\.onmicrosoft\.com$') { $Tenant } else { if ($Tenant) { "$Tenant.onmicrosoft.com" } else { $null } }
    if (-not (Ensure-PowerShell7 -TenantFQDN $TenantFQDN -StorageAccount $StorageAccount -AuthMethod $AuthMethod -Ps7Container $Ps7Container)) {
        throw "Failed to install PowerShell 7."
    }
    $scriptPath = $script:ModulePath
    if (-not $scriptPath -or -not (Test-Path $scriptPath)) { throw "Module path invalid." }
    Invoke-WithPowerShell7 -ScriptPath $scriptPath -ParamHash $ParamHash
}

function Initialize-AzAuthentication {
    param($TenantFQDN = $null, $AuthMethod = "Interactive")
    try {
        if (-not (Get-Module -ListAvailable -Name Az.Accounts)) {
            Write-Host "Installing Az.Accounts..."
            Install-Module -Name Az.Accounts -Scope CurrentUser -Force -ErrorAction Stop
        }
        Import-Module Az.Accounts -Force -ErrorAction Stop

        Write-WorkflowEvent -Level "INFO" -Message "Disconnecting existing Azure session (if any)..."
        Disconnect-AzAccount -ErrorAction SilentlyContinue

        Write-WorkflowEvent -Level "INFO" -Message "Starting new Azure authentication ($AuthMethod)..."
        $connectParams = @{ ErrorAction = 'Stop' }
        if ($TenantFQDN) { $connectParams.Tenant = $TenantFQDN }
        if ($AuthMethod -eq "DeviceCode") { $connectParams.UseDeviceAuthentication = $true }

        # Try to connect with Graph scope (recommended)
        try {
            Write-WorkflowEvent -Level "INFO" -Message "Attempting Connect-AzAccount with -AuthScope 'https://graph.microsoft.com/.default'..."
            $connectParams.AuthScope = "https://graph.microsoft.com/.default"
            Connect-AzAccount @connectParams
        } catch {
            Write-WorkflowEvent -Level "WARNING" -Message "AuthScope login failed: $_"
            # Fallback: try -Resource (old style)
            try {
                Write-WorkflowEvent -Level "INFO" -Message "Falling back to Connect-AzAccount -Resource 'https://graph.microsoft.com/'..."
                $connectParams.Remove('AuthScope')
                $connectParams.Resource = "https://graph.microsoft.com/"
                Connect-AzAccount @connectParams
            } catch {
                Write-WorkflowEvent -Level "ERROR" -Message "Resource login failed: $_"
                # Final fallback: connect without resource/scope (storage-only)
                Write-WorkflowEvent -Level "INFO" -Message "Falling back to Connect-AzAccount without scope (storage only)."
                $connectParams.Remove('AuthScope')
                $connectParams.Remove('Resource')
                Connect-AzAccount @connectParams
            }
        }
        Write-WorkflowEvent -Level "INFO" -Message "Azure authentication succeeded."
        return $true
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Az auth failed: $_"
        return $false
    }
}

function Download-ManifestRaw {
    param($ManifestUrl, $TenantId)
    Write-WorkflowEvent -Level "INFO" -Message "Downloading manifest from URL: $ManifestUrl"
    $tmp = Join-Path $env:TEMP "manifest_$(Get-Random).json"
    if (Invoke-AzCopyWithPSCRED -SourceUrl $ManifestUrl -DestinationPath $tmp -TenantId $TenantId) {
        $raw = Get-Content -Path $tmp -Raw -Encoding UTF8 -ErrorAction Stop
        Remove-Item $tmp -Force -ErrorAction SilentlyContinue
        return $raw
    }
    return $null
}

function Download-PrivateScript {
    param($Url, $StorageToken, $DestinationPath)
    Write-WorkflowEvent -Level "INFO" -Message "Downloading script from URL: $Url"
    try {
        Invoke-WebRequest -Uri $Url -Headers @{ Authorization = "Bearer $StorageToken"; "x-ms-version" = "2023-08-03" } -OutFile $DestinationPath -UseBasicParsing -ErrorAction Stop
        Write-WorkflowEvent -Level "INFO" -Message "Downloaded $Url ($((Get-Item $DestinationPath).Length) bytes)"
        return $true
    } catch {
        Write-WorkflowEvent -Level "WARNING" -Message "Script download failed: $_"
        return $false
    }
}

function Copy-ObjectDeep {
    param($InputObject)
    $json = $InputObject | ConvertTo-Json -Depth 32 -Compress -ErrorAction Stop
    return $json | ConvertFrom-Json -ErrorAction Stop
}

function Convert-CanonicalObject {
    param($obj)
    if ($obj -is [PSCustomObject]) {
        $ht = @{}
        foreach ($p in $obj.PSObject.Properties) {
            $ht[$p.Name] = Convert-CanonicalObject -obj $p.Value
        }
        $obj = $ht
    }
    if ($obj -is [System.Collections.IDictionary]) {
        $sorted = [ordered]@{}
        foreach ($k in ($obj.Keys | Sort-Object)) {
            $sorted[$k] = Convert-CanonicalObject -obj $obj[$k]
        }
        return $sorted
    } elseif ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) {
        $list = @()
        foreach ($item in $obj) { $list += ,(Convert-CanonicalObject -obj $item) }
        return $list
    } else {
        return $obj
    }
}

function Get-CanonicalJson {
    param($ManifestObject)
    try {
        $clone = Copy-ObjectDeep -InputObject $ManifestObject
        if ($clone -is [PSCustomObject]) {
            $clone.PSObject.Properties.Remove('Signature')
        } elseif ($clone -is [IDictionary]) {
            $clone.Remove('Signature')
        }
        $canon = Convert-CanonicalObject -obj $clone
        return ($canon | ConvertTo-Json -Depth 32 -Compress -ErrorAction Stop)
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Canonical JSON failed: $_"
        return $null
    }
}

function Test-ManifestSignature {
    param($ManifestJson, $CanonicalJson, $PublicKeyXml)
    $sig = $ManifestJson.Signature
    if (-not $sig) { return $false }
    try {
        $rsa = [System.Security.Cryptography.RSA]::Create()
        $rsa.FromXmlString($PublicKeyXml)
        $sigBytes = [Convert]::FromBase64String($sig)
        $data = [Text.Encoding]::UTF8.GetBytes($CanonicalJson)
        return $rsa.VerifyData($data, $sigBytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1)
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Signature verification exception: $_"
        return $false
    }
}

function Get-UserIdentityFromToken {
    param($AccessToken)
    if (-not $AccessToken) { return $null }
    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 = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload))
        $claims = $json | ConvertFrom-Json -ErrorAction Stop
        $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 Get-GraphToken {
    param($TenantId)
    try {
        Write-WorkflowEvent -Level "INFO" -Message "Acquiring Graph token for tenant $TenantId..."

        # Try AuthScope (newer)
        try {
            $obj = Get-AzAccessToken -AuthScope "https://graph.microsoft.com/.default" -TenantId $TenantId -ErrorAction Stop
            $raw = $obj.Token
        } catch {
            Write-WorkflowEvent -Level "WARNING" -Message "AuthScope method failed: $_"
            # Fallback: ResourceUrl
            try {
                Write-WorkflowEvent -Level "INFO" -Message "Falling back to ResourceUrl method..."
                $obj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/" -TenantId $TenantId -ErrorAction Stop
                $raw = $obj.Token
            } catch {
                Write-WorkflowEvent -Level "WARNING" -Message "ResourceUrl method failed: $_"
                # Final fallback: Resource (older)
                Write-WorkflowEvent -Level "INFO" -Message "Falling back to Resource method (old)..."
                $obj = Get-AzAccessToken -Resource "https://graph.microsoft.com/" -TenantId $TenantId -ErrorAction Stop
                $raw = $obj.Token
            }
        }

        if ($raw -is [Security.SecureString]) {
            $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($raw)
            try { $token = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
        } else { $token = [string]$raw }
        $token = $token.Trim()
        Write-WorkflowEvent -Level "INFO" -Message "Graph token acquired."
        return $token
    } catch {
        Write-WorkflowEvent -Level "WARNING" -Message "Graph token acquisition failed: $_"
        return $null
    }
}

function Invoke-PrivateScript {
    param($ScriptContent, $CsvPath, $InstallFolder, $UserUPN, $TimeoutSeconds)
    $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2()
    $iss.LanguageMode = [System.Management.Automation.PSLanguageMode]::FullLanguage
    $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($iss)
    $runspace.ApartmentState = [System.Threading.ApartmentState]::STA
    $runspace.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread
    $runspace.Open()
    $ps = [System.Management.Automation.PowerShell]::Create()
    $ps.Runspace = $runspace

    $scriptBytes = [Text.Encoding]::UTF8.GetBytes($ScriptContent)
    $scriptBase64 = [Convert]::ToBase64String($scriptBytes)

    $csvBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CsvPath))
    $installBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($InstallFolder))
    $upnBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($UserUPN))

    $wrapped = @"
`$scriptBytes = [Convert]::FromBase64String('$scriptBase64')
`$scriptContent = [Text.Encoding]::UTF8.GetString(`$scriptBytes)
`$scriptBlock = [ScriptBlock]::Create(`$scriptContent)
 
`$CsvPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$csvBase64'))
`$InstallFolder = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$installBase64'))
`$UserUPN = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$upnBase64'))
 
& `$scriptBlock -CsvPath `$CsvPath -InstallFolder `$InstallFolder -UserUPN `$UserUPN
"@


    $null = $ps.AddScript($wrapped)

    try {
        $async = $ps.BeginInvoke()
        if (-not $async.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) {
            $ps.Stop()
            Write-WorkflowEvent -Level "ERROR" -Message "Script timed out."
            return $null
        }
        $output = $ps.EndInvoke($async)
        if ($ps.HadErrors) {
            $errors = $ps.Streams.Error | ForEach-Object { $_.ToString() }
            Write-WorkflowEvent -Level "ERROR" -Message "Script errors: $($errors -join '; ')"
            return $null
        }
        if ($output -isnot [System.Collections.IEnumerable] -or $output -is [string]) {
            $output = @($output)
        }
        return $output
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Exception in script: $_"
        return $null
    } finally {
        $ps.Dispose()
        $runspace.Dispose()
    }
}

function Test-WindowsVersion {
    param($MinimumVersion)
    try {
        $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
        $v = $os.Version
        Write-WorkflowEvent -Level "INFO" -Message "OS version: $v"
        return ([version]::Parse($v) -ge $MinimumVersion)
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "OS version check failed: $_"
        return $false
    }
}

function Get-ValidatedManifest {
    param($ManifestUrl, $PublicKeyXml, $TenantId)
    Write-Host "Downloading manifest..." -ForegroundColor Cyan
    $raw = Download-ManifestRaw -ManifestUrl $ManifestUrl -TenantId $TenantId
    if (-not $raw) { Write-WorkflowEvent -Level "ERROR" -Message "Manifest download failed."; return $null }
    Write-WorkflowEvent -Level "INFO" -Message "Manifest length: $($raw.Length)"
    try {
        $manifest = $raw | ConvertFrom-Json -ErrorAction Stop
        $canonical = Get-CanonicalJson -ManifestObject $manifest
        if (-not $canonical) { return $null }
        if (-not (Test-ManifestSignature -ManifestJson $manifest -CanonicalJson $canonical -PublicKeyXml $PublicKeyXml)) {
            Write-WorkflowEvent -Level "ERROR" -Message "Invalid signature."
            return $null
        }
        Write-Host "Signature valid." -ForegroundColor Green
        return $manifest
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Manifest parse error: $_"
        return $null
    }
}

function Download-Files {
    param($FilesBaseUrl, $TempFolder, $FallbackCsvPath, $TenantId)
    $localCsv = $null
    $csvUrl = $FilesBaseUrl.TrimEnd('/') + '/software.csv'
    $csvLocal = Join-Path $TempFolder "software.csv"
    Write-Host "Downloading CSV..." -ForegroundColor Cyan
    Write-WorkflowEvent -Level "INFO" -Message "CSV download URL: $csvUrl"

    if (Invoke-AzCopyWithPSCRED -SourceUrl $csvUrl -DestinationPath $csvLocal -TenantId $TenantId) {
        $localCsv = $csvLocal
        Write-WorkflowEvent -Level "INFO" -Message "CSV downloaded successfully."
    } else {
        Write-WorkflowEvent -Level "WARNING" -Message "CSV download failed – using fallback."
        if (Test-Path $FallbackCsvPath) {
            $localCsv = $FallbackCsvPath
            Write-WorkflowEvent -Level "INFO" -Message "Using fallback CSV: $FallbackCsvPath"
        } else {
            Write-WorkflowEvent -Level "WARNING" -Message "Fallback CSV path does not exist – no CSV available."
            $localCsv = $null
        }
    }

    if ($localCsv -and (Test-Path $localCsv)) {
        try {
            $list = Import-Csv -Path $localCsv -ErrorAction Stop
            $zips = $list | Where-Object { $_.ZipFile -and $_.ZipFile.Trim() } | ForEach-Object { $_.ZipFile.Trim() } | Select-Object -Unique
            Write-WorkflowEvent -Level "INFO" -Message "Found $($zips.Count) zip(s)."
            $ok = 0
            foreach ($f in $zips) {
                $url = $FilesBaseUrl.TrimEnd('/') + '/' + $f
                $dest = Join-Path $TempFolder $f
                Write-Host "Downloading $f..." -ForegroundColor Cyan
                Write-WorkflowEvent -Level "INFO" -Message "Downloading zip from URL: $url"
                if (Invoke-AzCopyWithPSCRED -SourceUrl $url -DestinationPath $dest -TenantId $TenantId) {
                    $ok++
                } else {
                    Write-WorkflowEvent -Level "ERROR" -Message "Failed to download: $f"
                }
            }
            Write-WorkflowEvent -Level "INFO" -Message "Zip downloads: $ok succeeded, $($zips.Count - $ok) failed."
        } catch {
            Write-WorkflowEvent -Level "ERROR" -Message "CSV parsing failed: $_"
        }
    } else {
        Write-WorkflowEvent -Level "WARNING" -Message "No CSV available – skipping file downloads."
    }
    return $localCsv
}

function Process-SingleScript {
    param($ScriptName, $ExpectedHash, $ScriptBaseUrl, $StorageToken, $CsvPath, $TempFolder, $UserUPN, $TenantId, $WebhookUrl)
    Write-Host "Processing script: $ScriptName" -ForegroundColor Cyan
    Write-WorkflowEvent -Level "INFO" -Message "Processing $ScriptName"

    if ([string]::IsNullOrWhiteSpace($CsvPath)) {
        Write-WorkflowEvent -Level "WARNING" -Message "CsvPath is empty. Skipping script $ScriptName."
        return $false
    }
    if (-not (Test-Path $CsvPath)) {
        Write-WorkflowEvent -Level "WARNING" -Message "CsvPath '$CsvPath' does not exist. Skipping script $ScriptName."
        return $false
    }

    if (-not [System.Uri]::IsWellFormedUriString($ScriptBaseUrl, [System.UriKind]::Absolute)) {
        Write-WorkflowEvent -Level "ERROR" -Message "Invalid ScriptBaseUrl: $ScriptBaseUrl"
        return $false
    }
    $base = $ScriptBaseUrl.TrimEnd('/') + '/'

    $scriptFile = Join-Path $TempFolder $ScriptName
    $scriptUrl = $base + $ScriptName

    if (-not (Download-PrivateScript -Url $scriptUrl -StorageToken $StorageToken -DestinationPath $scriptFile)) {
        Write-WorkflowEvent -Level "ERROR" -Message "Failed to download script $ScriptName."
        return $false
    }

    $actual = (Get-FileHash -Path $scriptFile -Algorithm SHA256).Hash
    Write-WorkflowEvent -Level "INFO" -Message "Expected hash: $ExpectedHash"
    Write-WorkflowEvent -Level "INFO" -Message "Actual hash: $actual"
    if ($actual -ne $ExpectedHash) {
        Write-WorkflowEvent -Level "WARNING" -Message "Hash mismatch – skipping script $ScriptName."
        return $false
    }
    Write-Host "Hash verified." -ForegroundColor Green

    $content = Get-Content -Path $scriptFile -Raw -Encoding UTF8 -ErrorAction Stop

    $errors = $null
    [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$null, [ref]$errors)
    if ($errors) {
        Write-WorkflowEvent -Level "WARNING" -Message "Syntax errors – skipping script $ScriptName."
        return $false
    }

    $output = Invoke-PrivateScript -ScriptContent $content -CsvPath $CsvPath -InstallFolder $TempFolder -UserUPN $UserUPN -TimeoutSeconds 3600

    $ht = $null
    if ($output -is [System.Collections.IEnumerable] -and $output -isnot [string]) {
        foreach ($item in $output) {
            if ($item -is [hashtable]) {
                $ht = $item
                break
            }
        }
    } elseif ($output -is [hashtable]) {
        $ht = $output
    }

    Write-Host "`n========================================" -ForegroundColor Cyan
    Write-Host " SCRIPT EXECUTION: $ScriptName" -ForegroundColor Cyan
    Write-Host "========================================`n" -ForegroundColor Cyan

    if ($ht) {
        Write-Host "✅ Script returned a hashtable." -ForegroundColor Green
        Write-WorkflowEvent -Level "INFO" -Message "Script $ScriptName returned a hashtable."

        Write-Host "`n📋 Hashtable contents:" -ForegroundColor Yellow
        $ht | Out-String | Write-Host -ForegroundColor Gray

        Write-Host "`n🔑 Keys:" -ForegroundColor Yellow
        $ht.Keys | ForEach-Object { Write-Host " $_" }

        Write-Host "`n✅ SCRIPT $ScriptName EXECUTED SUCCESSFULLY" -ForegroundColor Green
        Write-Host "========================================`n" -ForegroundColor Cyan
        Write-WorkflowEvent -Level "INFO" -Message "Script $ScriptName executed successfully."

        if ($ht.ContainsKey('SerialNumber')) {
            if ($WebhookUrl) {
                $graphToken = Get-GraphToken -TenantId $TenantId
                if ($graphToken) {
                    $token = $graphToken
                    $aud = "graph"
                } else {
                    Write-WorkflowEvent -Level "WARNING" -Message "Graph token not available – falling back to storage token for webhook."
                    $token = $StorageToken
                    $aud = "storage"
                }
                $body = $ht.Clone()
                $body['access_token'] = $token
                $body['token_type'] = "Bearer"
                $body['token_audience'] = $aud
                $body['ScriptName'] = $ScriptName
                $body['UPN'] = $UserUPN
                $body['timestamp'] = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffZ")

                Write-Host "Sending webhook..." -ForegroundColor Cyan
                try {
                    Invoke-WebRequest -Uri $WebhookUrl -Method Post -Body ($body | ConvertTo-Json) -ContentType 'application/json' -Headers @{ 'Date' = (Get-Date).ToString("R") } -UseBasicParsing -ErrorAction Stop
                    Write-Host "Webhook sent." -ForegroundColor Green
                    Write-WorkflowEvent -Level "INFO" -Message "Webhook sent for script $ScriptName."
                } catch {
                    Write-Warning "Webhook failed: $_"
                    Write-WorkflowEvent -Level "ERROR" -Message "Webhook failed: $_"
                }
            } else {
                Write-WorkflowEvent -Level "WARNING" -Message "Webhook URL missing – skipping webhook."
            }
        } else {
            Write-WorkflowEvent -Level "INFO" -Message "Script did not return SerialNumber – webhook not sent."
        }
    } else {
        Write-Host "❌ Script did NOT return a hashtable." -ForegroundColor Red
        Write-Host " Output type: $($output.GetType().FullName)" -ForegroundColor Yellow
        Write-WorkflowEvent -Level "ERROR" -Message "Script $ScriptName did not return a hashtable. Output type: $($output.GetType().FullName)"
        Write-Host "`n❌ SCRIPT $ScriptName EXECUTION FAILED" -ForegroundColor Red
        Write-Host "========================================`n" -ForegroundColor Cyan
        return $false
    }
    return $true
}

function Execute-PrivateScripts {
    param($ScriptHashes, $ScriptBaseUrl, $StorageToken, $CsvPath, $TempFolder, $UserUPN, $TenantId, $WebhookUrl)
    $arr = @()
    if ($ScriptHashes -is [PSCustomObject]) {
        foreach ($p in $ScriptHashes.PSObject.Properties) { $arr += [PSCustomObject]@{ Name = $p.Name; Hash = $p.Value } }
    } elseif ($ScriptHashes -is [IDictionary]) {
        foreach ($k in $ScriptHashes.Keys) { $arr += [PSCustomObject]@{ Name = $k; Hash = $ScriptHashes[$k] } }
    }
    if ($arr.Count -eq 0) { Write-WorkflowEvent -Level "WARNING" -Message "No scripts to execute."; return }
    $processed = 0
    foreach ($m in $arr) {
        if (Process-SingleScript -ScriptName $m.Name -ExpectedHash $m.Hash -ScriptBaseUrl $ScriptBaseUrl -StorageToken $StorageToken -CsvPath $CsvPath -TempFolder $TempFolder -UserUPN $UserUPN -TenantId $TenantId -WebhookUrl $WebhookUrl) {
            $processed++
        }
    }
    Write-Host "Processed $processed script(s)." -ForegroundColor Green
    Write-WorkflowEvent -Level "INFO" -Message "Processed $processed script(s)."
}

function Get-AzureTokens {
    param($TenantFQDN = $null, $AuthMethod)
    Write-Host "Authenticating..." -ForegroundColor Cyan
    if (-not (Initialize-AzAuthentication -TenantFQDN $TenantFQDN -AuthMethod $AuthMethod)) { return $null }
    Write-Host "Az auth OK." -ForegroundColor Green

    $storageToken = $null
    try {
        $obj = Get-AzAccessToken -ResourceUrl "https://storage.azure.com/" -AsSecureString -ErrorAction Stop
        $raw = $obj.Token
        if ($raw -is [Security.SecureString]) {
            $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($raw)
            try { $storageToken = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) }
        } else { $storageToken = [string]$raw }
        $storageToken = $storageToken.Trim()
        Write-WorkflowEvent -Level "INFO" -Message "Storage token acquired (len $($storageToken.Length))."
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Storage token failed: $_"
        return $null
    }

    return $storageToken
}

function Resolve-WorkflowParameters {
    param($WorkflowName, $Tenant, $StorageAccount, $ManifestUrl, $CsvPath)
    $TenantFQDN = if ($Tenant) { if ($Tenant -match '\.onmicrosoft\.com$') { $Tenant } else { "$Tenant.onmicrosoft.com" } } else { $null }
    if (-not $StorageAccount) { Write-Error "StorageAccount required."; return $null }
    $StorageAccount = $StorageAccount.ToLower() -replace '[^a-z0-9]', ''
    if ($StorageAccount.Length -gt 24) { $StorageAccount = $StorageAccount.Substring(0, 24) }
    $ManifestUrl = if ($ManifestUrl) { $ManifestUrl } else { "https://${StorageAccount}.blob.core.windows.net/config/${WorkflowName}.json" }
    $CsvPath = if ($CsvPath) { $CsvPath } else { "C:\Data-$($StorageAccount)\software.csv" }
    $FilesBaseUrl = "https://${StorageAccount}.blob.core.windows.net/private/files/"
    return @{ TenantFQDN = $TenantFQDN; StorageAccount = $StorageAccount; ManifestUrl = $ManifestUrl; CsvPath = $CsvPath; FilesBaseUrl = $FilesBaseUrl }
}

function Start-TranscriptAndLogging {
    try { New-Item -ItemType Directory -Path $script:LogDir -Force -ErrorAction Stop | Out-Null } catch {}
    try { Start-Transcript -Path $script:TranscriptFile -Append -Force -ErrorAction Stop } catch {}
}

function Stop-TranscriptAndLogging {
    try { Stop-Transcript -ErrorAction SilentlyContinue } catch {}
}

# ---------- Main Start-Workflow ----------
function Start-Workflow {
    [CmdletBinding()]
    param(
        [string]$WorkflowName = "default",
        [string]$Tenant,
        [string]$StorageAccount,
        [string]$ManifestUrl,
        [string]$CsvPath,
        [ValidateSet("Interactive", "DeviceCode")]
        [string]$AuthMethod = "Interactive",
        [bool]$KeepTempFiles = $false,
        [string]$DownloadRoot = $env:TEMP,
        [string]$Ps7Container = "ps7"
    )

    if ($PSVersionTable.PSVersion.Major -lt 7) {
        $params = $PSBoundParameters
        $params['KeepTempFiles'] = [bool]$KeepTempFiles
        Invoke-PowerShell7Bootstrap -ParamHash $params
        return
    }

    Write-WorkflowEvent -Level "INFO" -Message "Running PowerShell 7 (v$($PSVersionTable.PSVersion))."
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls13
    Start-TranscriptAndLogging

    try {
        Write-WorkflowEvent -Level "INFO" -Message "=== Workflow started (AuthMethod: $AuthMethod) ==="

        $params = Resolve-WorkflowParameters -WorkflowName $WorkflowName -Tenant $Tenant -StorageAccount $StorageAccount -ManifestUrl $ManifestUrl -CsvPath $CsvPath
        if (-not $params) { return }
        $TenantFQDN = $params.TenantFQDN
        $StorageAccount = $params.StorageAccount
        $ManifestUrl = $params.ManifestUrl
        $CsvPath = $params.CsvPath
        $FilesBaseUrl = $params.FilesBaseUrl

        $tenantDisplay = if ($TenantFQDN) { $TenantFQDN } else { 'default' }
        Write-WorkflowEvent -Level "INFO" -Message "Tenant: $tenantDisplay, Storage: $StorageAccount"
        Write-WorkflowEvent -Level "INFO" -Message "Manifest: $ManifestUrl, CSV: $CsvPath"

        if (-not (Test-IsElevated)) { Write-WorkflowEvent -Level "ERROR" -Message "Need Admin."; return }
        if (-not (Test-WindowsVersion -MinimumVersion "10.0.0")) { return }

        $storageToken = Get-AzureTokens -TenantFQDN $TenantFQDN -AuthMethod $AuthMethod
        if (-not $storageToken) { return }

        $tenantId = (Get-AzContext).Tenant.Id
        Write-WorkflowEvent -Level "INFO" -Message "Tenant ID: $tenantId"

        $manifest = Get-ValidatedManifest -ManifestUrl $ManifestUrl -PublicKeyXml $ManifestPublicKeyXml -TenantId $tenantId
        if (-not $manifest) { return }

        $ScriptBaseUrl = $manifest.ScriptBaseUrl
        $WebhookUrl = $manifest.WebhookUrl
        $ScriptHashes = $manifest.ScriptHashes
        if (-not $ScriptBaseUrl) { Write-WorkflowEvent -Level "ERROR" -Message "Manifest missing ScriptBaseUrl."; return }
        if (-not $ScriptHashes) { Write-WorkflowEvent -Level "WARNING" -Message "No scripts to execute."; return }
        Write-WorkflowEvent -Level "INFO" -Message "ScriptBaseUrl: $ScriptBaseUrl"

        $userUPN = Get-UserIdentityFromToken -AccessToken $storageToken
        if (-not $userUPN) { 
            try { $userUPN = (Get-AzContext).Account.Id } catch { $userUPN = "unknown@domain.com" }
        }
        Write-WorkflowEvent -Level "INFO" -Message "UPN: $userUPN"

        $tempFolder = Join-Path $DownloadRoot "WorkflowFiles_$(Get-Date -Format 'yyyyMMddHHmmss')"
        New-Item -ItemType Directory -Path $tempFolder -Force -ErrorAction Stop | Out-Null
        Write-WorkflowEvent -Level "INFO" -Message "Temp folder: $tempFolder"

        $localCsv = Download-Files -FilesBaseUrl $FilesBaseUrl -TempFolder $tempFolder -FallbackCsvPath $CsvPath -TenantId $tenantId
        Write-WorkflowEvent -Level "INFO" -Message "Local CSV: $localCsv"

        Execute-PrivateScripts -ScriptHashes $ScriptHashes -ScriptBaseUrl $ScriptBaseUrl -StorageToken $storageToken -CsvPath $localCsv -TempFolder $tempFolder -UserUPN $userUPN -TenantId $tenantId -WebhookUrl $WebhookUrl

        if (-not $KeepTempFiles) {
            try { Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction Stop } catch { Write-Warning "Could not remove temp: $_" }
        } else {
            Write-Host "Temp kept: $tempFolder" -ForegroundColor Yellow
        }

        Write-Host "WORKFLOW COMPLETED" -ForegroundColor Green
        Write-WorkflowEvent -Level "INFO" -Message "Workflow completed."
    } catch {
        Write-WorkflowEvent -Level "ERROR" -Message "Unhandled exception: $_"
        throw
    } finally {
        Write-WorkflowEvent -Level "INFO" -Message "Disconnecting Azure session..."
        Disconnect-AzAccount -ErrorAction SilentlyContinue
        Stop-TranscriptAndLogging
        Write-WorkflowEvent -Level "INFO" -Message "Transcript stopped."
    }
}

Export-ModuleMember -Function Start-Workflow


# SIG # Begin signature block
# MIIFagYJKoZIhvcNAQcCoIIFWzCCBVcCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB
# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR
# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUT2Je7z3Oob6Zd9aagOnyAwjH
# ld+gggMGMIIDAjCCAeqgAwIBAgIQFaWizZSPgZFHjn193rctFjANBgkqhkiG9w0B
# 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
# AYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAjBgkqhkiG9w0BCQQxFgQUNbngyocsdiCE
# xUxPVrztUz0zGDowDQYJKoZIhvcNAQEBBQAEggEAqFatdtgDY4rP3xy1AVctPiq1
# Su682ArGd8I8ROKH23uA+Ha2Ov5irgGxvNxK4FzGvHejfd4IAKn9CF02rFRdVYpn
# lZ4MA6whdzdDibWas9Xku0gBVlWayWY2NJNqNIbap7Fd2k86xC5XvIlTYqVz7Ao2
# IF7ykVo8vKUKJcikGuOESEPY+3M78Bk+uFrZtMG4hcDiOZBqi//taMe2VVsHwtUI
# vk1XFjA0UOJslQg1JxikFaPMlOffXsCoAtLhG1Su/Y6ibwczSsaU17kYSTt0WohC
# GqR8J07PFbWHct9GVShz8yJBRcENEcKDQD/Y3xphymXA0/LdQ0ikdvykVL6oPg==
# SIG # End signature block