[CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string]$PublishPath, [Parameter(Mandatory)] [ValidatePattern('^https://')] [string]$BrokerEndpoint, [Parameter(Mandatory)] [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')] [string]$ClientCertificateThumbprint, [Parameter(Mandatory)] [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')] [string]$ServerCertificateThumbprint, [string]$DomainNetbios = 'LCI', [ValidateRange(2, 90)] [int]$TimeoutSeconds = 90, [switch]$DoNotSetAsDefaultCredentialProvider, [switch]$InstallDotNetRuntime, [string]$DotNetRuntimeInstallerPath ) $ErrorActionPreference = 'Stop' $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}' $installRoot = Join-Path $env:ProgramFiles 'SGU\CredentialProvider' $settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json' $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\Credential Providers\$providerClassId" $classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32" $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $accountPictureSourcePath = Join-Path $PublishPath 'branding\user.png' $accountPictureDirectory = Join-Path $env:ProgramData 'Microsoft\User Account Pictures' $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this script from an elevated PowerShell session.' } function Test-DotNet10Runtime { $dotnetCandidates = @( (Get-Command dotnet -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue), (Join-Path $env:ProgramFiles 'dotnet\dotnet.exe') ) | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Leaf) } | Select-Object -Unique foreach ($dotnet in $dotnetCandidates) { if (& $dotnet --list-runtimes | Select-String '^Microsoft\.NETCore\.App 10\.') { return $true } } return $false } function Install-DefaultAccountPicture { param([Parameter(Mandatory)][string]$SourcePath) if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) { return $false } Add-Type -AssemblyName System.Drawing New-Item -ItemType Directory -Path $accountPictureDirectory -Force | Out-Null function Save-AccountPicture { param( [Parameter(Mandatory)][Drawing.Image]$Image, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][Drawing.Imaging.ImageFormat]$Format ) $stream = [IO.MemoryStream]::new() try { $Image.Save($stream, $Format) [IO.File]::WriteAllBytes($Path, $stream.ToArray()) } finally { $stream.Dispose() } } $source = [Drawing.Image]::FromFile($SourcePath) try { foreach ($size in @(192, 48, 40, 32)) { $bitmap = [Drawing.Bitmap]::new($size, $size) try { $graphics = [Drawing.Graphics]::FromImage($bitmap) try { $graphics.Clear([Drawing.Color]::Transparent) $graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic $graphics.DrawImage($source, [Drawing.Rectangle]::new(0, 0, $size, $size)) Save-AccountPicture -Image $bitmap ` -Path (Join-Path $accountPictureDirectory "user-$size.png") ` -Format ([Drawing.Imaging.ImageFormat]::Png) } finally { $graphics.Dispose() } } finally { $bitmap.Dispose() } } Save-AccountPicture -Image $source ` -Path (Join-Path $accountPictureDirectory 'user.png') ` -Format ([Drawing.Imaging.ImageFormat]::Png) Save-AccountPicture -Image $source ` -Path (Join-Path $accountPictureDirectory 'user.bmp') ` -Format ([Drawing.Imaging.ImageFormat]::Bmp) } finally { $source.Dispose() } return $true } if (-not (Test-DotNet10Runtime)) { if (-not $InstallDotNetRuntime) { throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.' } if ($DotNetRuntimeInstallerPath) { if (-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) { throw 'DotNetRuntimeInstallerPath does not exist.' } $runtimeInstaller = Start-Process -FilePath $DotNetRuntimeInstallerPath ` -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru if ($runtimeInstaller.ExitCode -notin @(0, 1641, 3010)) { throw "The Microsoft .NET 10 runtime installer returned $($runtimeInstaller.ExitCode)." } } else { $winget = Get-Command winget -ErrorAction SilentlyContinue if (-not $winget) { throw 'winget is unavailable. Supply the offline installer with -DotNetRuntimeInstallerPath.' } & $winget.Source install --id Microsoft.DotNet.Runtime.10 --exact --silent ` --accept-package-agreements --accept-source-agreements --disable-interactivity if ($LASTEXITCODE -ne 0) { throw 'winget could not install the Microsoft .NET 10 runtime.' } } if (-not (Test-DotNet10Runtime)) { throw 'The Microsoft .NET 10 runtime installation failed.' } } $requiredFiles = @( 'SGU.CredentialProvider.dll', 'SGU.CredentialProvider.comhost.dll', 'SGU.CredentialProvider.runtimeconfig.json', 'SGU.CredentialProvider.deps.json', 'Lithnet.CredentialProvider.dll', 'SGU.AuthBroker.Core.dll' ) foreach ($file in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $PublishPath $file))) { throw "PublishPath is missing $file." } } $resolvedPublishPath = (Resolve-Path -LiteralPath $PublishPath).Path.TrimEnd('\') $packageManifest = Get-ChildItem -LiteralPath $resolvedPublishPath -Recurse -File | Sort-Object FullName | ForEach-Object { $relativePath = $_.FullName.Substring($resolvedPublishPath.Length).TrimStart('\') '{0}={1}' -f $relativePath, (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash } $manifestBytes = [Text.Encoding]::UTF8.GetBytes(($packageManifest -join "`n")) $sha256 = [Security.Cryptography.SHA256]::Create() try { $packageHash = -join ($sha256.ComputeHash($manifestBytes) | ForEach-Object { $_.ToString('x2') }) } finally { $sha256.Dispose() } $versionId = $packageHash.Substring(0, 16) $installPath = Join-Path $installRoot "versions\$versionId" $completeMarker = Join-Path $installPath '.complete' $completeMarkerValid = $false if (Test-Path -LiteralPath $completeMarker -PathType Leaf) { try { $completeMarkerValid = [IO.File]::ReadAllText($completeMarker).Trim() -eq $packageHash } catch { # Treat an unreadable marker as an incomplete installation. Never reuse # a version directory unless its marker proves that every package byte # represented by packageHash finished installing. } } if ((Test-Path -LiteralPath $installPath) -and -not $completeMarkerValid) { $installPath = '{0}-{1}' -f $installPath, ([Guid]::NewGuid().ToString('N').Substring(0, 8)) $completeMarker = Join-Path $installPath '.complete' } $clientThumbprint = $ClientCertificateThumbprint -replace ' ', '' $serverThumbprint = $ServerCertificateThumbprint -replace ' ', '' if ($clientThumbprint.Length -ne 40 -or $serverThumbprint.Length -ne 40) { throw 'Certificate thumbprints must contain exactly 40 hexadecimal characters.' } $clientCertificate = Get-ChildItem Cert:\LocalMachine\My | Where-Object Thumbprint -eq $clientThumbprint | Select-Object -First 1 if (-not $clientCertificate -or -not $clientCertificate.HasPrivateKey) { throw 'The client certificate with private key is not installed in LocalMachine\My.' } if (-not $clientCertificate.Verify()) { throw 'The client certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.' } $serverCertificate = Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA | Where-Object Thumbprint -eq $serverThumbprint if (-not $serverCertificate) { throw 'The broker server certificate or its issuing CA is not trusted by LocalMachine.' } if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credential Provider')) { if (-not (Test-Path -LiteralPath $completeMarker)) { New-Item -ItemType Directory -Path $installPath -Force | Out-Null Copy-Item -Path (Join-Path $resolvedPublishPath '*') -Destination $installPath -Recurse -Force [IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false)) } # The domain GPO selects the Windows default account picture. Install its # branded bitmap during enrollment so no per-machine manual setup is needed. Install-DefaultAccountPicture -SourcePath $accountPictureSourcePath | Out-Null New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null $settingsJson = @{ BrokerEndpoint = $BrokerEndpoint DomainNetbios = $DomainNetbios TimeoutSeconds = $TimeoutSeconds ClientCertificateThumbprint = $clientThumbprint ServerCertificateThumbprint = $serverThumbprint } | ConvertTo-Json $utf8WithoutBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($settingsPath, $settingsJson, $utf8WithoutBom) $acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent) $acl.SetAccessRuleProtection($true, $false) # Resolve built-in identities by SID instead of localized display names. # "BUILTIN\Administrators" is not resolvable on every non-English client. $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') $administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( $administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl New-Item -Path $classRegistryPath -Force | Out-Null Set-Item -Path $classRegistryPath -Value (Join-Path $installPath 'SGU.CredentialProvider.comhost.dll') New-ItemProperty -Path $classRegistryPath -Name ThreadingModel -Value Both -PropertyType String -Force | Out-Null New-Item -Path $providerRegistryPath -Force | Out-Null # Windows PowerShell 5.1 interprets an UTF-8 script without a BOM using the # current ANSI code page. Construct the middle dot from its Unicode value so # the LogonUI registry label remains correct on every client locale. Set-Item -Path $providerRegistryPath -Value ('La Salle {0} Acceso SGU' -f [char]0x00B7) if (-not $DoNotSetAsDefaultCredentialProvider) { if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) { New-Item -Path $defaultProviderPolicyPath -Force | Out-Null } New-ItemProperty -Path $defaultProviderPolicyPath ` -Name DefaultCredentialProvider ` -Value $providerClassId ` -PropertyType String ` -Force | Out-Null } # Do not leave a signed-out SGU identity exposed as a persistent user tile. # The Microsoft password provider remains registered and supplies Other user. if (-not (Test-Path -LiteralPath $interactiveLogonPolicyPath)) { New-Item -Path $interactiveLogonPolicyPath -Force | Out-Null } New-ItemProperty -Path $interactiveLogonPolicyPath ` -Name DontDisplayLastUserName ` -Value 1 ` -PropertyType DWord ` -Force | Out-Null if (-not (Test-Path -LiteralPath $defaultProviderPolicyPath)) { New-Item -Path $defaultProviderPolicyPath -Force | Out-Null } New-ItemProperty -Path $defaultProviderPolicyPath ` -Name EnumerateLocalUsers ` -Value 0 ` -PropertyType DWord ` -Force | Out-Null } $defaultProviderConfigured = $false try { $defaultProviderConfigured = (Get-ItemPropertyValue ` -LiteralPath $defaultProviderPolicyPath ` -Name DefaultCredentialProvider ` -ErrorAction Stop) -eq $providerClassId } catch { # An explicitly opted-out installation has no default-provider policy. } [pscustomobject]@{ ProviderClassId = $providerClassId InstallPath = $installPath SettingsPath = $settingsPath Registered = Test-Path -LiteralPath $providerRegistryPath DefaultProviderConfigured = $defaultProviderConfigured LastSignedInUserHidden = (Get-ItemPropertyValue ` -LiteralPath $interactiveLogonPolicyPath ` -Name DontDisplayLastUserName) -eq 1 LocalUserEnumerationDisabled = (Get-ItemPropertyValue ` -LiteralPath $defaultProviderPolicyPath ` -Name EnumerateLocalUsers) -eq 0 SystemPasswordProviderPreserved = $true }