#Requires -Version 5.1 <# .SYNOPSIS Automated setup for testing the SecureSign USB CCID dongle on a Windows PC. .DESCRIPTION Installs and configures everything documented in the SecureSign setup guide (https://securesigndongle.rioncore.com/setup-guide.html): 1. Verifies this PC meets the basic requirements (Windows, admin rights). 2. Installs OpenSC (0.27.x) if it isn't already present. 3. Deploys the patched OpenSC PKCS#11 module (fixes the two OpenSC-side gaps that block signing against this prototype's ATR). 4. Optionally registers a friendly device name for Windows (auto-detects the dongle's ATR). 5. Installs jSignPdf and writes its PKCS#11 config. 6. Verifies Python is available and installs the 'flask' package. 7. Downloads and extracts the SecureSign signing web app. 8. Starts the web app detached in the background (logging to app.log), so this window is free to use for anything else as soon as the script finishes. Safe to re-run: every step checks whether its work is already done and skips it if so, so re-running after a partial/failed run just resumes. .PARAMETER InstallRoot Base directory for downloaded components (jSignPdf, the web app). Default: C:\SecureSign. .PARAMETER SkipOpenSC Skip installing/patching OpenSC (use if it's already installed and patched). .PARAMETER SkipDeviceName Skip the optional Windows friendly-device-name registry entry. .PARAMETER SkipJSignPdf Skip installing jSignPdf. .PARAMETER SkipWebApp Skip downloading the SecureSign web app. .PARAMETER SkipStartWebApp Download/verify the web app (unless -SkipWebApp) but don't launch it at the end -- just print the commands to start it manually instead. .PARAMETER StartWebAppForeground Run the web app in this console (blocks until Ctrl+C) instead of the default: launched detached in the background, logging to app.log, so this window (and the script) returns control to you immediately. .EXAMPLE .\Setup-SecureSign.ps1 Full setup with default options. .EXAMPLE .\Setup-SecureSign.ps1 -InstallRoot "D:\Tools\SecureSign" -SkipDeviceName Custom install location, skip the cosmetic device-naming step. .NOTES Must be run as Administrator (installing OpenSC and copying into Program Files both require it). The script self-elevates if needed. #> [CmdletBinding()] param( [string]$InstallRoot = "C:\SecureSign", [switch]$SkipOpenSC, [switch]$SkipDeviceName, [switch]$SkipJSignPdf, [switch]$SkipWebApp, [switch]$SkipStartWebApp, [switch]$StartWebAppForeground ) $ErrorActionPreference = "Stop" # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- $SiteBase = "https://securesigndongle.rioncore.com" $OpenScPatchUrl = "$SiteBase/downloads/opensc_patch_package.zip" $WebAppUrl = "$SiteBase/downloads/securesign_webapp.zip" $JSignPdfUrl = "https://downloads.sourceforge.net/project/jsignpdf/stable/JSignPdf-3.1.0/jsignpdf-3.1.0-windows-x64.zip" $OpenScInstallDir = "C:\Program Files\OpenSC Project\OpenSC" $OpenScToolsDir = Join-Path $OpenScInstallDir "tools" $OpenScPkcs11Dir = Join-Path $OpenScInstallDir "pkcs11" $JSignPdfDir = Join-Path $InstallRoot "jsignpdf" $JSignPdfConfigDir = Join-Path $JSignPdfDir "config" $WebAppDir = Join-Path $InstallRoot "securesign_webapp" $DownloadCacheDir = Join-Path $InstallRoot "downloads" $StepNumber = 0 # --------------------------------------------------------------------------- # Output helpers # --------------------------------------------------------------------------- function Write-Step { param([string]$Message) $script:StepNumber++ Write-Host "" Write-Host "[$script:StepNumber] $Message" -ForegroundColor Cyan } function Write-Info { param([string]$Message) Write-Host " $Message" -ForegroundColor Gray } function Write-Success { param([string]$Message) Write-Host " OK: $Message" -ForegroundColor Green } function Write-Skip { param([string]$Message) Write-Host " SKIP: $Message" -ForegroundColor Yellow } function Write-Warn2 { param([string]$Message) Write-Host " WARNING: $Message" -ForegroundColor Yellow } function Write-Fail { param([string]$Message) Write-Host " FAILED: $Message" -ForegroundColor Red } function Write-Banner { Write-Host "" Write-Host "================================================================" -ForegroundColor DarkCyan Write-Host " SecureSign dongle -- automated PC setup" -ForegroundColor DarkCyan Write-Host " $SiteBase" -ForegroundColor DarkCyan Write-Host "================================================================" -ForegroundColor DarkCyan } # --------------------------------------------------------------------------- # Step 0: elevation + prerequisites # --------------------------------------------------------------------------- function Test-Admin { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Assert-Admin { if (-not (Test-Admin)) { Write-Host "" Write-Host "This script needs to run as Administrator (it installs software and" -ForegroundColor Yellow Write-Host "writes to Program Files). Relaunching elevated..." -ForegroundColor Yellow $psArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$PSCommandPath`"") foreach ($key in $PSBoundParameters.Keys) { $val = $PSBoundParameters[$key] if ($val -is [switch]) { if ($val.IsPresent) { $psArgs += "-$key" } } else { $psArgs += "-$key"; $psArgs += "`"$val`"" } } Start-Process powershell.exe -Verb RunAs -ArgumentList $psArgs exit } } function Test-InternetConnection { try { $null = Invoke-WebRequest -Uri $SiteBase -Method Head -TimeoutSec 10 -UseBasicParsing return $true } catch { return $false } } function Stop-LockingProcesses { $names = @("opensc-notify", "opensc-tool", "pkcs15-tool", "pkcs11-tool", "JSignPdfC", "python") $stopped = @(Get-Process -Name $names -ErrorAction SilentlyContinue) foreach ($proc in $stopped) { Write-Info "Stopping locking process: $($proc.ProcessName) (PID $($proc.Id))" Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } if ($stopped.Count -gt 0) { # Stop-Process returns as soon as termination is requested, not once # the process has actually exited and released its file handles -- # wait for each PID to fully disappear (bounded, so a stuck process # can't hang the script forever). foreach ($proc in $stopped) { $null = Wait-Process -Id $proc.Id -Timeout 5 -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds 500 } } function Copy-ItemWithRetry { <# Windows file locks (antivirus scan, File Explorer holding a handle on a folder that's open/being viewed, a process that didn't fully release a handle the instant it exited, etc.) are common and usually transient -- retrying beats trying to enumerate every possible culprit up front. #> param( [string]$Path, [string]$Destination, [int]$MaxAttempts = 5, [int]$DelayMs = 1000 ) for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { try { Copy-Item -Path $Path -Destination $Destination -Force -ErrorAction Stop return } catch { if ($attempt -eq $MaxAttempts) { throw } Write-Warn2 "Copy locked (attempt $attempt/$MaxAttempts): $($_.Exception.Message)" Write-Info "Retrying in $([math]::Round($DelayMs / 1000, 1))s -- close any open Explorer windows/terminals in that folder if this keeps failing." Start-Sleep -Milliseconds $DelayMs } } } function Format-FileSize { param([long]$Bytes) if ($Bytes -ge 1MB) { return "$([math]::Round($Bytes / 1MB, 1)) MB" } return "$([math]::Round($Bytes / 1KB, 1)) KB" } function Test-LooksLikeHtml { # Some hosts (SourceForge, fronted by Cloudflare) serve an HTML # mirror-selection or bot-challenge page instead of the real file to # certain HTTP clients -- catch that instead of handing a fake .zip to # Expand-Archive, where the failure otherwise surfaces as a confusing # "End of Central Directory record could not be found" error. param([string]$Path) $head = Get-Content -Path $Path -TotalCount 1 -ErrorAction SilentlyContinue return ($head -match "^\s*_x64.msi' asset in the latest GitHub release ($($release.tag_name)). Assets found: $($release.assets.name -join ', ')" } Write-Info "Latest release: $($release.tag_name) -- $($asset.name)" return $asset.browser_download_url } function Step-InstallOpenSC { Write-Step "Installing OpenSC" if ($SkipOpenSC) { Write-Skip "SkipOpenSC specified" return } if (Test-Path (Join-Path $OpenScToolsDir "opensc-tool.exe")) { Write-Skip "OpenSC already installed at $OpenScInstallDir" return } $msiUrl = Get-LatestOpenScMsiUrl $msiPath = Join-Path $DownloadCacheDir "opensc.msi" Invoke-DownloadFile -Url $msiUrl -OutFile $msiPath Write-Info "Running silent install (msiexec) ..." $logPath = Join-Path $DownloadCacheDir "opensc_install.log" $proc = Start-Process msiexec.exe -ArgumentList "/i `"$msiPath`" /quiet /norestart /l*v `"$logPath`"" -Wait -PassThru if ($proc.ExitCode -ne 0) { Write-Fail "OpenSC install failed (exit $($proc.ExitCode)). See $logPath" exit 1 } Write-Success "OpenSC installed to $OpenScInstallDir" } function Step-PatchOpenSC { Write-Step "Deploying patched OpenSC PKCS#11 module" if ($SkipOpenSC) { Write-Skip "SkipOpenSC specified" return } if (-not (Test-Path $OpenScToolsDir)) { Write-Fail "OpenSC isn't installed at $OpenScInstallDir -- cannot patch. Run without -SkipOpenSC." exit 1 } $zipPath = Join-Path $DownloadCacheDir "opensc_patch_package.zip" Invoke-DownloadFile -Url $OpenScPatchUrl -OutFile $zipPath $extractDir = Join-Path $DownloadCacheDir "opensc_patch_package" if (-not (Test-Path $extractDir)) { Write-Info "Extracting patch package ..." Expand-ArchiveVerified -ZipPath $zipPath -DestinationPath $extractDir ` -VerifyFile (Join-Path $extractDir "pkcs11\opensc-pkcs11.dll") } Stop-LockingProcesses Write-Info "Copying patched files into $OpenScInstallDir ..." Copy-ItemWithRetry -Path (Join-Path $extractDir "tools\*") -Destination $OpenScToolsDir Copy-ItemWithRetry -Path (Join-Path $extractDir "pkcs11\*") -Destination $OpenScPkcs11Dir $hash = (Get-FileHash (Join-Path $OpenScPkcs11Dir "opensc-pkcs11.dll")).Hash $expectedHash = (Get-FileHash (Join-Path $extractDir "pkcs11\opensc-pkcs11.dll")).Hash if ($hash -eq $expectedHash) { Write-Success "Patched opensc-pkcs11.dll verified in place (hash match)" } else { Write-Fail "Deployed file hash doesn't match the patch package -- copy may have been blocked or reverted." Write-Info "Try closing any OpenSC-related processes and re-running this script." exit 1 } } # --------------------------------------------------------------------------- # Step 4: optional friendly device name # --------------------------------------------------------------------------- function Step-RegisterDeviceName { Write-Step "Registering friendly device name (optional, cosmetic)" if ($SkipDeviceName) { Write-Skip "SkipDeviceName specified" return } $regPath = "HKLM:\SOFTWARE\Microsoft\Cryptography\Calais\SmartCards\SEQUENTIA SecureSign Dongle" if (Test-Path $regPath) { Write-Skip "Registry entry already present" return } Write-Info "Reading the device's ATR via opensc-tool ..." $atrToolOutput = & (Join-Path $OpenScToolsDir "opensc-tool.exe") --atr 2>$null $atrLine = $atrToolOutput | Where-Object { $_ -match "^([0-9a-f]{2}:){10,}[0-9a-f]{2}\s*$" } | Select-Object -First 1 if (-not $atrLine) { Write-Warn2 "Could not read the device's ATR (is it plugged in?) -- skipping this optional step." return } $atrBytes = ($atrLine.Trim() -split ":") | ForEach-Object { [Convert]::ToByte($_, 16) } $maskBytes = ,0xFF * $atrBytes.Length New-Item -Path $regPath -Force | Out-Null Set-ItemProperty -Path $regPath -Name "ATR" -Value ([byte[]]$atrBytes) Set-ItemProperty -Path $regPath -Name "ATRMask" -Value ([byte[]]$maskBytes) Write-Success "Registered as 'SEQUENTIA SecureSign Dongle' (unplug/replug the device to see it in Device Manager)" } # --------------------------------------------------------------------------- # Step 5: jSignPdf # --------------------------------------------------------------------------- function Step-InstallJSignPdf { Write-Step "Installing jSignPdf" if ($SkipJSignPdf) { Write-Skip "SkipJSignPdf specified" return } if (Test-Path (Join-Path $JSignPdfDir "JSignPdf\JSignPdfC.exe")) { Write-Skip "jSignPdf already installed at $JSignPdfDir" } else { $zipPath = Join-Path $DownloadCacheDir "jsignpdf.zip" Invoke-DownloadFile -Url $JSignPdfUrl -OutFile $zipPath Write-Info "Extracting jSignPdf (this is a ~130 MB archive, bundles its own Java runtime) ..." New-Item -ItemType Directory -Path $JSignPdfDir -Force | Out-Null Expand-ArchiveVerified -ZipPath $zipPath -DestinationPath $JSignPdfDir ` -VerifyFile (Join-Path $JSignPdfDir "JSignPdf\JSignPdfC.exe") Write-Success "jSignPdf extracted to $JSignPdfDir" } $configPath = Join-Path $JSignPdfConfigDir "pkcs11.cfg" if (Test-Path $configPath) { Write-Skip "pkcs11.cfg already present" return } New-Item -ItemType Directory -Path $JSignPdfConfigDir -Force | Out-Null @" name=SecureSign library=$($OpenScPkcs11Dir -replace '\\','/')/opensc-pkcs11.dll slotListIndex=0 "@ | Set-Content -Path $configPath -Encoding utf8 Write-Success "Wrote $configPath" } # --------------------------------------------------------------------------- # Step 6: Python + Flask # --------------------------------------------------------------------------- function Step-CheckPythonAndFlask { Write-Step "Checking Python and installing Flask" $python = Get-Command python -ErrorAction SilentlyContinue if (-not $python) { Write-Fail "Python not found on PATH." Write-Info "Install Python 3.10+ from https://python.org (check 'Add to PATH' during install), then re-run this script." exit 1 } $version = (& python --version) 2>&1 Write-Success "Found $version" $flaskCheck = & python -c "import flask" 2>&1 if ($LASTEXITCODE -eq 0) { Write-Skip "flask already installed" } else { Write-Info "Installing flask ..." & python -m pip install flask --quiet if ($LASTEXITCODE -ne 0) { Write-Fail "pip install flask failed." exit 1 } Write-Success "flask installed" } } # --------------------------------------------------------------------------- # Step 7: SecureSign web app # --------------------------------------------------------------------------- function Step-InstallWebApp { Write-Step "Downloading the SecureSign web app" if ($SkipWebApp) { Write-Skip "SkipWebApp specified" return } # Unlike OpenSC/jSignPdf (stable, large third-party downloads worth # caching), the web app is small and expected to change across runs of # this script -- always re-fetch a fresh copy rather than trusting # whatever's already on disk, so updates actually get picked up. $zipPath = Join-Path $DownloadCacheDir "securesign_webapp.zip" Remove-Item $zipPath -ErrorAction SilentlyContinue Remove-Item $WebAppDir -Recurse -Force -ErrorAction SilentlyContinue Invoke-DownloadFile -Url $WebAppUrl -OutFile $zipPath Write-Info "Extracting web app ..." New-Item -ItemType Directory -Path $WebAppDir -Force | Out-Null Expand-ArchiveVerified -ZipPath $zipPath -DestinationPath $WebAppDir ` -VerifyFile (Join-Path $WebAppDir "app.py") Write-Success "Web app extracted to $WebAppDir (fresh copy)" } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- Write-Banner Assert-Admin New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null New-Item -ItemType Directory -Path $DownloadCacheDir -Force | Out-Null Step-Prerequisites Step-InstallOpenSC Step-PatchOpenSC Step-RegisterDeviceName Step-InstallJSignPdf Step-CheckPythonAndFlask Step-InstallWebApp Write-Host "" Write-Host "================================================================" -ForegroundColor DarkCyan Write-Host " Setup complete" -ForegroundColor Green Write-Host "================================================================" -ForegroundColor DarkCyan Write-Host "" Write-Host " Verify the device:" Write-Host " cd `"$OpenScToolsDir`"" Write-Host " .\opensc-tool.exe --list-readers" Write-Host "" Write-Host " Full test command reference: $SiteBase/#testing" Write-Host "" $appPyPath = Join-Path $WebAppDir "app.py" $pidPath = Join-Path $WebAppDir "app.pid" $logPath = Join-Path $WebAppDir "app.log" $errLogPath = Join-Path $WebAppDir "app.err.log" # Flask/werkzeug logs requests here, not app.log # Start-Process rejects -RedirectStandardOutput and -RedirectStandardError # pointing at the same file, so these have to be two separate paths. if ($SkipWebApp -or $SkipStartWebApp -or -not (Test-Path $appPyPath)) { Write-Host " Start the signing web app:" Write-Host " cd `"$WebAppDir`"" Write-Host " python app.py" Write-Host " (then open http://127.0.0.1:5001 -- or $SiteBase, Sign a PDF tab)" Write-Host "" } elseif ($StartWebAppForeground) { Write-Host " Starting the signing web app -- open http://127.0.0.1:5001 (or $SiteBase, Sign a PDF" -ForegroundColor Cyan Write-Host " tab) once it's running below. Press Ctrl+C here to stop it." -ForegroundColor Cyan Write-Host "" Set-Location $WebAppDir python app.py } else { $already = $false try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:5001/api/status" -TimeoutSec 2 -UseBasicParsing $already = $true } catch { } if ($already) { Write-Host " The signing web app is already running on http://127.0.0.1:5001 -- leaving it as-is." -ForegroundColor Cyan } else { Write-Host " Starting the signing web app in the background ..." -ForegroundColor Cyan $proc = Start-Process python -ArgumentList "app.py" -WorkingDirectory $WebAppDir ` -WindowStyle Hidden -RedirectStandardOutput $logPath -RedirectStandardError $errLogPath -PassThru Set-Content -Path $pidPath -Value $proc.Id $ready = $false for ($i = 0; $i -lt 10; $i++) { Start-Sleep -Milliseconds 500 try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:5001/api/status" -TimeoutSec 2 -UseBasicParsing $ready = $true break } catch { } } if ($ready) { Write-Success "Running in the background (PID $($proc.Id)). Log: $errLogPath" } else { Write-Fail "Didn't respond within 5s -- check $errLogPath for errors." } } Write-Host "" Write-Host " Open: http://127.0.0.1:5001 -- or $SiteBase, Sign a PDF tab" Write-Host " Stop it later:" Write-Host " Stop-Process -Id (Get-Content `"$pidPath`")" Write-Host "" }