Skip to content

Windows: Authenticode-sign DisplayXRSetup-*.exe (parallel to macOS #280) #281

@dfattal

Description

@dfattal

Parallel to #280 (macOS code-signing + notarization). Today's Windows installer (DisplayXRSetup-X.Y.Z.NNN.exe, built by installer/DisplayXRInstaller.nsi) ships unsigned. Users get Microsoft SmartScreen's "Windows protected your PC — unrecognized app" prompt on double-click and need "More info" → "Run anyway" to proceed. installer /S (silent install from a script with admin context) works regardless of signing.

This issue tracks Authenticode signing + (eventually) SmartScreen reputation to ship a clickable installer.

Prerequisites (not in repo today)

  1. Code Signing certificate from a Microsoft-trusted CA. Two tiers, both viable:

    • Standard Code Signing ($200-500/yr — DigiCert, Sectigo, SSL.com). P12 file, easy CI import. SmartScreen still warns until the cert builds "reputation" (~3000+ download-and-runs over weeks-to-months). Useful as a stepping stone.
    • EV (Extended Validation) Code Signing ($400-700/yr — same CAs). Reputation pre-baked: clean run from the first signed binary. Requires hardware token (HSM) by default; for CI use one of:
      • Azure Key Vault with a code-signing certificate (Microsoft's recommended modern path; SignTool 6.3+ supports -fd sha256 -dlib AzureSignTool.dll)
      • DigiCert KeyLocker (their cloud HSM service)
      • SSL.com eSigner CSC (similar)
        Locally-held tokens don't work from CI without a self-hosted runner.
  2. GitHub Actions secrets (depending on which path):

    • Standard cert path: WINDOWS_CERT_P12 (base64-encoded), WINDOWS_CERT_PASSWORD
    • Azure Key Vault path: AZURE_KEY_VAULT_URI, AZURE_CERT_NAME, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID (plus AzureSignTool installed via dotnet tool install)
  3. Forks: same as macOS — gracefully fall back to unsigned builds on fork PRs.

Implementation sketch — Standard cert path (simpler, ships unsigned-with-warning-until-reputation)

- name: Import code signing cert
  if: needs.DetectChanges.outputs.docs_only != 'true' && github.event_name != 'pull_request'
  shell: pwsh
  env:
    CERT_P12: ${{ secrets.WINDOWS_CERT_P12 }}
    CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
  run: |
    $bytes = [Convert]::FromBase64String($env:CERT_P12)
    $certPath = "$env:RUNNER_TEMP\cert.p12"
    [IO.File]::WriteAllBytes($certPath, $bytes)
    $pwd = ConvertTo-SecureString -String $env:CERT_PASSWORD -AsPlainText -Force
    Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password $pwd
    Remove-Item $certPath

# (NSIS produces _package\DisplayXRSetup-*.exe unsigned here.)

- name: Sign installer + dependent binaries
  if: needs.DetectChanges.outputs.docs_only != 'true' && github.event_name != 'pull_request'
  shell: pwsh
  run: |
    $signtool = "$env:ProgramFiles(x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe"
    # Sign every .exe / .dll inside _package, then the installer itself.
    # Microsoft recommends SHA-256 + RFC3161 timestamping.
    Get-ChildItem -Recurse _package -Include *.exe,*.dll | ForEach-Object {
      & $signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /a $_.FullName
    }

Implementation sketch — Azure Key Vault / EV path

- name: Install AzureSignTool
  if: needs.DetectChanges.outputs.docs_only != 'true' && github.event_name != 'pull_request'
  run: dotnet tool install --global AzureSignTool

- name: Sign with EV cert from Azure Key Vault
  if: needs.DetectChanges.outputs.docs_only != 'true' && github.event_name != 'pull_request'
  shell: pwsh
  env:
    AKV_URI: ${{ secrets.AZURE_KEY_VAULT_URI }}
    AKV_CERT: ${{ secrets.AZURE_CERT_NAME }}
    AKV_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
    AKV_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
    AKV_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
  run: |
    Get-ChildItem -Recurse _package -Include *.exe,*.dll | ForEach-Object {
      AzureSignTool sign `
        --azure-key-vault-url "$env:AKV_URI" `
        --azure-key-vault-client-id "$env:AKV_CLIENT_ID" `
        --azure-key-vault-tenant-id "$env:AKV_TENANT_ID" `
        --azure-key-vault-client-secret "$env:AKV_CLIENT_SECRET" `
        --azure-key-vault-certificate "$env:AKV_CERT" `
        --file-digest sha256 --timestamp-rfc3161 http://timestamp.digicert.com `
        $_.FullName
    }

What to sign

NSIS installers wrap their content; signing only DisplayXRSetup-*.exe is not sufficient — the unpacked DLLs/EXEs that ship inside trigger SmartScreen warnings when the user's apps load them. Sign:

  • _package\bin\DisplayXRClient.dll (runtime)
  • _package\bin\displayxr-service.exe
  • _package\bin\displayxr-cli.exe (if shipped)
  • _package\bin\plugins\DisplayXR-SimDisplay.dll
  • The NSIS installer's DisplayXRSetup-*.exe itself
  • (Future) Leia SR plug-in DLL — signed independently by the LeiaSR installer pipeline

Acceptance

  • Code Signing cert procured (standard or EV — decide based on launch timeline)
  • GH Actions secrets populated
  • CI run from main signs every shipped .exe + .dll and the NSIS installer
  • signtool verify /pa _package\DisplayXRSetup-*.exe returns Successfully verified
  • (EV cert) Double-clicking the installer on a clean Windows shows no SmartScreen warning
  • (Standard cert) Warning still shown initially; track reputation build-up
  • Fork PRs continue to produce unsigned binaries and don't fail the workflow
  • CLAUDE.md + docs/getting-started/building.md updated to drop the "More info → Run anyway" workaround note

When to schedule

Same trigger as #280: when the audience shifts from developers (who tolerate the warning) to end users. The macOS .pkg and Windows .exe are roughly symmetric — sign them together so users on either platform see the same UX, not one polished and one warning-y.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions