Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ on:
jobs:
build-windows-installer:
if: github.event_name == 'push'
runs-on: windows-latest
runs-on: [self-hosted, windows, x64, openhcs-signing]
timeout-minutes: 30
steps:
- name: Enable Windows long paths
shell: pwsh
Expand Down Expand Up @@ -70,9 +71,9 @@ jobs:

- name: Sign and verify Windows installer
shell: pwsh
timeout-minutes: 5
env:
OPENHCS_WINDOWS_SIGNING_CERTIFICATE_BASE64: ${{ secrets.OPENHCS_WINDOWS_SIGNING_CERTIFICATE_BASE64 }}
OPENHCS_WINDOWS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.OPENHCS_WINDOWS_SIGNING_CERTIFICATE_PASSWORD }}
OPENHCS_WINDOWS_SIGNING_CERTIFICATE_THUMBPRINT: ${{ vars.OPENHCS_WINDOWS_SIGNING_CERTIFICATE_THUMBPRINT }}
run: |
& packaging/installers/windows/Sign-Installer.ps1 `
-ArtifactPath "$env:GITHUB_WORKSPACE/OpenHCS-Windows-Installer.exe"
Expand Down
81 changes: 64 additions & 17 deletions docs/source/development/mcp_release.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,26 +138,65 @@ Native installer signing
Pull-request and local installer builds are intentionally unsigned. Every
production tag build is fail-closed: the installer jobs must sign and validate
both native assets before ``build-and-publish`` can publish Python artifacts or
create the GitHub Release. Missing, partial, expired, or invalid credentials
therefore stop the entire release rather than silently publishing an unsigned
installer.
create the GitHub Release. A missing signing host, inaccessible key, incomplete
configuration, expired certificate, or failed validation therefore stops the
entire release rather than silently publishing an unsigned installer.

The Windows job requires a publicly trusted Authenticode code-signing
certificate and its private key in a password-protected PFX. Store the
credentials as these repository secrets:
Windows uses the same low-cost certificate-store route as Fiji's Jaunch
launchers. The production private key is non-exportable and remains in Certum
SimplySign or another Windows-compatible hardware/cloud provider. SignTool
selects the corresponding certificate from the current Windows user's
``My`` store by its exact SHA-1 thumbprint. Configure that public thumbprint as
one repository variable, not a secret:

.. code-block:: text

OPENHCS_WINDOWS_SIGNING_CERTIFICATE_BASE64
OPENHCS_WINDOWS_SIGNING_CERTIFICATE_PASSWORD
OPENHCS_WINDOWS_SIGNING_CERTIFICATE_THUMBPRINT

The certificate secret is the base64 encoding of the complete PFX file. The
workflow decodes it only into an ephemeral runner file, signs the existing
``OpenHCS-Windows-Installer.exe`` with SHA-256 and an RFC 3161 timestamp, runs
SignTool with the default Authenticode policy, requires a timestamp, and
then requires PowerShell's native signature object to report ``Valid`` with
both signer and timestamp-authority certificates. It deletes the temporary PFX
before the step exits.
The Windows tag job targets only a self-hosted runner with all of these labels:

.. code-block:: text

self-hosted
windows
x64
openhcs-signing

Prepare that runner under the same interactive Windows user that owns the
certificate-store projection:

1. Install Windows SDK Signing Tools, Certum SimplySign Desktop (or the chosen
provider's equivalent), and the GitHub Actions runner.
2. Connect SimplySign from the desktop session and confirm that the selected
certificate and private key appear in ``Cert:\CurrentUser\My``.
3. Assign the runner's custom ``openhcs-signing`` label and start the runner
interactively from that connected session. Do not run it as another user or
a background service that cannot access the virtual card or PIN dialog.
4. Push the release tag. A PIN-backed card may prompt during SignTool; a
pinless card signs immediately. The signing step has a bounded timeout and
the release fails if access is not authorized.

Certum documents SimplySign Desktop plus its mobile application as required to
link the cloud certificate to the signing computer. It documents SignTool
selection by ``/sha1 <thumbprint>`` and states that PIN-backed cards show a PIN
dialog while pinless cards sign without that prompt. It does not document a
safe unattended GitHub-hosted-runner login flow, so OpenHCS does not claim or
configure one.

The helper validates the exact certificate-store object, accessible private
key, validity period, and code-signing EKU before signing the existing
``OpenHCS-Windows-Installer.exe``. It uses SHA-256 with Certum's RFC 3161
timestamp service, runs SignTool with the default Authenticode policy, and then
requires PowerShell's native signature object to report ``Valid``, the exact
configured signer thumbprint, and a timestamp-authority certificate.

The same helper can be invoked outside Actions after SimplySign is connected:

.. code-block:: powershell

$env:OPENHCS_WINDOWS_SIGNING_CERTIFICATE_THUMBPRINT = "<40-character thumbprint>"
.\packaging\installers\windows\Sign-Installer.ps1 `
-ArtifactPath .\OpenHCS-Windows-Installer.exe

