Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,17 @@ 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 }}
Comment thread
sandboxcoder marked this conversation as resolved.

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:
Expand Down Expand Up @@ -79,7 +83,41 @@ 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
Comment on lines +88 to +90
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
- name: 'Run Game Capture Binary Signature Verification'
run: yarn ts-node scripts/ci/verify_game_signatures.ts
working-directory: ${{ runner.temp }}/frontend-test-project
Comment thread
sandboxcoder marked this conversation as resolved.
Comment on lines +108 to +110
- name: 'Verify HEVC Encoder is present'
run: |
output=$(./ffmpeg.exe -hide_banner -encoders 2>/dev/null | grep hevc)
if [ -z "$output" ]; 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
Expand Down Expand Up @@ -161,7 +199,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'
Expand Down
100 changes: 100 additions & 0 deletions scripts/ci/verify_game_signatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as path from 'path';
import execa from 'execa';
import { promises as fs } from 'fs';

Comment thread
Copilot marked this conversation as resolved.
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';

// 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 published by 'OBS Project, LLC'.
async function verifyGameCaptureBinarySignatures(dir: string): Promise<void> {
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, 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 }`,
'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[^>]*>([^<]*)<\/S>/)?.[1];
const detail =
clixmlDetail ||
stderr
.split(/\r?\n/)
.map(line => line.trim())
.find(
line =>
line &&
!line.startsWith('#< CLIXML') &&
!line.startsWith('<Objs') &&
!line.startsWith('<Obj'),
) ||
'no details';
Comment thread
Copilot marked this conversation as resolved.
if (err.timedOut) {
console.error(`Signature verification failed for ${bin}: PowerShell timed out`);
} else if (err.exitCode === 2) {
console.error(
`Signature verification failed for ${bin}: publisher is not "${GAME_CAPTURE_PUBLISHER}" (${detail})`,
);
} else {
console.error(
`Signature verification failed for ${bin}: unsigned, tampered, or untrusted chain (${detail})`,
);
}
process.exit(1);
}

console.log(`Signature OK: ${bin}`);
}
}

const node_modules = path.join(process.cwd(), 'node_modules');
const osnDir = path.join(node_modules, 'obs-studio-node');
void verifyGameCaptureBinarySignatures(osnDir).catch(err => {
console.error('Signature verification failed:', err);
process.exit(1);
});
Loading