[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' $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 } 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)) } 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) $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( 'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( 'BUILTIN\Administrators', '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 }