The macOS job requires a Developer ID Application certificate, not a Developer
ID Installer certificate, because the shipped objects are an application and
Expand Down Expand Up @@ -202,6 +241,14 @@ Publisher setup should follow the current primary platform guidance:
<https://learn.microsoft.com/en-us/windows/win32/seccrypto/time-stamping-authenticode-signatures>`_.
* `Microsoft PowerShell Authenticode signature inspection
<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/get-authenticodesignature>`_.
* `Certum Open Source Code Signing in the Cloud
<https://shop.certum.eu/open-source-code-signing-on-simplysign.html>`_.
* `Certum SignTool cloud-signing instructions
<https://support.certum.eu/en/signing-the-code-using-tools-like-signtool-and-jarsigner-instruction/>`_.
* `Jaunch's Fiji-compatible Windows signing guide
<https://github.com/apposed/jaunch/blob/5dbbcb8b865aaeb4f0a1c508d8bfc73f3ff0d0cf/doc/WINDOWS.md#code-signing>`_.
* `GitHub self-hosted runner labels
<https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/use-in-a-workflow>`_.
* `Apple Developer ID
<https://developer.apple.com/developer-id/>`_.
* `Apple custom notarization workflow
Expand All @@ -213,8 +260,8 @@ Publisher setup should follow the current primary platform guidance:

Do not test the production path with self-signed credentials: that would prove
only file mutation, not the user-facing Windows or macOS trust chain. After
installing the real secrets, validate them with a release candidate tag only
after the normal integration matrix is green.
preparing the real Windows signing host and Apple secrets, validate them with a
release candidate tag only after the normal integration matrix is green.

External steps
--------------
Expand Down
6 changes: 4 additions & 2 deletions packaging/installers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ publisher credentials. Existing release assets created before this trust
workflow remain unsigned; adding signatures does not change the install
contract or OpenHCS environment layout.

The private keys, certificates, passwords, and Apple API key are GitHub Actions
The Windows private key remains non-exportable in its certificate provider;
the workflow receives only its public thumbprint through a repository
variable. Apple certificate and notary credentials remain GitHub Actions
secrets. See ``docs/source/development/mcp_release.rst`` for the exact release
credential contract. No signing credential belongs in this directory or in a
trust contract. No signing credential belongs in this directory or in a
generated installer artifact.
199 changes: 123 additions & 76 deletions packaging/installers/windows/Sign-Installer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,78 @@
param(
[Parameter(Mandatory = $true)]
[string]$ArtifactPath,
[string]$TimestampUrl = "http://timestamp.digicert.com"
[string]$CertificateThumbprint,
[string]$TimestampUrl = "http://time.certum.pl"
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Get-RequiredEnvironmentValue {
function Normalize-CertificateThumbprint {
param(
[Parameter(Mandatory = $true)]
[string]$Name
[string]$Thumbprint
)

$value = [Environment]::GetEnvironmentVariable($Name)
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required Windows installer signing secret is missing: $Name"
$normalized = ($Thumbprint -replace "[\s:]", "").ToUpperInvariant()
if ($normalized -notmatch "^[0-9A-F]{40}$") {
throw (
"Certificate thumbprint must contain exactly 40 hexadecimal " +
"characters."
)
}
return $value
return $normalized
}

function Get-CodeSigningCertificate {
param(
[Parameter(Mandatory = $true)]
[string]$Thumbprint
)

$certificatePath = "Cert:\CurrentUser\My\$Thumbprint"
$certificate = Get-Item -LiteralPath $certificatePath `
-ErrorAction SilentlyContinue
if ($null -eq $certificate) {
throw (
"Code-signing certificate is absent from CurrentUser\\My: " +
$Thumbprint
)
}
if (-not $certificate.HasPrivateKey) {
throw (
"The selected certificate has no accessible private key. " +
"Connect SimplySign or the certificate's hardware provider."
)
}

$utcNow = [DateTime]::UtcNow
if ($certificate.NotBefore.ToUniversalTime() -gt $utcNow) {
throw "The selected code-signing certificate is not valid yet."
}
if ($certificate.NotAfter.ToUniversalTime() -le $utcNow) {
throw "The selected code-signing certificate has expired."
}

$codeSigningEkuOid = "1.3.6.1.5.5.7.3.3"
$hasCodeSigningEku = $false
foreach ($extension in $certificate.Extensions) {
if (
$extension -is
[System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]
) {
foreach ($usage in $extension.EnhancedKeyUsages) {
if ($usage.Value -eq $codeSigningEkuOid) {
$hasCodeSigningEku = $true
}
}
}
}
if (-not $hasCodeSigningEku) {
throw "The selected certificate does not permit code signing."
}

return $certificate
}

