How to automate signing your Windows app with Certum's SimplySign app

I’ve been running a SaaS for 9 years, which includes an Electron app for Windows.
For code signing, I’ve been using Certum's Code Signing Certificate:

The pricing has been great, but there’s been one small annoyance that makes automation a bit tricky.
In this post, I’ll share how I worked around it.
SimplySign requires TOTP authentication
Certum uses an app called SimplySign to handle authentication.
This app is required for signing but unfortunately makes it impossible to automate the code-signing process out of the box.
By default, SimplySign Desktop isn’t connected:

You need to manually double-click the tray icon and enter a TOTP (Time-based One-Time Password):

The code is generated by their mobile app.
This manual step breaks automation because, without completing it, you can’t use signtool.exe
— the private key isn't loaded until SimplySign authenticates.
The token can be generated programmatically
While setting up SimplySign, you scan a QR code to activate your account:

It turns out that this QR code contains a standard otpauth://
URI.
You can scan it with other password managers like 1Password — and indeed, 1Password shows the exact same token as the SimplySign app.

When you click the Edit button, you can reveal the underlying otpauth://
URI.
This means you can generate the token programmatically using a script!
Generating the OTP in PowerShell
Here’s a PowerShell snippet that generates a TOTP using inline C#:
# === 1. SETTINGS ============================================================
$OtpUri = $env:CERTUM_OTP_URI
# === 2. PARSE THE otpauth:// URI ===========================================
$uri = [Uri]$OtpUri
# Try System.Web.HttpUtility first (exists on Windows PowerShell);
# fall back to manual split if not available (Core / Linux containers).
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 = ($q['digits'] -as [int]) ?? 6
$Period = ($q['period'] -as [int]) ?? 30
$Algorithm = (($q['algorithm']) ?? 'SHA1').ToUpper()
if ($Algorithm -ne 'SHA1') {
throw "This helper only implements HMAC-SHA1 (requested: $Algorithm)."
}
# === 3. TOTP GENERATOR =====================================================
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();
int byteCount = s.Length * 5 / 8;
byte[] bytes = new byte[byteCount];
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;
}
public static string Now(string secret, int digits, int period)
{
byte[] key = Base32Decode(secret);
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] cnt = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
byte[] hash = new HMACSHA1(key).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 Get-TotpCode {
param([string]$Secret,[int]$Digits=6,[int]$Period=30)
[Totp]::Now($Secret,$Digits,$Period)
}
# === 4. LAUNCH SimplySign AND SEND CREDENTIALS =============================
$otp = Get-TotpCode -Secret $Base32 -Digits $Digits -Period $Period
Write-Host "Current TOTP: $otp"
To run it:
$env:CERTUM_OTP_URI = "otpauth://totp/…"
pwsh -ExecutionPolicy Bypass -File ./otp.ps1
This alone gives you the token.
Now, let’s use it to automate the authentication step.
Automating TOTP entry into SimplySign
PowerShell can simulate keystrokes sent to a window.
$wshell = New-Object -ComObject WScript.Shell
$focused = $wshell.AppActivate('SimplySign Desktop')
# Retry if the window hasn't appeared yet
for ($i = 0; -not $focused -and $i -lt 10; $i++) {
Start-Sleep -Milliseconds 500
$focused = $wshell.AppActivate('SimplySign Desktop')
}
if (-not $focused) {
throw "Still couldn’t bring SimplySign Desktop to the foreground."
}
Start-Sleep -Milliseconds 400
$wshell.SendKeys("$otp{ENTER}")
This snippet searches for the SimplySign Desktop window and, if found, sends the TOTP code via simulated keystrokes.
It works!
Full Script: Connect-SimplySign.ps1
Here’s the full PowerShell script you can use to automate the entire process:
<#
Connect-SimplySign.ps1
----------------------
• Works on PowerShell 5.1 and 7+
• Generates TOTP from an otpauth:// URI
• Sends username + OTP to SimplySign Desktop via SendKeys
#>
# === 1. SETTINGS ============================================================
$OtpUri = $env:CERTUM_OTP_URI
$UserId = $env:CERTUM_USERID
$ExePath = $env:CERTUM_EXE_PATH
# ============================================================================
# === 2. PARSE THE otpauth:// URI ===========================================
$uri = [Uri]$OtpUri
# Try System.Web.HttpUtility first (exists on Windows PowerShell);
# fall back to manual split if not available (Core / Linux containers).
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 = ($q['digits'] -as [int]) ?? 6
$Period = ($q['period'] -as [int]) ?? 30
$Algorithm = (($q['algorithm']) ?? 'SHA1').ToUpper()
if ($Algorithm -ne 'SHA1') {
throw "This helper only implements HMAC-SHA1 (requested: $Algorithm)."
}
# === 3. TOTP GENERATOR =====================================================
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();
int byteCount = s.Length * 5 / 8;
byte[] bytes = new byte[byteCount];
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;
}
public static string Now(string secret, int digits, int period)
{
byte[] key = Base32Decode(secret);
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / period;
byte[] cnt = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(cnt);
byte[] hash = new HMACSHA1(key).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 Get-TotpCode {
param([string]$Secret,[int]$Digits=6,[int]$Period=30)
[Totp]::Now($Secret,$Digits,$Period)
}
# === 4. LAUNCH SimplySign AND SEND CREDENTIALS =============================
$otp = Get-TotpCode -Secret $Base32 -Digits $Digits -Period $Period
Write-Host "Current TOTP: $otp"
$proc = Start-Process -FilePath $ExePath -PassThru
Write-Host "Waiting for SimplySign Desktop to appear…"
Start-Sleep -Seconds 5 # crude warm-up; tweak as needed
$wshell = New-Object -ComObject WScript.Shell
# Try by **process ID** first (most reliable) ────────────────────────────────
$focused = $wshell.AppActivate($proc.Id)
# Fallback: exact window caption ────────────────────────────────────────────
if (-not $focused) {
$focused = $wshell.AppActivate('SimplySign Desktop')
}
# Give it a few more tries, just in case the window is still spawning
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) {
throw "Still couldn’t bring SimplySign Desktop to the foreground."
}
# Window has focus → send the credentials
Start-Sleep -Milliseconds 400
$wshell.SendKeys("$otp{ENTER}")
Write-Host "`n✅ Credentials sent. The cloud smart-card should mount in a few seconds."
Environment setup
You’ll need to define these environment variables:
$env:CERTUM_OTP_URI
$env:CERTUM_USERID
$env:CERTUM_EXE_PATH
I keep these in a .env
file and use dotenv-cli
to load them when running the script.
I hope this helps you automate code signing with Certum and SimplySign!
Looking for a good Markdown tech note-taking app? Here is what I'm building:
