Files
mike 8c1a12b6e9
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
Implement code signing (cStudio CA)
2026-05-08 21:51:03 +02:00

360 lines
16 KiB
YAML

name: build-windows
on:
push:
branches: [main, master]
workflow_dispatch:
inputs:
version_suffix:
description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla."
type: string
default: ""
# Workflow-level env is visible to every job. Runner-specific paths
# (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the
# Linux signing runner has no use for them and shouldn't see them.
env:
TIMESTAMP_URL: "http://timestamp.digicert.com"
SIGN_DESCRIPTION: "HelloAgent Remote Support"
SIGN_INFO_URL: "https://cstudio.ch/hello-agent"
jobs:
# ---- 1. BUILD on Windows ---------------------------------------------------
# Produces an unsigned hello-agent.exe and a SHA-256 of those bytes that
# the sign job re-checks before signing. The pre-sign hash is the audit
# link between "what the build runner produced" and "what the signer was
# asked to sign" — a transit corruption or tampered artifact gets caught
# at sign time, not at the customer.
build-x64:
name: build-hello-agent-x64
runs-on: [self-hosted, windows-10]
timeout-minutes: 90
outputs:
version_tag: ${{ steps.version.outputs.tag }}
env:
RUST_VERSION: "1.75"
LLVM_VERSION: "15.0.6"
LLVM_HOME: 'C:\tools\llvm-15.0.6'
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VCPKG_ROOT: C:\vcpkg
VCPKG_BINARY_SOURCES: "clear;files,C:\\vcpkg-cache,readwrite"
LIBCLANG_PATH: 'C:\tools\llvm-15.0.6\bin'
steps:
- name: Checkout hello-agent (with vendored rustdesk)
uses: actions/checkout@v4
# We vendor the rustdesk source under vendor/rustdesk/ so this
# checkout is fully self-contained — no sibling repo, no submodules.
- name: Verify host toolchain
shell: pwsh
run: |
$required = 'pwsh','git','bash','python','rustc','cargo','rustup','clang'
$missing = @()
foreach ($tool in $required) {
$cmd = Get-Command $tool -ErrorAction SilentlyContinue
if (-not $cmd) { $missing += $tool; continue }
$ver = & $tool --version 2>&1 | Select-Object -First 1
Write-Host ("{0,-10} {1} ({2})" -f $tool, $cmd.Source, $ver)
}
if ($missing.Count -gt 0) {
Write-Error ("Missing tools on runner: {0}" -f ($missing -join ', '))
exit 1
}
if (-not $env:VCPKG_ROOT -or -not (Test-Path "$env:VCPKG_ROOT\vcpkg.exe")) {
Write-Error "VCPKG_ROOT not set or vcpkg.exe missing at $env:VCPKG_ROOT"
exit 1
}
if (-not (Test-Path "$env:LIBCLANG_PATH\libclang.dll")) {
Write-Error "libclang.dll not found at $env:LIBCLANG_PATH"
exit 1
}
- name: Configure Rust toolchain
shell: pwsh
run: |
rustup toolchain install $env:RUST_VERSION --profile minimal --component rustfmt
if ($LASTEXITCODE -ne 0) { throw "rustup toolchain install failed ($LASTEXITCODE)" }
rustup default $env:RUST_VERSION
if ($LASTEXITCODE -ne 0) { throw "rustup default failed ($LASTEXITCODE)" }
rustup target add x86_64-pc-windows-msvc
rustc --version
cargo --version
- name: Configure git safe.directory
shell: pwsh
run: git config --global --add safe.directory '*'
- name: vcpkg install dependencies (x64-windows-static)
shell: bash
env:
VCPKG_DEFAULT_HOST_TRIPLET: x64-windows-static
# vcpkg.json sits at vendor/rustdesk/vcpkg.json (alongside the
# rustdesk Cargo.toml). Run from there so manifest mode picks it up.
run: |
mkdir -p /c/vcpkg-cache
cd vendor/rustdesk
if ! "$VCPKG_ROOT/vcpkg" install \
--triplet x64-windows-static \
--x-install-root="$VCPKG_ROOT/installed"; then
find "$VCPKG_ROOT/" -name "*.log" -exec sh -c 'echo "===== {} ====="; cat "{}"' \;
exit 1
fi
# Build hello-agent. We do NOT pre-build vendor/rustdesk/libs/virtual_display/dylib
# the way the upstream rustdesk workflow does. That dylib produces a
# standalone `dylib_virtual_display.dll` runtime artifact that the
# rustdesk Flutter exe ships side-by-side; hello-agent doesn't bundle
# it (no virtual-display feature in v0), and the `virtual_display`
# crate that librustdesk *does* link against has no compile-time dep
# on the dylib — it loads it by name at runtime if present.
#
# Pre-building it would also force a second cargo invocation inside
# the vendor/rustdesk/ workspace, which has no Cargo.lock of its own
# and would re-resolve git deps from HEAD (breaking the tray-icon
# 0.21.3 pin we keep at the hello-agent root).
- name: Cargo build hello-agent
shell: pwsh
run: |
cargo build --release --bin hello-agent --locked
if ($LASTEXITCODE -ne 0) { throw "hello-agent build failed" }
if (-not (Test-Path target\release\hello-agent.exe)) {
throw "target\release\hello-agent.exe missing after cargo build"
}
- name: Compute version tag
id: version
shell: pwsh
env:
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
$base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value
if (-not $base) { throw "could not parse version from Cargo.toml" }
if ($env:VERSION_SUFFIX) { $tag = "$base-$env:VERSION_SUFFIX" } else { $tag = $base }
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
Write-Host "Version tag: $tag"
- name: Stage unsigned artifact + record pre-sign hash
shell: pwsh
# The pre-sign hash is the chain-of-custody primitive: build runner
# publishes it with the binary, signing runner re-hashes the binary
# it actually received and refuses to sign on mismatch. Catches
# corruption in transit and tampering between jobs.
run: |
New-Item -ItemType Directory -Force -Path .\BuildOutput | Out-Null
Copy-Item -Force target\release\hello-agent.exe `
.\BuildOutput\hello-agent.exe
# Lowercase hex + LF-only line ending is the canonical sha256
# format (matches sha256sum, openssl, etc.). PowerShell's
# Get-FileHash returns uppercase, and Out-File writes CRLF on
# Windows — both bite the Linux signer's hash compare. We use
# WriteAllText with an explicit "`n" so the file ends up exactly
# 65 bytes: 64 hex chars + one LF.
$h = Get-FileHash .\BuildOutput\hello-agent.exe -Algorithm SHA256
$hash = $h.Hash.ToLower()
[IO.File]::WriteAllText(
"$PWD\BuildOutput\hello-agent.exe.presig.sha256",
"$hash`n")
Write-Host "::notice title=Pre-sign SHA-256::$hash"
- name: Upload unsigned artifact
uses: actions/upload-artifact@v3
with:
name: hello-agent-unsigned-${{ github.sha }}
path: |
BuildOutput/hello-agent.exe
BuildOutput/hello-agent.exe.presig.sha256
if-no-files-found: error
# Short retention — superseded by the signed artifact within a few
# minutes. Keeps the artifact store from filling up with
# unsigned-and-therefore-not-shippable binaries.
retention-days: 1
# ---- 2. SIGN on the Linux LXC ---------------------------------------------
# Runs on the dedicated signing host provisioned by ci/runners/linux/provision.sh.
# Has access to /etc/pki/hello-agent/{chain.pem,codesign.key}; doesn't have
# a build toolchain. The runner can sign — it can't produce a binary from
# source.
sign-x64:
name: sign-hello-agent-x64
needs: build-x64
runs-on: [self-hosted, linux, signing]
timeout-minutes: 10
env:
PKI_DIR: /etc/pki/hello-agent
steps:
- name: Download unsigned artifact
uses: actions/download-artifact@v3
with:
name: hello-agent-unsigned-${{ github.sha }}
path: ./incoming
- name: Check signing-cert expiry
# Fail HARD if the cert expires within 30 days; warn at 60. Without
# this, an expired cert silently produces a signature that's invalid
# at customer install time (and lifetimeSigning isn't set, but
# signtool still rejects expired-at-sign-time leaves).
run: |
end="$(openssl x509 -in "$PKI_DIR/chain.pem" -noout -enddate | cut -d= -f2)"
end_epoch="$(date -d "$end" +%s)"
now_epoch="$(date +%s)"
days_left=$(( (end_epoch - now_epoch) / 86400 ))
echo "Signing cert NotAfter: $end ($days_left days)"
if [ "$days_left" -lt 30 ]; then
echo "::error title=Signing cert expiring::$days_left days left — rotate before next sign"
exit 1
elif [ "$days_left" -lt 60 ]; then
echo "::warning title=Signing cert::$days_left days left — schedule rotation"
fi
- name: Verify pre-sign hash matches received bytes
# Pre-sign hash check: confirms the file we're about to sign matches
# what the build runner emitted. A mismatch means corruption or
# tampering between jobs — abort, do not sign.
#
# `tr -d '\r' | tr A-Z a-z` defensively normalizes the expected
# value: `awk '{print $1}'` strips the trailing LF (its RS) but NOT
# CR, and PowerShell's default Out-File writes CRLF on Windows —
# so without -d '\r' a CRLF-encoded sha256 file ends up with $1 =
# "<hash>\r", which compares unequal to the actual sha256sum output
# and renders as visually identical in logs (the \r is a no-op in
# most terminals). Lowercasing covers a similar case-mismatch trap.
# The build job already writes LF + lowercase, but the verifier
# shouldn't trust that contract.
run: |
expected="$(tr -d '\r' < incoming/hello-agent.exe.presig.sha256 | awk '{print $1}' | tr 'A-Z' 'a-z')"
actual="$(sha256sum incoming/hello-agent.exe | awk '{print $1}')"
if [ "$expected" != "$actual" ]; then
echo "::error title=Pre-sign hash mismatch::artifact corrupted or tampered"
echo "expected=$expected"
echo "actual=$actual"
exit 1
fi
echo "Pre-sign SHA-256 OK: $actual"
- name: Authenticode sign (osslsigncode, SHA-256, RFC 3161)
# -h sha256: file digest. -ts: RFC 3161 timestamp (NOT -t which is
# legacy Authenticode timestamp). -n / -i: cosmetic, shown in the
# UAC prompt and "signtool verify /v" output.
run: |
mkdir -p signed
osslsigncode sign \
-certs "$PKI_DIR/chain.pem" \
-key "$PKI_DIR/codesign.key" \
-h sha256 \
-ts "$TIMESTAMP_URL" \
-n "$SIGN_DESCRIPTION" \
-i "$SIGN_INFO_URL" \
-in incoming/hello-agent.exe \
-out signed/hello-agent.exe
- name: Verify signature (osslsigncode self-check)
run: osslsigncode verify -in signed/hello-agent.exe
- name: Record post-sign hash
run: |
h="$(sha256sum signed/hello-agent.exe | awk '{print $1}')"
echo "$h" > signed/hello-agent.exe.signed.sha256
echo "::notice title=Post-sign SHA-256::$h"
- name: Stage signed bundle for upload
# upload-artifact@v3 stores files using paths relative to the
# *most root common directory* of all matched paths. Mixing
# `signed/...` and `incoming/...` makes that common directory the
# workflow root, so the artifact ends up containing `signed/file`
# and `incoming/file` — and `download-artifact@v3 path: ./signed`
# then double-nests it as `./signed/signed/file`. Flatten into a
# single staging dir so the artifact has a flat layout that
# extracts to `./signed/file` cleanly on the validate runner.
run: |
mkdir -p bundle
cp signed/hello-agent.exe bundle/
cp signed/hello-agent.exe.signed.sha256 bundle/
cp incoming/hello-agent.exe.presig.sha256 bundle/
- name: Upload signed artifact
uses: actions/upload-artifact@v3
with:
name: hello-agent-signed-${{ github.sha }}
path: bundle/
if-no-files-found: error
retention-days: 90
# ---- 3. VALIDATE on Windows -----------------------------------------------
# Trust-but-verify: osslsigncode produces signatures that should validate
# on Windows, but "should" isn't "did". This job is the cross-OS smoke
# test — a Windows runner running CryptoAPI's verifier against the same
# bytes the customer will see. Catches the rare cases where Linux thinks
# a sig is fine but Windows rejects it.
validate-x64:
name: validate-hello-agent-x64
needs: [build-x64, sign-x64]
runs-on: [self-hosted, windows-10]
timeout-minutes: 10
steps:
- name: Download signed artifact
uses: actions/download-artifact@v3
with:
name: hello-agent-signed-${{ github.sha }}
path: ./signed
- name: Verify Authenticode signature (Windows CryptoAPI)
shell: pwsh
run: |
# signtool ships with Windows Kits; version dir varies. Find the
# newest x64 build present rather than hardcoding 10.0.22621.0.
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" `
-Recurse -Filter signtool.exe -ErrorAction SilentlyContinue `
| Where-Object { $_.FullName -match '\\x64\\' } `
| Sort-Object FullName -Descending `
| Select-Object -First 1 -ExpandProperty FullName
if (-not $signtool) { throw "signtool.exe not found in any Windows Kits 10 bin\*\x64\" }
Write-Host "Using signtool: $signtool"
& $signtool verify /pa /v signed\hello-agent.exe
if ($LASTEXITCODE -ne 0) { throw "signtool verify failed ($LASTEXITCODE)" }
$sig = Get-AuthenticodeSignature signed\hello-agent.exe
if ($sig.Status -ne 'Valid') {
throw "AuthenticodeSignature.Status = $($sig.Status); $($sig.StatusMessage)"
}
if (-not $sig.TimeStamperCertificate) {
throw "no RFC 3161 timestamp present — would expire with the cert"
}
Write-Host ""
Write-Host "Signed by: $($sig.SignerCertificate.Subject)"
Write-Host "Cert expires: $($sig.SignerCertificate.NotAfter.ToString('u'))"
Write-Host "Timestamp: $($sig.TimeStamperCertificate.Subject)"
Write-Host "TS expires: $($sig.TimeStamperCertificate.NotAfter.ToString('u'))"
- name: Stage final release artifact
shell: pwsh
env:
VERSION_TAG: ${{ needs.build-x64.outputs.version_tag }}
# Final artifact is named with the canonical "hello-agent-<tag>-x86_64.exe"
# form so it lands in MDM / release storage with a stable, versioned
# filename rather than a generic "hello-agent.exe".
run: |
if (-not $env:VERSION_TAG) { throw "build-x64 did not export version_tag" }
New-Item -ItemType Directory -Force -Path .\SignOutput | Out-Null
$final = "hello-agent-$env:VERSION_TAG-x86_64.exe"
Copy-Item -Force signed\hello-agent.exe ".\SignOutput\$final"
$h = Get-FileHash ".\SignOutput\$final" -Algorithm SHA256
$hash = $h.Hash.ToLower()
# `<hash> <filename>\n` is the standard `sha256sum -c` format —
# lowercase hex, two spaces, filename, LF terminator. Out-File
# would write CRLF; WriteAllText with explicit "`n" matches what
# GNU coreutils emits, so `sha256sum -c` works without complaint.
[IO.File]::WriteAllText(
"$PWD\SignOutput\$final.sha256",
"$hash $final`n")
Write-Host "::notice title=Release SHA-256::$hash $final"
- name: Upload final release artifact
uses: actions/upload-artifact@v3
with:
name: hello-agent-windows-x64-${{ github.sha }}
path: SignOutput/hello-agent-*.exe*
if-no-files-found: error
retention-days: 90