diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9fc3d762f41..5ac5a7a1be5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,13 +41,16 @@ jobs: - '!*.yml' - '!package.json' - '!yarn.lock' + deps: + - '{package.json,yarn.lock}' outputs: has-testable-changes: ${{ steps.filter.outputs.src }} + has-dependency-changes: ${{ steps.filter.outputs.deps }} prepare-frontend-tests: name: 'Prepare Frontend Tests' needs: testable-changes - if: needs.testable-changes.outputs.has-testable-changes == 'true' || github.event_name == 'push' + if: needs.testable-changes.outputs.has-testable-changes == 'true' || needs.testable-changes.outputs.has-dependency-changes == 'true' || github.event_name == 'push' runs-on: windows-2022 timeout-minutes: 80 steps: @@ -79,7 +82,44 @@ jobs: path: ${{ runner.temp }}/frontend-test-build.7z compression-level: 0 retention-days: 1 + verify-binary: + name: 'Verify Game Capture Binary Signatures' + needs: [testable-changes, prepare-frontend-tests] + if: needs.testable-changes.outputs.has-testable-changes == 'true' || needs.testable-changes.outputs.has-dependency-changes == 'true' || github.event_name == 'push' + runs-on: windows-2022 + timeout-minutes: 10 + steps: + - name: 'Download Test Build' + uses: actions/download-artifact@v4 + with: + name: frontend-test-build + path: ${{ runner.temp }}/frontend-test-artifact + - name: 'Prepare Test Runner' + run: | + $projectDirectory = Join-Path $env:RUNNER_TEMP 'frontend-test-project' + New-Item -ItemType Directory -Force -Path $projectDirectory | Out-Null + 7z x "$env:RUNNER_TEMP\frontend-test-artifact\frontend-test-build.7z" "-o$projectDirectory" -y -bb0 + Set-Location $projectDirectory + if (-not (Test-Path package.json)) { + throw "Test build does not contain package.json at $projectDirectory" + } + shell: powershell + - uses: actions/setup-node@v4 + with: + node-version: '22.18.0' + - name: 'Run Game Capture Binary Signature Verification' + run: yarn ts-node scripts/ci/verify_game_signatures.ts + working-directory: ${{ runner.temp }}/frontend-test-project + - name: 'Verify HEVC Encoder is present' + run: | + if ! output=$(./ffmpeg.exe -hide_banner -encoders 2>/dev/null | grep hevc); then + echo "HEVC encoder not found in ffmpeg encoders list. This may indicate that the HEVC encoder is missing or not properly configured." + exit 1 + fi + echo "HEVC encoder found: $output" + working-directory: ${{ runner.temp }}/frontend-test-project/node_modules/obs-studio-node + shell: bash test: name: 'Frontend Tests' needs: prepare-frontend-tests @@ -161,7 +201,7 @@ jobs: if: always() # Note: every required job in this workflow must be accounted in the results collation # to work properly as a required check - needs: [testable-changes, prepare-frontend-tests, test, es-lint-strict-nulls] + needs: [testable-changes, prepare-frontend-tests, verify-binary, test, es-lint-strict-nulls] runs-on: ubuntu-latest steps: - name: 'Check CI Results' diff --git a/scripts/ci/verify_game_signatures.ts b/scripts/ci/verify_game_signatures.ts new file mode 100644 index 000000000000..eb1ae98dc67f --- /dev/null +++ b/scripts/ci/verify_game_signatures.ts @@ -0,0 +1,119 @@ +import * as path from 'path'; +import execa from 'execa'; +import { promises as fs } from 'fs'; + +if (process.platform !== 'win32') { + console.error('verify_game_signatures.ts can only be run on Windows runners.'); + process.exit(1); +} +// Expected Authenticode publisher (certificate simple name) of the game capture binaries. +const GAME_CAPTURE_PUBLISHER = 'OBS Project, LLC'; +// Allowlisted signer certificate thumbprints for OBS Project, LLC game capture binaries. +// SHA256 fingerprints of the signer certificates used by the binaries shipped with the +// current release of obs-studio-node. Add an entry when the signing certificate changes. +const GAME_CAPTURE_SIGNER_THUMBPRINTS = [ + 'F776B38AB738AE9717D728170216559926661C440D9E71A70D5EEBD4908D42E7', // signed-game-binaries-32.1.1 + 'A0F5601CFC70A8E4D58D08522A5B2CB204547AB5ECB3F4B53E2BF5465B64D2CC', // signed-game-binaries-32.1.1 + '90C823C5701D7E1F9BE1DCDD3A2BC59ABE8DD93B734331AE8A3E68612A8CF3D1', // signed-game-binaries-31.1.2 + '9BC0CC37980FC4C62E8E46723627F83CA3ACB51BB8A869F4EE94B30A71B441AE', // signed-game-binaries-31.1.2 +]; + +// List of the binaries needed for game capture +const gameCaptureDependencies = [ + 'get-graphics-offsets32.exe', + 'get-graphics-offsets64.exe', + 'graphics-hook32.dll', + 'graphics-hook64.dll', + 'inject-helper32.exe', + 'inject-helper64.exe', +]; + +// Verifies the Authenticode signature of Windows game capture binaries using PowerShell. +// Exits the process with code 1 if a binary is unsigned/tampered/untrusted or not signed by the +// expected OBS Project, LLC certificate identity. +async function verifyGameCaptureBinarySignatures(dir: string): Promise { + for (const bin of gameCaptureDependencies) { + const filePath = path.join(dir, 'data', 'obs-plugins', 'win-capture', bin); + try { + await fs.access(filePath); + } catch { + console.error(`Signature verification failed for ${bin}: file not found at ${filePath}`); + process.exit(1); + } + const escapedPath = filePath.replace(/'/g, "''"); + // The publisher is compared against the certificate's simple name rather than against the + // raw Subject DN: Windows quotes any RDN value containing a comma, so the DN reads + // CN="OBS Project, LLC", ... and a bare `CN=OBS Project, LLC` pattern never matches it. + // Exit codes: 1 = unsigned/tampered/untrusted, 2 = wrong publisher, 3 = unexpected signer + // certificate, 0 = valid. + const script = [ + "$ErrorActionPreference = 'Stop'", + `try { $sig = Get-AuthenticodeSignature -LiteralPath '${escapedPath}' } catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`, + 'if ($null -eq $sig -or $null -eq $sig.SignerCertificate) { [Console]::Error.WriteLine("no signature"); exit 1 }', + "if ($sig.Status -ne 'Valid') { [Console]::Error.WriteLine(\"status=$($sig.Status): $($sig.StatusMessage)\"); exit 1 }", + "$cn = $sig.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false)", + `if ($cn -cne '${GAME_CAPTURE_PUBLISHER}') { [Console]::Error.WriteLine("publisher=$cn"); exit 2 }`, + '$sha256 = [System.Security.Cryptography.SHA256]::Create()', + '$thumbprint = [BitConverter]::ToString($sha256.ComputeHash($sig.SignerCertificate.RawData)).Replace("-", "").ToUpperInvariant()', + 'if ([string]::IsNullOrWhiteSpace($thumbprint)) { [Console]::Error.WriteLine("thumbprint=missing"); exit 3 }', + `if (@(${GAME_CAPTURE_SIGNER_THUMBPRINTS.map(thumbprint => `'${thumbprint}'`).join(', ')}) -notcontains $thumbprint) { [Console]::Error.WriteLine("thumbprint=$thumbprint"); exit 3 }`, + 'exit 0', + ].join('; '); + const encodedCommand = Buffer.from(script, 'utf16le').toString('base64'); + + try { + await execa( + 'powershell', + ['-NonInteractive', '-NoProfile', '-EncodedCommand', encodedCommand], + { + stdio: 'pipe', + timeout: 30000, + }, + ); + } catch (e: unknown) { + const err = e as { exitCode?: number; timedOut?: boolean; stderr?: string }; + // powershell.exe wraps redirected stderr in a CLIXML envelope; drop it so the real + // message (status=..., publisher=...) is what gets logged. + const stderr = err.stderr || ''; + const clixmlDetail = stderr.match(/]*>([^<]*)<\/S>/)?.[1]; + const detail = + clixmlDetail || + stderr + .split(/\r?\n/) + .map(line => line.trim()) + .find( + line => + line && + !line.startsWith('#< CLIXML') && + !line.startsWith(' { + console.error('Signature verification failed:', err); + process.exit(1); +});