function Resolve-SignToolPath {
Expand Down Expand Up @@ -78,79 +133,71 @@ if (
throw "TimestampUrl must be an absolute HTTP or HTTPS URL."
}

$certificateBase64 = Get-RequiredEnvironmentValue `
"OPENHCS_WINDOWS_SIGNING_CERTIFICATE_BASE64"
$certificatePassword = Get-RequiredEnvironmentValue `
"OPENHCS_WINDOWS_SIGNING_CERTIFICATE_PASSWORD"
$temporaryCertificatePath = [IO.Path]::Combine(
[IO.Path]::GetTempPath(),
"openhcs-authenticode-$([Guid]::NewGuid().ToString('N')).pfx"
)

try {
try {
$certificateBytes = [Convert]::FromBase64String($certificateBase64)
}
catch [FormatException] {
throw "OPENHCS_WINDOWS_SIGNING_CERTIFICATE_BASE64 is not valid base64."
}
[IO.File]::WriteAllBytes($temporaryCertificatePath, $certificateBytes)
$certificateBytes = $null
$certificateBase64 = $null

$signToolPath = Resolve-SignToolPath
$signArguments = @(
"sign",
"/fd",
"SHA256",
"/tr",
$parsedTimestampUrl.AbsoluteUri,
"/td",
"SHA256",
"/f",
$temporaryCertificatePath,
"/p",
$certificatePassword,
$resolvedArtifact
$configuredThumbprint = $CertificateThumbprint
if ([string]::IsNullOrWhiteSpace($configuredThumbprint)) {
$configuredThumbprint = [Environment]::GetEnvironmentVariable(
"OPENHCS_WINDOWS_SIGNING_CERTIFICATE_THUMBPRINT"
)
& $signToolPath @signArguments
if ($LASTEXITCODE -ne 0) {
throw "Authenticode signing failed with exit code $LASTEXITCODE."
}
}
if ([string]::IsNullOrWhiteSpace($configuredThumbprint)) {
throw (
"Set CertificateThumbprint or " +
"OPENHCS_WINDOWS_SIGNING_CERTIFICATE_THUMBPRINT."
)
}
$normalizedThumbprint = Normalize-CertificateThumbprint $configuredThumbprint
$null = Get-CodeSigningCertificate $normalizedThumbprint

& $signToolPath "verify" "/pa" "/tw" "/v" $resolvedArtifact
if ($LASTEXITCODE -ne 0) {
throw "Authenticode verification failed with exit code $LASTEXITCODE."
}
$signToolPath = Resolve-SignToolPath
$signArguments = @(
"sign",
"/sha1",
$normalizedThumbprint,
"/s",
"My",
"/tr",
$parsedTimestampUrl.AbsoluteUri,
"/td",
"SHA256",
"/fd",
"SHA256",
"/v",
$resolvedArtifact
)
& $signToolPath @signArguments
if ($LASTEXITCODE -ne 0) {
throw "Authenticode signing failed with exit code $LASTEXITCODE."
}

$authenticodeSignature = Get-AuthenticodeSignature `
-LiteralPath $resolvedArtifact
if (
$authenticodeSignature.Status -ne
[System.Management.Automation.SignatureStatus]::Valid
) {
throw (
"Authenticode status is not valid: " +
$authenticodeSignature.StatusMessage
)
}
if ($null -eq $authenticodeSignature.SignerCertificate) {
throw "Authenticode verification returned no signer certificate."
}
if ($null -eq $authenticodeSignature.TimeStamperCertificate) {
throw "The Authenticode signature has no timestamp certificate."
}
& $signToolPath "verify" "/pa" "/all" "/tw" "/v" $resolvedArtifact
if ($LASTEXITCODE -ne 0) {
throw "Authenticode verification failed with exit code $LASTEXITCODE."
}
finally {
$certificatePassword = $null
Remove-Item Env:OPENHCS_WINDOWS_SIGNING_CERTIFICATE_BASE64 `
-ErrorAction SilentlyContinue
Remove-Item Env:OPENHCS_WINDOWS_SIGNING_CERTIFICATE_PASSWORD `
-ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $temporaryCertificatePath) {
Remove-Item -LiteralPath $temporaryCertificatePath -Force `
-ErrorAction SilentlyContinue
}

$authenticodeSignature = Get-AuthenticodeSignature `
-LiteralPath $resolvedArtifact
if (
$authenticodeSignature.Status -ne
[System.Management.Automation.SignatureStatus]::Valid
) {
throw (
"Authenticode status is not valid: " +
$authenticodeSignature.StatusMessage
)
}
if ($null -eq $authenticodeSignature.SignerCertificate) {
throw "Authenticode verification returned no signer certificate."
}
$actualSignerThumbprint = Normalize-CertificateThumbprint `
$authenticodeSignature.SignerCertificate.Thumbprint
if ($actualSignerThumbprint -ne $normalizedThumbprint) {
throw (
"Authenticode signer does not match the selected certificate. " +
"Expected $normalizedThumbprint; received $actualSignerThumbprint."
)
}
if ($null -eq $authenticodeSignature.TimeStamperCertificate) {
throw "The Authenticode signature has no timestamp certificate."
}

Write-Host "Signed and verified $resolvedArtifact"
Loading
Loading