commit 18100b2c1c207abea26554d9b98f1c3747b90a16 Author: dave Date: Thu Nov 17 23:21:25 2022 +0300 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..0567d8b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Set-FileAssoc.ps1 - a script to change Windows 10 file associations + + +## What? +This script allows a user or an IT administrator to change user file associations in Windows 10. + +## Why? +User file associations in newer versions of Windows are normally protected from an unauthorized change, and therefore can only be set interactively through Settings app, or using a XML file pushed through GPO. + +The XML method has several drawbacks: +- IT administrator has to keep track of any new associations when a Windows Feature Update gets released; +- if the computer is not in a domain, associations can only be set in a reference image, and as a result: + - apps also have to be pre-built in your image; + - once an user changes one of their file associations, it cannot be set back using the XML method. + +A [SetUserFTA tool](https://kolbi.cz/blog/2017/10/25/setuserfta-userchoice-hash-defeated-set-file-type-associations-per-user) has been made in 2017 to combat this limitation. However, it is also not a perfect solution: +- it only changes associations for the user that launched the tool; +- this is problematic if computers are managed by means of a remote configuration administration tool, like Ansible; +- workarounds to run SetUserFTA in different user contexts exist, but they are also not ideal; +- closed-source model. + +For my personal use case (domainless network of Ansible-managed Windows 10 nodes with a "bleeding-edge" update policy), a different approach was needed, therefore, this script was made. + +# How? +1. Download the script; +2. Specify mandatory parameters - file extension and a ProgID (extension handler): + - `-Extension .pdf -ProgID SumatraPDF` + - `-Extension .html -ProgID ChromeHTML` +3. Set the user context in which this script should run: + - current user only: `-CurrentUser` + - all users: `-AllUsers` + - specific users: `-Users user1, user2` +4. Run the script: + - `.\Set-FileAssoc.ps1 -Extension .pdf -ProgID SumatraPDF -CurrentUser` + - (shorthand version) `.\Set-FileAssoc.ps1 .pdf SumatraPDF` + +## Is that... legal? +This script is a product of reverse-engineering Windows binaries. Therefore, if your organization has to strictly adhere to Microsoft EULA, it may be problematic, legal-wise, to use this script, because: +- it circumvents the measures set in place by Microsoft to prevent tampering with file associations and user experience; +- it uses features that were implemented by reverse-engineering binaries that are "legally protected" from being reverse-engineered. + +Consult your legal department for guidance. + +## Credits +Christoph Kolbicz for SetUserFTA: https://kolbi.cz diff --git a/Set-FileAssoc.ps1 b/Set-FileAssoc.ps1 new file mode 100644 index 0000000..34cc4f0 --- /dev/null +++ b/Set-FileAssoc.ps1 @@ -0,0 +1,545 @@ +<# + .SYNOPSIS + + Sets Windows file associations on a per-user basis, bypassing the built-in protection. + + .DESCRIPTION + + This script allows a user or an IT administrator to change user file associations in Windows 10. + + User file associations in newer versions of Windows are normally protected from an unauthorized change, + and therefore can only be set interactively through Settings app, or using a XML file pushed through GPO. + + The XML method has several drawbacks: + - IT administrator has to keep track of any new associations when a Windows Feature Update gets released; + - if the computer is not in a domain, associations can only be set in a reference image, and as a result: + - apps also have to be pre-built in your image; + - once an user changes one of their file associations, it cannot be set back using the XML method. + + A SetUserFTA tool has been made in 2017 to combat this limitation: + https://kolbi.cz/blog/2017/10/25/setuserfta-userchoice-hash-defeated-set-file-type-associations-per-user/ + + However, this tool is also not a perfect solution: + - it only changes associations for the user that launched the tool; + - this is problematic if computers are managed by means of a remote configuration administration tool, + like Ansible; + - workarounds to run SetUserFTA in different user contexts exist, but they are also not ideal; + - closed-source model. + + For my personal use case (domainless network of Ansible-managed Windows 10 nodes with a "bleeding-edge" update policy), + a different approach was needed, therefore, this script was made. + + .PARAMETER Extension + + Specifies a file extension that an association will be set for. + + Example: .pdf + + .PARAMETER ProgID + + Specifies the ProgID - an application/extension identifier for a file extension. + ProgIDs can be found: + - in HKCU:\Software\Classes for software that was installed in an user context; + - in HKLM:\Software\Classes for system-wide software; + - in HKCR: for a combined list of software (only works for your own user context). + + Examples: + SumatraPDF + VLC.mp4 + + .PARAMETER SkipProgIDValidation + + Do not check if a ProgID actually exists for each user. + If this parameter is specified, ProgID will always be set, even if it does not correspond to anything. + + .PARAMETER CurrentUser + + Specify this to explicitly use the script in current user context (default behaviour). + + .PARAMETER AllUsers + + Specify this to use the script for all valid local users. + This will also try to change associations for service accounts and such, but these accounts normally + do not have any association preferences, if they never were logged in to interactively. + + This parameter requires administrative privileges. + + .PARAMETER Users + + An array (comma-delimited list) of usernames. + If this is set, this script will change settings only if an user's name exists in the array. + + Examples: + user + admin, paul, mike, lina + + .INPUTS + + None. + + .OUTPUTS + + System.Int32. + + -1: if nothing was changed, or a fatal error occurred. + 0: if associations were changed for at least one user. + + .EXAMPLE + + C:\> .\Set-FileAssoc.ps1 -Extension .pdf -ProgID SumatraPDF -AllUsers + + .EXAMPLE + + C:\> .\Set-FileAssoc.ps1 -Extension .html -ProgID ChromeHTML -Users user1, user2 + + .NOTES + This script was tested on Windows 10 Home 1909 and 2004. + + This script was originally made for personal use (and still is), and, therefore: + - its author provides no guarantee that the product works, and/or will work on future versions of Windows; + - does not have a SLA or even a guarantee that the product will be maintained within its lifecycle; + - no obligations are made regarding user support and troubleshooting. + + This script is a product of reverse-engineering Windows binaries. + Therefore, if your organization has to strictly adhere to Microsoft EULA, + it may be problematic, legal-wise, to use this script, because: + - it circumvents the measures set in place by Microsoft to prevent tampering with + file associations and user experience; + - it uses features that were implemented by reverse-engineering binaries that + are "legally protected" from being reverse-engineered. + + Hash algorithm re-implementation is written in C# to avoid pitfalls with PowerShell arithmetic and integer overflows. + + .LINK + + https://git.pootis.network/dave/set-fileassoc +#> + + +#Requires -Version 5 + +[CmdletBinding(DefaultParameterSetName="CurrentUser", SupportsShouldProcess)] + +Param ( + [Parameter(Mandatory, Position=0, HelpMessage="File extension", ParameterSetName="CurrentUser")] + [Parameter(Mandatory, Position=0, HelpMessage="File extension", ParameterSetName="AllUsers")] + [Parameter(Mandatory, Position=0, HelpMessage="File extension", ParameterSetName="SpecificUsers")] + [ValidatePattern("\..+")] + [String]$Extension, + + [Parameter(Mandatory, Position=1, HelpMessage="Program ID", ParameterSetName="CurrentUser")] + [Parameter(Mandatory, Position=1, HelpMessage="Program ID", ParameterSetName="AllUsers")] + [Parameter(Mandatory, Position=1, HelpMessage="Program ID", ParameterSetName="SpecificUsers")] + [ValidateNotNullOrEmpty()] + [String]$ProgID, + + [Parameter(ParameterSetName="CurrentUser", HelpMessage="Do not check if ProgID exists in per-user HKCR")] + [Parameter(ParameterSetName="AllUsers", HelpMessage="Do not check if ProgID exists in per-user HKCR")] + [Parameter(ParameterSetName="SpecificUsers", HelpMessage="Do not check if ProgID exists in per-user HKCR")] + [Switch]$SkipProgIDValidation, + + [Parameter(ParameterSetName="CurrentUser", HelpMessage="Perform operations only on current user")] + [Switch]$CurrentUser, + + [Parameter(ParameterSetName="AllUsers", HelpMessage="Perform operations on all users")] + [Switch]$AllUsers, + + [Parameter(ParameterSetName="SpecificUsers", HelpMessage="Perform operations on specific users")] + [ValidateNotNullOrEmpty()] + [String[]]$Users +) + +Set-StrictMode -Version 3 + +$RegQueryInfoKeySig = @" + [DllImport("advapi32.dll", CharSet = CharSet.Auto)] + extern public static Int32 RegQueryInfoKey( + Microsoft.Win32.SafeHandles.SafeRegistryHandle hKey, + StringBuilder lpClass, + [In, Out] ref UInt32 lpcbClass, + UInt32 lpReserved, + out UInt32 lpcSubKeys, + out UInt32 lpcbMaxSubKeyLen, + out UInt32 lpcbMaxClassLen, + out UInt32 lpcValues, + out UInt32 lpcbMaxValueNameLen, + out UInt32 lpcbMaxValueLen, + out UInt32 lpcbSecurityDescriptor, + out System.Runtime.InteropServices.ComTypes.FILETIME lpftLastWriteTime + ); +"@ + +Add-Type -Language CSharp @" +using System; + +namespace SetFileAssoc.PatentHash +{ + public static class HashFuncs + { + + public static uint[] WordSwap(byte[] a, int sz, byte[] md5) + { + if (sz < 2 || (sz & 1) == 1) { + throw new ArgumentException(String.Format("Invalid input size: {0}", sz), "sz"); + } + + unchecked { + uint o1 = 0; + uint o2 = 0; + int ta = 0; + int ts = sz; + int ti = ((sz - 2) >> 1) + 1; + + uint c0 = (BitConverter.ToUInt32(md5, 0) | 1) + 0x69FB0000; + uint c1 = (BitConverter.ToUInt32(md5, 4) | 1) + 0x13DB0000; + + for (uint i = (uint)ti; i > 0; i--) { + uint n = BitConverter.ToUInt32(a, ta) + o1; + ta += 8; + ts -= 2; + + uint v1 = 0x79F8A395 * (n * c0 - 0x10FA9605 * (n >> 16)) + 0x689B6B9F * ((n * c0 - 0x10FA9605 * (n >> 16)) >> 16); + uint v2 = 0xEA970001 * v1 - 0x3C101569 * (v1 >> 16); + uint v3 = BitConverter.ToUInt32(a, ta - 4) + v2; + uint v4 = v3 * c1 - 0x3CE8EC25 * (v3 >> 16); + uint v5 = 0x59C3AF2D * v4 - 0x2232E0F1 * (v4 >> 16); + + + o1 = 0x1EC90001 * v5 + 0x35BD1EC9 * (v5 >> 16); + o2 += o1 + v2; + } + + if (ts == 1) { + uint n = BitConverter.ToUInt32(a, ta) + o1; + + uint v1 = n * c0 - 0x10FA9605 * (n >> 16); + uint v2 = 0xEA970001 * (0x79F8A395 * v1 + 0x689B6B9F * (v1 >> 16)) - + 0x3C101569 * ((0x79F8A395 * v1 + 0x689B6B9F * (v1 >> 16)) >> 16); + uint v3 = v2 * c1 - 0x3CE8EC25 * (v2 >> 16); + + o1 = 0x1EC90001 * (0x59C3AF2D * v3 - 0x2232E0F1 * (v3 >> 16)) + + 0x35BD1EC9 * ((0x59C3AF2D * v3 - 0x2232E0F1 * (v3 >> 16)) >> 16); + o2 += o1 + v2; + } + + uint[] ret = new uint[2]; + ret[0] = o1; + ret[1] = o2; + return ret; + } + } + + public static uint[] Reversible(byte[] a, int sz, byte[] md5) + { + if (sz < 2 || (sz & 1) == 1) { + throw new ArgumentException(String.Format("Invalid input size: {0}", sz), "sz"); + } + + unchecked { + uint o1 = 0; + uint o2 = 0; + int ta = 0; + int ts = sz; + int ti = ((sz - 2) >> 1) + 1; + + uint c0 = BitConverter.ToUInt32(md5, 0) | 1; + uint c1 = BitConverter.ToUInt32(md5, 4) | 1; + + for (uint i = (uint)ti; i > 0; i--) { + uint n = (BitConverter.ToUInt32(a, ta) + o1) * c0; + n = 0xB1110000 * n - 0x30674EEF * (n >> 16); + ta += 8; + ts -= 2; + + uint v1 = 0x5B9F0000 * n - 0x78F7A461 * (n >> 16); + uint v2 = 0x1D830000 * (0x12CEB96D * (v1 >> 16) - 0x46930000 * v1) + + 0x257E1D83 * ((0x12CEB96D * (v1 >> 16) - 0x46930000 * v1) >> 16); + uint v3 = BitConverter.ToUInt32(a, ta - 4) + v2; + + uint v4 = 0x16F50000 * c1 * v3 - 0x5D8BE90B * (c1 * v3 >> 16); + uint v5 = 0x2B890000 * (0x96FF0000 * v4 - 0x2C7C6901 * (v4 >> 16)) + + 0x7C932B89 * ((0x96FF0000 * v4 - 0x2C7C6901 * (v4 >> 16)) >> 16); + + o1 = 0x9F690000 * v5 - 0x405B6097 * (v5 >> 16); + o2 += o1 + v2; + } + + if (ts == 1) { + uint n = BitConverter.ToUInt32(a, ta) + o1; + + uint v1 = 0xB1110000 * c0 * n - 0x30674EEF * ((c0 * n) >> 16); + uint v2 = 0x5B9F0000 * v1 - 0x78F7A461 * (v1 >> 16); + uint v3 = 0x1D830000 * (0x12CEB96D * (v2 >> 16) - 0x46930000 * v2) + + 0x257E1D83 * ((0x12CEB96D * (v2 >> 16) - 0x46930000 * v2) >> 16); + uint v4 = 0x16F50000 * c1 * v3 - 0x5D8BE90B * ((c1 * v3) >> 16); + uint v5 = 0x96FF0000 * v4 - 0x2C7C6901 * (v4 >> 16); + + o1 = 0x9F690000 * (0x2B890000 * v5 + 0x7C932B89 * (v5 >> 16)) - + 0x405B6097 * ((0x2B890000 * v5 + 0x7C932B89 * (v5 >> 16)) >> 16); + o2 += o1 + v2; + } + + uint[] ret = new uint[2]; + ret[0] = o1; + ret[1] = o2; + return ret; + } + } + + public static long MakeLong(uint left, uint right) { + return (long)left << 32 | (long)right; + } + } +} +"@ + +$RegQueryInfoKey = Add-Type -MemberDefinition $RegQueryInfoKeySig -Name ImportedFuncs -Namespace RegQueryInfoKey -Using System.Text -PassThru + + + + + +function Get-ObjectCount($Objects) { + if (!$Objects) { + return 0 + } else { + return ($Objects | Measure).Count + } +} + +function Get-HKURootForUser($User) { + try { + if ($User.CU) { + return "HKCU:" + } else { + $Root = "registry::HKEY_USERS\$($User.SID)" + if (!(Test-Path $Root)) { + throw "Root HKU subkey does not exist or is unavaliable" + } else { + return $Root + } + } + } catch { + Write-Warning "Failed to retrieve HKU subkey for user `"$($User.Name)`": $_" + } + + return $null +} + +function Get-HKUKeyForUser($User) { + try { + $Key = "$($User.Root)\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts\$Extension\UserChoice" + if (!(Test-Path $Key)) { + throw "UserChoice subkey does not exist or is unavaliable" + } else { + return $Key + } + } catch { + Write-Warning "Failed to retrieve UserChoice subkey for user `"$($User.Name)`": $_" + } + + return $null +} + + +function Get-KeyWriteTimeForUser($User) { + try { + if ($User.Key -is [Microsoft.Win32.RegistryKey]) { + $Key = $User.Key + } else { + $Key = Get-Item -Path $User.Key -ErrorAction Stop + if (!$Key -or ($Key -isnot [Microsoft.Win32.RegistryKey])) { + throw "Expected RegistryKey, got $($Key.GetType())" + } + } + + if (!$Key.Handle) { + throw "Key handle is missing or set to 0" + } + + $SBLen = 16384 + $SB = New-Object System.Text.StringBuilder -ArgumentList $SBLen + $LastWriteTime = New-Object System.Runtime.InteropServices.ComTypes.FILETIME + + switch ($RegQueryInfoKey::RegQueryInfoKey($Key.Handle, $SB, [ref]$SBLen, $null, [ref]$null, [ref]$null, [ref]$null, + [ref]$null, [ref]$null, [ref]$null, [ref]$null, [ref]$LastWriteTime)) { + 0 { + $FTHigh = [System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes($LastWriteTime.dwHighDateTime), 0) + $FTLow = [System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes($LastWriteTime.dwLowDateTime), 0) + $FT = [datetime]::FromFileTime(([Int64]$FTHigh -shl 32) -bor $FTLow) + + $FTTrunc = (New-Object DateTime $FT.Year, $FT.Month, $FT.Day, $FT.Hour, $FT.Minute, 0, $FT.Kind).ToFileTime() + + return [string]::Format("{0:x8}{1:x8}", $FTTrunc -shr 32, $FTTrunc -band [uint32]::MaxValue) + } + + default { + throw "RegQueryInfoKey returned error code $_" + } + } + } catch { + Write-Warning "Failed to retrieve write time for UserChoice subkey for user `"$($User.Name)`": $_" + } + + return $null +} + + +function Get-InputStringForUser($User) { + return ("{0}{1}{2}{3}{4}" -f $Extension, $User.SID, $ProgID, $User.WriteTime, + "User Choice set via Windows User Experience {D18B6DD5-6124-4341-9318-804003BAFA0B}").ToLowerInvariant() +} + + + +function Get-IsUserSelected($User) { + if ($CurrentUser) { + return $User.CU + } elseif ($Users) { + return ($User.Name -in $Users) + } else { + return $true + } +} + +function Enumerate-Users { + try { + $SIDs = Get-CimInstance -Filter "LocalAccount=TRUE" -Class "Win32_UserAccount" | ? {$_.SID -notmatch "^S-1-5-21-(\d{10}-){3}5[0-9]{2}$"} + + if ($SIDs -and (Get-ObjectCount $SIDs) -ge 1) { + $NewSIDs = $SIDs | select Name, SID, @{n="CU"; e={$_.SID -eq (([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value)}} + $NewSIDs = $NewSIDs | ? {(Get-IsUserSelected $_) -eq $true} + + return $NewSIDs + } + } catch { + Write-Warning "Failed to enumerate user list through CIM" + } + + return @() +} + +function Set-UserKeyInfo($User) { + return $User | + select *, @{n="Root"; e={Get-HKURootForUser $_}} | ? Root -ne $null | + select *, @{n="Key"; e={Get-HKUKeyForUser $_}} | ? Key -ne $null +} + +function Set-UserExtraInfo($User) { + return $User | + select *, @{n="WriteTime"; e={Get-KeyWriteTimeForUser $_}} | ? WriteTime -ne $null | + select *, @{n="InputString"; e={Get-InputStringForUser $_}} | ? InputString -ne $null +} + +function Get-IsProgIDAvaliable($User) { + if ($SkipProgIDValidation) { + return $true + } + + return (Test-Path "$($User.Root)\Software\Classes\$ProgID" -ErrorAction SilentlyContinue) -or + (Test-Path "HKLM:\Software\Classes\$ProgID" -ErrorAction SilentlyContinue) +} + +function Clear-UserChoice($User) { + if (!$User.Key) { + throw "No registry key provided for Clear-UserChoice" + } + + if ($PSCmdlet.ShouldProcess($User.Key, "Clear-ItemProperty")) { + Clear-ItemProperty -Path $User.Key -Name "Hash" + } +} + +function Set-UserChoice($User, $Hash) { + if (!$Hash) { + throw "No hash provided for Set-UserChoice" + } + + if ($PSCmdlet.ShouldProcess($User.Key, "Set-ItemProperty")) { + Set-ItemProperty -Path $User.Key -Name "ProgId" -Value $ProgID -Type String + Set-ItemProperty -Path $User.Key -Name "Hash" -Value $Hash -Type String + } +} + + + +function Convert-StringToUTF16LEArray($Str) { + return [System.Collections.ArrayList]([System.Text.Encoding]::Unicode.GetBytes($Str)) +} + +function Get-ArrayMD5Hash($A) { + return [System.Security.Cryptography.HashAlgorithm]::Create("MD5").ComputeHash($A) +} + +function Get-PatentHash([byte[]]$A, [byte[]]$MD5) { + $Size = $A.Count + $ShiftedSize = ($Size -shr 2) - ($Size -shr 2 -band 1) * 1 + + [uint32[]]$A1 = [SetFileAssoc.PatentHash.HashFuncs]::WordSwap($A, [int]$ShiftedSize, $MD5); + [uint32[]]$A2 = [SetFileAssoc.PatentHash.HashFuncs]::Reversible($A, [int]$ShiftedSize, $MD5); + + $Ret = [SetFileAssoc.PatentHash.HashFuncs]::MakeLong($A1[1] -bxor $A2[1], $A1[0] -bxor $A2[0]); + return $Ret +} + + + + + + + +{ + [System.Int32]$ReturnCode = -1 + + if (!$CurrentUser -and !$AllUsers -and !$Users) { + $CurrentUser = $true + } + + $EnumeratedUsers = Enumerate-Users + foreach ($User in $EnumeratedUsers) { + $User = Set-UserKeyInfo $User + if (!$User) { + continue + } + + try { + Clear-UserChoice $User + } catch { + Write-Warning "Clear-UserChoice failed, skipping user `"$($User.Name)`"" + continue + } + + $User = Set-UserExtraInfo $User + if (!$User) { + continue + } + + + try { + $A = Convert-StringToUTF16LEArray $User.InputString + $A += (0,0) + + $MD5 = Get-ArrayMD5Hash $A + $PatentHash = Get-PatentHash $A $MD5 + + $Hash = [System.Convert]::ToBase64String([System.BitConverter]::GetBytes([Int64]$PatentHash)) + + Write-Verbose "Hash for user `"$($User.Name)`": $Hash" + } catch { + Write-Warning "Failed to calculate hash for `"$($User.Name)`", skipping this user" + continue + } + + + try { + Set-UserChoice -User $User -Hash $Hash + } catch { + Write-Warning "Set-UserChoice failed for user `"$($User.Name)`"" + } + + Write-Host "File association set for user `"$($User.Name)`": $Extension => $ProgID (hash `"$Hash`")" + $ReturnCode = 0 # return 0 if at least one assoc was set correctly + } + + exit $ReturnCode +}.Invoke()