Compare commits

...

4 Commits

Author SHA1 Message Date
Marc 66663fa9f9 v1.10.0 - signed binaries (#39)
* Sign release binaries with Certum and bump to v2.0.0.0

Add Certum SimplySign cloud code signing to the CD pipeline so gMod.dll
and TpfConvert.exe are signed before being published as release assets.
The signing scripts are ported from gwlauncher (commit 5ae825a).

Signing is gated on the CERTUM_OTP_URI secret, so forks/unconfigured
repos still build, just unsigned. Requires repo secrets CERTUM_OTP_URI,
CERTUM_USERID and CERTUM_CERT_SHA1.

Bump the major version to 2.0.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 1.10

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:46:55 +07:00
Marc 34dd80388f Merge dev: emulation re-JIT flush, unhook fallthrough, teardown safety (v1.9.0.3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:06:00 +00:00
Marc f420bbd50a Bump version to 1.9.0.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:05:51 +00:00
Marc e762e1b716 Flush instruction cache after (un)hooking so the ARM64 emulator re-JITs
MinHook flushes only the 5 patched bytes; under the x86-on-ARM64
emulator (xtajit) that can leave a stale JIT translation of the
modified prologue and trap with a non-continuable breakpoint. Flush the
whole prologue and the trampoline on install, and the restored prologue
on teardown, so the emulator re-translates them. No-op on native x86.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:03:36 +00:00
9 changed files with 434 additions and 3 deletions
+2
View File
@@ -0,0 +1,2 @@
# Shell scripts must stay LF or bash on the CI runner chokes on \r.
*.sh text eol=lf
+28
View File
@@ -0,0 +1,28 @@
# Pre-configure SimplySign Desktop for unattended use:
# - auto-show the login dialog on launch (so the TOTP keystrokes have a target)
# - cache the smart-card PIN in the CSP, so signtool signs without a per-call
# PIN prompt, and forget it again when the session disconnects.
# Mirrors the settings used by the blinkdisk / docuscope CI signing setups.
$ErrorActionPreference = "Stop"
$RegistryPath = "HKCU:\Software\Certum\SimplySign"
$settings = [ordered]@{
ShowLoginDialogOnStart = 1
ShowLoginDialogOnAppRequest = 1
RememberLastUserName = 0
Autostart = 0
UnregisterCertificatesOnDisconnect = 0
RememberPINinCSP = 1
ForgetPINinCSPonDisconnect = 1
LangID = 9
}
Write-Host "=== Configuring SimplySign Desktop registry ==="
New-Item -Path $RegistryPath -Force | Out-Null
foreach ($name in $settings.Keys) {
Set-ItemProperty -Path $RegistryPath -Name $name -Value $settings[$name] -Type DWord
Write-Host " $name = $($settings[$name])"
}
Write-Host "Done."
+245
View File
@@ -0,0 +1,245 @@
# Authenticate SimplySign Desktop non-interactively.
#
# Certum's SimplySign cloud has no headless login: the certificate only reaches
# the Windows store after the GUI client authenticates. So we generate the
# current TOTP from the otpauth:// secret (CERTUM_OTP_URI) and paste the
# credentials into the login dialog. This needs an interactive desktop session,
# which GitHub-hosted Windows runners provide.
#
# Based on https://www.devas.life/how-to-automate-signing-your-windows-app-with-certum/
# and the refinements in blinkdisk's connect-simplysign.ps1.
param(
[string]$OtpUri = $env:CERTUM_OTP_URI,
[string]$UserId = $env:CERTUM_USERID,
[string]$ExePath = $env:CERTUM_EXE_PATH
)
if (-not $OtpUri) { Write-Host "ERROR: CERTUM_OTP_URI not provided"; exit 1 }
if (-not $UserId) { Write-Host "ERROR: CERTUM_USERID not provided"; exit 1 }
if (-not $ExePath) {
$ExePath = "C:\Program Files\Certum\SimplySign Desktop\SimplySignDesktop.exe"
}
Write-Host "=== SimplySign Desktop TOTP authentication ==="
if (-not (Test-Path $ExePath)) {
Write-Host "ERROR: SimplySign Desktop not found at $ExePath"
exit 1
}
# --- Parse the otpauth:// URI (works on PowerShell 5.1 and 7+) ---------------
$uri = [Uri]$OtpUri
try {
$q = [System.Web.HttpUtility]::ParseQueryString($uri.Query)
} catch {
$q = @{}
foreach ($part in $uri.Query.TrimStart('?') -split '&') {
$kv = $part -split '=', 2
if ($kv.Count -eq 2) { $q[$kv[0]] = [Uri]::UnescapeDataString($kv[1]) }
}
}
$Base32 = $q['secret']
$Digits = if ($q['digits']) { [int]$q['digits'] } else { 6 }
$Period = if ($q['period']) { [int]$q['period'] } else { 30 }
$Algorithm = if ($q['algorithm']) { $q['algorithm'].ToUpper() } else { 'SHA256' }
if (-not $Base32) { Write-Host "ERROR: otpauth URI has no 'secret'"; exit 1 }
if ($Algorithm -notin @('SHA1', 'SHA256', 'SHA512')) {
Write-Host "ERROR: unsupported TOTP algorithm: $Algorithm"
exit 1
}
# --- TOTP generator (RFC 6238), inline C# so there are no dependencies --------
Add-Type -Language CSharp @"
using System;
using System.Security.Cryptography;
public static class Totp
{
private const string B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
private static byte[] Base32Decode(string s)
{
s = s.TrimEnd('=').ToUpperInvariant();
byte[] bytes = new byte[s.Length * 5 / 8];
int bitBuffer = 0, bitsLeft = 0, idx = 0;
foreach (char c in s)
{
int val = B32.IndexOf(c);
if (val < 0) throw new ArgumentException("Invalid Base32 char: " + c);
bitBuffer = (bitBuffer << 5) | val;
bitsLeft += 5;
if (bitsLeft >= 8) { bytes[idx++] = (byte)(bitBuffer >> (bitsLeft - 8)); bitsLeft -= 8; }
}
return bytes;
}
private static HMAC Hmac(string algorithm, byte[] key)
{
switch (algorithm.ToUpper())
{
case "SHA1": return new HMACSHA1(key);
case "SHA256": return new HMACSHA256(key);
case "SHA512": return new HMACSHA512(key);
default: throw new ArgumentException("Unsupported algorithm: " + algorithm);
}
}
public static string Now(string secret, int digits, int period, string algorithm)
{
byte[] key = Base32Decode(secret);
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] cnt = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
byte[] hash;
using (var h = Hmac(algorithm, key)) { hash = h.ComputeHash(cnt); }
int offset = hash[hash.Length - 1] & 0x0F;
int binary =
((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
int otp = binary % (int)Math.Pow(10, digits);
return otp.ToString(new string('0', digits));
}
}
"@
function Find-UiByName($el, $walker, $name) {
$c = $walker.GetFirstChild($el)
while ($c) {
if ($c.Current.Name -eq $name) { return $c }
$r = Find-UiByName $c $walker $name
if ($r) { return $r }
$c = $walker.GetNextSibling($c)
}
return $null
}
# An outdated SimplySign build pops a modal "New version found - download?" box
# over the login form, which swallows the credential keystrokes. Decline it by
# clicking "No" (Invoke / legacy default action / click the element's point).
function Dismiss-UpdatePrompt {
try { Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes -ErrorAction Stop } catch { return $false }
if (-not ('Win32Mouse' -as [type])) {
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Win32Mouse {
[DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] static extern void mouse_event(uint f, uint x, uint y, uint d, int e);
public static void Click(int x, int y){ SetCursorPos(x,y); mouse_event(0x02,0,0,0,0); mouse_event(0x04,0,0,0,0); }
}
"@
}
$procIds = @((Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue).Id)
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$top = $walker.GetFirstChild([System.Windows.Automation.AutomationElement]::RootElement)
while ($top) {
if ($procIds -contains $top.Current.ProcessId) {
$no = Find-UiByName $top $walker "No"
if ($no) {
Write-Host "Update prompt detected; declining (clicking 'No')."
try { ($no.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern)).Invoke(); return $true } catch {}
try { ($no.GetCurrentPattern([System.Windows.Automation.LegacyIAccessiblePattern]::Pattern)).DoDefaultAction(); return $true } catch {}
try { $pt = $no.GetClickablePoint(); [Win32Mouse]::Click([int]$pt.X, [int]$pt.Y); return $true } catch { Write-Host "Click 'No' failed: $($_.Exception.Message)" }
}
}
$top = $walker.GetNextSibling($top)
}
return $false
}
# Start from a clean slate so a stale window/session can't swallow the keystrokes.
if ($existing = Get-Process -Name "SimplySignDesktop" -ErrorAction Ignore) {
Write-Host "Killing existing SimplySignDesktop process..."
$existing | Stop-Process -Force
Start-Sleep -Seconds 1
}
Write-Host "Launching SimplySign Desktop..."
$proc = Start-Process -FilePath $ExePath -PassThru
Start-Sleep -Seconds 3
$wshell = New-Object -ComObject WScript.Shell
Write-Host "Focusing the login window..."
$focused = $wshell.AppActivate($proc.Id)
if (-not $focused) { $focused = $wshell.AppActivate('SimplySign Desktop') }
for ($i = 0; (-not $focused) -and ($i -lt 10); $i++) {
Start-Sleep -Milliseconds 500
$focused = $wshell.AppActivate($proc.Id) -or $wshell.AppActivate('SimplySign Desktop')
}
if (-not $focused) {
Write-Host "ERROR: could not bring SimplySign Desktop to the foreground"
exit 1
}
# Decline the "new version available" modal if it's covering the login form.
if (Dismiss-UpdatePrompt) { Start-Sleep -Milliseconds 800 }
# Re-assert focus: dismissing the dialog can move the foreground window, and
# keystrokes sent to the wrong window are silently dropped.
$wshell.AppActivate($proc.Id) | Out-Null
$wshell.AppActivate('SimplySign Desktop') | Out-Null
Start-Sleep -Milliseconds 400
# Paste rather than type: SendKeys mangles characters like + ^ % ( ) and can
# drop characters; pasting delivers the exact string. Falls back to typing if
# the clipboard is unavailable.
function Set-Field([string]$text) {
try {
Set-Clipboard -Value $text -ErrorAction Stop
Start-Sleep -Milliseconds 150
$wshell.SendKeys("^v")
} catch {
Write-Host "Clipboard unavailable ($($_.Exception.Message)); typing instead."
$wshell.SendKeys($text)
}
Start-Sleep -Milliseconds 250
}
Write-Host "Injecting credentials..."
Set-Field $UserId
$wshell.SendKeys("{TAB}")
Start-Sleep -Milliseconds 200
# Generate the code right before sending it so it can't expire while we focus.
$otp = [Totp]::Now($Base32, $Digits, $Period, $Algorithm)
Set-Field $otp
Start-Sleep -Milliseconds 200
$wshell.SendKeys("{ENTER}")
# Don't leave the OTP / username sitting on the clipboard afterwards.
try { Set-Clipboard -Value " " -ErrorAction Stop } catch {}
Write-Host "Waiting for authentication to settle..."
Start-Sleep -Seconds 5
if (-not (Get-Process -Id $proc.Id -ErrorAction SilentlyContinue)) {
Write-Host "ERROR: SimplySign Desktop exited - authentication failed."
exit 1
}
# Confirm the login actually mounted the signing certificate into the store.
$thumb = if ($env:CERTUM_CERT_SHA1) { ($env:CERTUM_CERT_SHA1 -replace '\s', '').ToUpperInvariant() } else { $null }
Write-Host "Verifying the signing certificate reached the store..."
$deadline = (Get-Date).AddSeconds(60)
$found = $false
do {
$certs = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue
if ($thumb) { $found = [bool]($certs | Where-Object { $_.Thumbprint -eq $thumb }) }
else { $found = [bool]($certs | Where-Object { $_.Subject -like '*Certum*' -or $_.Issuer -like '*Certum*' }) }
if ($found) { break }
Start-Sleep -Seconds 3
} while ((Get-Date) -lt $deadline)
if ($found) {
Write-Host "=== Authentication complete: signing certificate is available. ==="
} else {
Write-Host "ERROR: signing certificate did not appear after authentication."
exit 1
}
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Install Certum SimplySign Desktop on the (ephemeral) Windows runner.
# SimplySign Desktop is what mounts the cloud signing certificate as a virtual
# smart card into the Windows certificate store; signtool then selects it by
# thumbprint. The version + checksum are pinned for reproducibility.
set -euo pipefail
VERSION="9.3.4.72"
URL="https://files.certum.eu/software/SimplySignDesktop/Windows/${VERSION}/SimplySignDesktop-${VERSION}-64-bit-en.msi"
EXPECTED_SHA256="bd51ebbaaac20fc7d59ab7103b5ed532b7800586df4e31f6999d03a394f9c515"
INSTALLER="SimplySignDesktop.msi"
INSTALL_DIR="/c/Program Files/Certum/SimplySign Desktop"
echo "=== Installing Certum SimplySign Desktop ${VERSION} ==="
# Idempotent: a cached/pre-provisioned image may already have it.
if [ -d "$INSTALL_DIR" ]; then
echo "Already installed at: $INSTALL_DIR"
exit 0
fi
echo "Downloading installer..."
curl -L "$URL" -o "$INSTALLER" --fail --max-time 600
ACTUAL_SHA256=$(sha256sum "$INSTALLER" | awk '{print $1}')
if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then
echo "ERROR: checksum mismatch"
echo " expected: $EXPECTED_SHA256"
echo " actual: $ACTUAL_SHA256"
exit 1
fi
echo "Checksum verified."
echo "Running msiexec (silent)..."
# Start-Process inherits this CWD, so the relative installer path resolves here.
powershell -Command "Start-Process msiexec.exe -ArgumentList '/i','${INSTALLER}','/quiet','/norestart','/l*v','install.log','ALLUSERS=1','REBOOT=ReallySuppress' -Wait -NoNewWindow"
if [ ! -d "$INSTALL_DIR" ]; then
echo "ERROR: installation directory not found after install"
echo "Last 20 lines of install.log:"
tail -20 install.log 2>/dev/null || echo "(no install.log)"
exit 1
fi
echo "SimplySign Desktop installed."
+66
View File
@@ -0,0 +1,66 @@
# Sign the given files with the Certum cloud certificate (selected by SHA1
# thumbprint from the store) and verify each signature. Assumes SimplySign
# Desktop has already authenticated (connect-simplysign.ps1).
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Files,
[string]$CertSha1 = $env:CERTUM_CERT_SHA1,
[string]$TimestampUrl = "http://time.certum.pl"
)
$ErrorActionPreference = "Stop"
if (-not $CertSha1) { throw "CERTUM_CERT_SHA1 not provided." }
$thumb = ($CertSha1 -replace '\s', '').ToUpperInvariant()
# Resolve the newest signtool.exe from the installed Windows SDK(s).
$kitsBin = "C:\Program Files (x86)\Windows Kits\10\bin"
$signtool = Get-ChildItem -Path $kitsBin -Recurse -Filter signtool.exe -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like '*\x64\*' } |
Sort-Object { [version]$_.Directory.Parent.Name } |
Select-Object -Last 1 -ExpandProperty FullName
if (-not $signtool) { throw "signtool.exe not found under $kitsBin" }
Write-Host "Using signtool: $signtool"
# The SimplySign virtual smart card registers in the store a moment after the
# desktop client authenticates; poll for our certificate before signing.
Write-Host "Waiting for certificate $thumb to appear in the store..."
$deadline = (Get-Date).AddSeconds(120)
$cert = $null
$elapsed = 0
do {
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.Thumbprint -eq $thumb } | Select-Object -First 1
if ($cert) { break }
Start-Sleep -Seconds 3
$elapsed += 3
Write-Host " ...still waiting (${elapsed}s)"
} while ((Get-Date) -lt $deadline)
if (-not $cert) {
Write-Host "Certificate not found. CurrentUser\My + LocalMachine\My contents:"
Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Select-Object Thumbprint, Subject, HasPrivateKey | Format-Table -AutoSize | Out-String | Write-Host
Write-Host "SimplySign processes:"
Get-Process -Name '*SimplySign*' -ErrorAction SilentlyContinue |
Select-Object Name, Id, Responding, MainWindowTitle | Format-Table -AutoSize | Out-String | Write-Host
throw "Certificate $thumb not found in the store - SimplySign authentication likely failed (see dump above)."
}
Write-Host "Found signing certificate: $($cert.Subject)"
$failed = @()
foreach ($file in $Files) {
if (-not (Test-Path $file)) { throw "File to sign not found: $file" }
Write-Host "=== Signing $file ==="
& $signtool sign /sha1 $thumb /fd sha256 /td sha256 /tr $TimestampUrl /v $file
if ($LASTEXITCODE -ne 0) { $failed += $file; continue }
& $signtool verify /pa /v $file
if ($LASTEXITCODE -ne 0) { $failed += $file }
}
if ($failed.Count -gt 0) {
throw "Signing/verification failed for: $($failed -join ', ')"
}
Write-Host "All binaries signed and verified."
+28 -1
View File
@@ -16,9 +16,15 @@ jobs:
runs-on: windows-2025-vs2026 runs-on: windows-2025-vs2026
permissions:
contents: write
env: env:
Configuration: Release Configuration: Release
Actions_Allow_Unsecure_Commands: true Actions_Allow_Unsecure_Commands: true
CERTUM_OTP_URI: ${{ secrets.CERTUM_OTP_URI }}
CERTUM_USERID: ${{ secrets.CERTUM_USERID }}
CERTUM_CERT_SHA1: ${{ secrets.CERTUM_CERT_SHA1 }}
steps: steps:
- name: Checkout - name: Checkout
@@ -46,7 +52,28 @@ jobs:
- name: Build binaries - name: Build binaries
run: cmake --build build --config Release run: cmake --build build --config Release
# Signing only runs when the Certum secrets are present (i.e. on the real
# repo, not forks); without them the build still produces unsigned binaries.
- name: Set up Certum SimplySign
if: env.CERTUM_OTP_URI != ''
shell: bash
run: |
chmod +x ./.github/scripts/install-simplysign.sh
./.github/scripts/install-simplysign.sh
powershell -ExecutionPolicy Bypass -File "./.github/scripts/configure-simplysign.ps1"
- name: Authenticate Certum SimplySign
if: env.CERTUM_OTP_URI != ''
shell: bash
run: powershell -ExecutionPolicy Bypass -File "./.github/scripts/connect-simplysign.ps1"
- name: Sign release binaries
if: env.CERTUM_OTP_URI != ''
shell: pwsh
run: |
./.github/scripts/sign-certum.ps1 -Files @(".\bin\Release\gMod.dll", ".\bin\Release\TpfConvert.exe")
- name: Retrieve version - name: Retrieve version
id: set_version id: set_version
run: | run: |
+2 -2
View File
@@ -10,9 +10,9 @@ if(NOT(CMAKE_SIZEOF_VOID_P EQUAL 4))
endif() endif()
set(VERSION_MAJOR 1) set(VERSION_MAJOR 1)
set(VERSION_MINOR 9) set(VERSION_MINOR 10)
set(VERSION_PATCH 0) set(VERSION_PATCH 0)
set(VERSION_TWEAK 2) set(VERSION_TWEAK 0)
set(VERSION_RC "${CMAKE_CURRENT_BINARY_DIR}/version.rc") set(VERSION_RC "${CMAKE_CURRENT_BINARY_DIR}/version.rc")
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.rc.in" "${VERSION_RC}" @ONLY) configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.rc.in" "${VERSION_RC}" @ONLY)
+13
View File
@@ -32,6 +32,10 @@ namespace {
// --- IDirect3D*Texture9 (IUnknown layout) --------------------------- // --- IDirect3D*Texture9 (IUnknown layout) ---------------------------
constexpr int kResource_Release = 2; constexpr int kResource_Release = 2;
// Bytes to flush from a hook target / trampoline so the x86-on-ARM64 emulator re-JITs
// it. Covers MinHook's max prologue patch and its 64-byte trampoline slot.
constexpr SIZE_T kFlushSpan = 64;
void** GetVTable(void* com_object) void** GetVTable(void* com_object)
{ {
return *static_cast<void***>(com_object); return *static_cast<void***>(com_object);
@@ -98,6 +102,13 @@ namespace {
Warning("D3D9Hooks: MH_EnableHook failed for %p\n", target); Warning("D3D9Hooks: MH_EnableHook failed for %p\n", target);
return false; return false;
} }
// Under the x86-on-ARM64 emulator, patched code is only re-translated when its
// instruction cache is flushed. MinHook flushes just the 5 patched bytes, which can
// leave a stale/guarded JIT block and trap; flush the whole prologue and the freshly
// built trampoline so the emulator re-JITs both. No-op on native x86.
const HANDLE proc = GetCurrentProcess();
FlushInstructionCache(proc, target, kFlushSpan);
if (original && *original) FlushInstructionCache(proc, *original, kFlushSpan);
g_hooked_targets.push_back(target); g_hooked_targets.push_back(target);
return true; return true;
} }
@@ -361,9 +372,11 @@ void RemoveAllD3D9Hooks()
// Signal first: a detour reached after this (e.g. a surviving patch) falls through. // Signal first: a detour reached after this (e.g. a surviving patch) falls through.
g_unhooked = true; g_unhooked = true;
const HANDLE proc = GetCurrentProcess();
for (void* target : g_hooked_targets) { for (void* target : g_hooked_targets) {
MH_DisableHook(target); MH_DisableHook(target);
MH_RemoveHook(target); MH_RemoveHook(target);
FlushInstructionCache(proc, target, kFlushSpan); // re-JIT the restored prologue under emulation
} }
g_hooked_targets.clear(); g_hooked_targets.clear();
+4
View File
@@ -252,6 +252,10 @@ void InitInstance(HINSTANCE hModule)
if (GetProcAddress_fn) { if (GetProcAddress_fn) {
MH_CreateHook(GetProcAddress_fn, OnGetProcAddress, (void**)&GetProcAddress_ret); MH_CreateHook(GetProcAddress_fn, OnGetProcAddress, (void**)&GetProcAddress_ret);
MH_EnableHook(GetProcAddress_fn); MH_EnableHook(GetProcAddress_fn);
// Re-JIT the patched prologue + trampoline under the x86-on-ARM64 emulator (no-op native).
const HANDLE proc = GetCurrentProcess();
FlushInstructionCache(proc, (void*)GetProcAddress_fn, 64);
if (GetProcAddress_ret) FlushInstructionCache(proc, (void*)GetProcAddress_ret, 64);
} }
} }