From c57a527b68b7dfc0d495e8802fb4e55cec139a7f Mon Sep 17 00:00:00 2001 From: henderkes Date: Mon, 29 Jun 2026 21:40:51 +0700 Subject: [PATCH 1/2] 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) --- .gitattributes | 2 + .github/scripts/configure-simplysign.ps1 | 28 +++ .github/scripts/connect-simplysign.ps1 | 245 +++++++++++++++++++++++ .github/scripts/install-simplysign.sh | 46 +++++ .github/scripts/sign-certum.ps1 | 66 ++++++ .github/workflows/cd.yaml | 29 ++- CMakeLists.txt | 6 +- 7 files changed, 418 insertions(+), 4 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/scripts/configure-simplysign.ps1 create mode 100644 .github/scripts/connect-simplysign.ps1 create mode 100644 .github/scripts/install-simplysign.sh create mode 100644 .github/scripts/sign-certum.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5012f36 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts must stay LF or bash on the CI runner chokes on \r. +*.sh text eol=lf diff --git a/.github/scripts/configure-simplysign.ps1 b/.github/scripts/configure-simplysign.ps1 new file mode 100644 index 0000000..7abc4ea --- /dev/null +++ b/.github/scripts/configure-simplysign.ps1 @@ -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." diff --git a/.github/scripts/connect-simplysign.ps1 b/.github/scripts/connect-simplysign.ps1 new file mode 100644 index 0000000..2d312ed --- /dev/null +++ b/.github/scripts/connect-simplysign.ps1 @@ -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 +} diff --git a/.github/scripts/install-simplysign.sh b/.github/scripts/install-simplysign.sh new file mode 100644 index 0000000..6e4f30a --- /dev/null +++ b/.github/scripts/install-simplysign.sh @@ -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." diff --git a/.github/scripts/sign-certum.ps1 b/.github/scripts/sign-certum.ps1 new file mode 100644 index 0000000..7f679f2 --- /dev/null +++ b/.github/scripts/sign-certum.ps1 @@ -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." diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 1253427..0387c08 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -16,9 +16,15 @@ jobs: runs-on: windows-2025-vs2026 + permissions: + contents: write + env: Configuration: Release 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: - name: Checkout @@ -46,7 +52,28 @@ jobs: - name: Build binaries 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 id: set_version run: | diff --git a/CMakeLists.txt b/CMakeLists.txt index c0c21eb..e154467 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,10 +9,10 @@ if(NOT(CMAKE_SIZEOF_VOID_P EQUAL 4)) message(FATAL_ERROR "You are configuring a non 32-bit build, this is not supported. Run cmake with `-A Win32`") endif() -set(VERSION_MAJOR 1) -set(VERSION_MINOR 9) +set(VERSION_MAJOR 2) +set(VERSION_MINOR 0) set(VERSION_PATCH 0) -set(VERSION_TWEAK 3) +set(VERSION_TWEAK 0) set(VERSION_RC "${CMAKE_CURRENT_BINARY_DIR}/version.rc") configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.rc.in" "${VERSION_RC}" @ONLY) From 9b809519f8b713b2636220d8b47a323c7498003e Mon Sep 17 00:00:00 2001 From: henderkes Date: Mon, 29 Jun 2026 21:41:48 +0700 Subject: [PATCH 2/2] 1.10 --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e154467..78b3511 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,8 +9,8 @@ if(NOT(CMAKE_SIZEOF_VOID_P EQUAL 4)) message(FATAL_ERROR "You are configuring a non 32-bit build, this is not supported. Run cmake with `-A Win32`") endif() -set(VERSION_MAJOR 2) -set(VERSION_MINOR 0) +set(VERSION_MAJOR 1) +set(VERSION_MINOR 10) set(VERSION_PATCH 0) set(VERSION_TWEAK 0)