diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 3885c79..d69a931 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -1531,39 +1531,30 @@ function Get-LenovoDrivers { [string]$ModelName ) - # Lenovo is special - they prevent access to the PSREF API without a cookie as of July 2025. - # This cookie must be retrieved via Javascript - # It appears that the cookie is hard-coded. We'll see how long this lasts. - # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. - # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). - - $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" - - # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. - # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one. - # $lenovoCookie = Get-LenovoPSREFToken - - # Add the cookie to the headers - $Headers["Cookie"] = $lenovoCookie - - $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$ModelName" - WriteLog "Querying Lenovo PSREF API for model: $ModelName" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - WriteLog "Complete" - - $jsonResponse = $response.Content | ConvertFrom-Json + $normalizedModelName = $ModelName.Trim() + $searchType = if ($normalizedModelName.Length -eq 4) { 'MT' } elseif ($normalizedModelName.Length -eq 10) { 'Model' } else { 'Normal' } + $cacheBuster = ([System.Random]::new().NextDouble()).ToString('0.################', [Globalization.CultureInfo]::InvariantCulture) + $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($normalizedModelName))&limit=6&IsPreviewProduct=true&SearchType=$searchType&t=$cacheBuster" + WriteLog "Querying Lenovo PSREF API for model: $normalizedModelName" + $requestHeaders = Get-LenovoPSREFRequestHeaders -Method GET -Uri $url -Headers $Headers -UserAgent $UserAgent + $originalVerbosePreference = $VerbosePreference + try { + $VerbosePreference = 'SilentlyContinue' + $jsonResponse = Invoke-RestMethod -Uri $url -Headers $requestHeaders -UserAgent $UserAgent -ErrorAction Stop + } + finally { + $VerbosePreference = $originalVerbosePreference + } + WriteLog "Lenovo PSREF API query complete." $products = @() foreach ($item in $jsonResponse.data) { if (-not [string]::IsNullOrEmpty($item.MachineType) -and -not [string]::IsNullOrEmpty($item.ProductName)) { $productName = $item.ProductName - $machineTypes = $item.MachineType -split " / " + $machineTypes = $item.MachineType -split '\s*/\s*' foreach ($machineType in $machineTypes) { - if ($machineType -eq $ModelName) { + if ($machineType -eq $normalizedModelName) { WriteLog "Model name entered is a matching machine type" $products = @() $products += [pscustomobject]@{ diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 index 75c9ddd..6989efc 100644 --- a/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 +++ b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.psm1 @@ -457,6 +457,143 @@ function Test-ExistingDriver { # If neither WIM nor a valid folder exists, return null return $null } + +$script:LenovoPSREFAuthContext = $null + +function Get-LenovoPSREFRequestHeaders { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH')] + [string]$Method, + [Parameter(Mandatory = $true)] + [uri]$Uri, + [Parameter()] + [AllowEmptyString()] + [string]$Body = '', + [Parameter()] + [hashtable]$Headers = @{}, + [Parameter()] + [string]$UserAgent + ) + + $currentUnixTime = [long][Math]::Floor(([DateTime]::UtcNow - [DateTime]'1970-01-01').TotalSeconds) + if ($null -eq $script:LenovoPSREFAuthContext -or + $script:LenovoPSREFAuthContext.ExpiresAt - $currentUnixTime -le 120 -or + $script:LenovoPSREFAuthContext.UserAgent -ne $UserAgent) { + $authRequestParameters = @{ + Uri = 'https://psref.lenovo.com/api/home/auth/issue' + Method = 'Post' + ContentType = 'application/json' + Body = '{}' + Headers = @{ + Accept = 'application/json, text/plain, */*' + Origin = 'https://psref.lenovo.com' + Referer = 'https://psref.lenovo.com/' + 'Sec-Fetch-Dest' = 'empty' + 'Sec-Fetch-Mode' = 'cors' + 'Sec-Fetch-Site' = 'same-origin' + } + ErrorAction = 'Stop' + } + if (-not [string]::IsNullOrWhiteSpace($UserAgent)) { + $authRequestParameters.UserAgent = $UserAgent + } + + WriteLog 'Requesting a Lenovo PSREF API access token.' + $authResponse = Invoke-RestMethod @authRequestParameters + $accessToken = [string]$authResponse.access_token + if ([string]::IsNullOrWhiteSpace($accessToken)) { + throw 'Lenovo PSREF authentication returned an empty access token.' + } + + $tokenParts = $accessToken.Split('.') + if ($tokenParts.Count -ne 3) { + throw 'Lenovo PSREF authentication returned an invalid access token.' + } + + $payloadSegment = $tokenParts[1].Replace('-', '+').Replace('_', '/') + switch ($payloadSegment.Length % 4) { + 2 { $payloadSegment += '==' } + 3 { $payloadSegment += '=' } + 1 { throw 'Lenovo PSREF authentication returned an invalid token payload.' } + } + + try { + $payloadBytes = [Convert]::FromBase64String($payloadSegment) + $tokenPayload = [Text.Encoding]::UTF8.GetString($payloadBytes) | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Unable to decode the Lenovo PSREF access token: $($_.Exception.Message)" + } + + $signatureSecret = [string]$tokenPayload.ss + $expiresAt = [long]$tokenPayload.exp + if ([string]::IsNullOrWhiteSpace($signatureSecret) -or $signatureSecret -notmatch '^(?:[0-9a-fA-F]{2})+$') { + throw 'Lenovo PSREF authentication returned an invalid signing secret.' + } + if ($expiresAt -le $currentUnixTime) { + throw 'Lenovo PSREF authentication returned an expired access token.' + } + + $script:LenovoPSREFAuthContext = [PSCustomObject]@{ + AccessToken = $accessToken + SignatureSecret = $signatureSecret + ExpiresAt = $expiresAt + UserAgent = $UserAgent + } + WriteLog 'Lenovo PSREF API access token acquired.' + } + + $requestHeaders = @{} + foreach ($key in $Headers.Keys) { + if ($key -notmatch '^(Accept|Authorization|Cookie|Priority|Sec-|Upgrade-Insecure-Requests|X-)') { + $requestHeaders[$key] = $Headers[$key] + } + } + $requestHeaders.Accept = 'application/json, text/plain, */*' + $requestHeaders.Referer = 'https://psref.lenovo.com/' + $requestHeaders['Sec-Fetch-Dest'] = 'empty' + $requestHeaders['Sec-Fetch-Mode'] = 'cors' + $requestHeaders['Sec-Fetch-Site'] = 'same-origin' + + $bodyHash = '' + if (-not [string]::IsNullOrEmpty($Body)) { + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $bodyHashBytes = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($Body)) + $bodyHash = -join ($bodyHashBytes | ForEach-Object { $_.ToString('x2') }) + } + finally { + $sha256.Dispose() + } + } + + $timestamp = [long][Math]::Floor(([DateTime]::UtcNow - [DateTime]'1970-01-01').TotalSeconds) + $nonce = [guid]::NewGuid().ToString() + $canonicalRequest = "$($Method.ToUpperInvariant())|$($Uri.PathAndQuery)|$bodyHash|$timestamp|$nonce" + $signatureSecret = $script:LenovoPSREFAuthContext.SignatureSecret + $secretBytes = New-Object byte[] ($signatureSecret.Length / 2) + for ($characterIndex = 0; $characterIndex -lt $signatureSecret.Length; $characterIndex += 2) { + $secretBytes[$characterIndex / 2] = [Convert]::ToByte($signatureSecret.Substring($characterIndex, 2), 16) + } + + $hmac = [Security.Cryptography.HMACSHA256]::new($secretBytes) + try { + $signatureBytes = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($canonicalRequest)) + $signature = -join ($signatureBytes | ForEach-Object { $_.ToString('x2') }) + } + finally { + $hmac.Dispose() + } + + $requestHeaders.Authorization = "Bearer $($script:LenovoPSREFAuthContext.AccessToken)" + $requestHeaders['X-Ts'] = $timestamp.ToString() + $requestHeaders['X-Nonce'] = $nonce + $requestHeaders['X-Sig'] = $signature + return $requestHeaders +} + function Get-LenovoPSREFToken { <# @@ -819,4 +956,5 @@ Export-ModuleMember -Function ` Compress-DriverFolderToWim, ` Update-DriverMappingJson, ` Test-ExistingDriver, ` + Get-LenovoPSREFRequestHeaders, ` Get-LenovoPSREFToken \ No newline at end of file diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 index 187cd38..4156c10 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Drivers.Lenovo.psm1 @@ -17,38 +17,29 @@ function Get-LenovoDriversModelList { [string]$UserAgent ) - # Lenovo is special - they prevent access to the PSREF API without a cookie as of July 2025. - # This cookie must be retrieved via Javascript - # It appears that the cookie is hard-coded. We'll see how long this lasts. - # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. - # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). - - $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" - - # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. - # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one. - # $lenovoCookie = Get-LenovoPSREFToken - - # Add the cookie to the headers - $Headers["Cookie"] = $lenovoCookie - - WriteLog "Querying Lenovo PSREF API for model/machine type: $ModelSearchTerm" - $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($ModelSearchTerm))" + $normalizedSearchTerm = $ModelSearchTerm.Trim() + $searchType = if ($normalizedSearchTerm.Length -eq 4) { 'MT' } elseif ($normalizedSearchTerm.Length -eq 10) { 'Model' } else { 'Normal' } + $cacheBuster = ([System.Random]::new().NextDouble()).ToString('0.################', [Globalization.CultureInfo]::InvariantCulture) + $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($normalizedSearchTerm))&limit=6&IsPreviewProduct=true&SearchType=$searchType&t=$cacheBuster" $models = [System.Collections.Generic.List[PSCustomObject]]::new() try { - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent -ErrorAction Stop - $VerbosePreference = $OriginalVerbosePreference + WriteLog "Querying Lenovo PSREF API for model/machine type: $normalizedSearchTerm" + $requestHeaders = Get-LenovoPSREFRequestHeaders -Method GET -Uri $url -Headers $Headers -UserAgent $UserAgent + $originalVerbosePreference = $VerbosePreference + try { + $VerbosePreference = 'SilentlyContinue' + $jsonResponse = Invoke-RestMethod -Uri $url -Headers $requestHeaders -UserAgent $UserAgent -ErrorAction Stop + } + finally { + $VerbosePreference = $originalVerbosePreference + } WriteLog "PSREF API query complete." - $jsonResponse = $response.Content | ConvertFrom-Json - if ($null -ne $jsonResponse.data -and $jsonResponse.data.Count -gt 0) { foreach ($item in $jsonResponse.data) { $productName = $item.ProductName - $machineTypes = $item.MachineType -split " / " # Split if multiple machine types are listed + $machineTypes = $item.MachineType -split '\s*/\s*' # Split if multiple machine types are listed foreach ($machineTypeRaw in $machineTypes) { $machineType = $machineTypeRaw.Trim() @@ -77,7 +68,7 @@ function Get-LenovoDriversModelList { } catch { WriteLog "Error querying Lenovo PSREF API: $($_.Exception.Message)" - # Return empty list on error + throw "Lenovo PSREF model search failed: $($_.Exception.Message)" } return $models }