Skip to content

Commit da4a4bb

Browse files
Add opt-in Windows Authenticode signing via Azure Trusted Signing
Windows .exe and .msi artifacts are currently only Sigstore-signed, which does not satisfy Windows SmartScreen — so downloaded connector executables (e.g. baton-active-directory) trigger SmartScreen warnings (CE-424). Add Authenticode signing to the goreleaser-windows job using Azure Trusted Signing (CE-178), gated behind a new windows_authenticode_signing input (default false) so existing releases are unaffected until the Azure account is provisioned. When enabled, the job: - logs in to Azure via GitHub Actions OIDC (no certificate material in secrets) - installs the Microsoft `sign` CLI - signs the raw .exe in a GoReleaser build post-hook, before it is packaged, so the binary in both the .zip and the .msi is signed - signs the .msi in place via a GoReleaser signs entry ordered ahead of the cosign signature, so the Sigstore bundle and all downstream hashes cover the Authenticode-signed bytes Signing logic lives in scripts/sign-windows-authenticode.ps1, which calls `sign code trusted-signing` and verifies the result. New optional inputs (trusted_signing_*) and secrets (AZURE_CLIENT_ID/TENANT_ID/SUBSCRIPTION_ID) are validated up front. README and release-workflow docs updated. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
1 parent f73a104 commit da4a4bb

6 files changed

Lines changed: 347 additions & 17 deletions

File tree

.github/workflows/release.yaml

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,26 @@ on:
5252
type: string
5353
default: ""
5454
description: "Path to a custom WXS file in the caller repo for MSI generation (relative to repo root). If not provided, uses default template."
55+
windows_authenticode_signing:
56+
required: false
57+
type: boolean
58+
default: false
59+
description: "Whether to Authenticode-sign Windows .exe and .msi artifacts with Azure Trusted Signing. When true, the AZURE_CLIENT_ID/AZURE_TENANT_ID/AZURE_SUBSCRIPTION_ID secrets and the trusted_signing_* inputs are required."
60+
trusted_signing_endpoint:
61+
required: false
62+
type: string
63+
default: ""
64+
description: "Azure Trusted Signing region endpoint (e.g. https://wus2.codesigning.azure.net/). Required when windows_authenticode_signing is true."
65+
trusted_signing_account_name:
66+
required: false
67+
type: string
68+
default: ""
69+
description: "Azure Trusted Signing account name. Required when windows_authenticode_signing is true."
70+
trusted_signing_certificate_profile:
71+
required: false
72+
type: string
73+
default: ""
74+
description: "Azure Trusted Signing certificate profile name. Required when windows_authenticode_signing is true."
5575
secrets:
5676
RELENG_GITHUB_TOKEN:
5777
required: true
@@ -68,6 +88,15 @@ on:
6888
GORELEASER_PRO_KEY:
6989
required: false
7090
description: "GoReleaser Pro license key for MSI builds. Required when msi is true."
91+
AZURE_CLIENT_ID:
92+
required: false
93+
description: "Client ID of the Azure AD app used for Trusted Signing OIDC login. Required when windows_authenticode_signing is true."
94+
AZURE_TENANT_ID:
95+
required: false
96+
description: "Azure AD tenant ID for Trusted Signing OIDC login. Required when windows_authenticode_signing is true."
97+
AZURE_SUBSCRIPTION_ID:
98+
required: false
99+
description: "Azure subscription ID hosting the Trusted Signing account. Required when windows_authenticode_signing is true."
71100

72101
env:
73102
CDN_BASE_URL: "https://dist.conductorone.com"
@@ -164,6 +193,53 @@ jobs:
164193
exit 1
165194
fi
166195
196+
- name: Validate Authenticode signing configuration
197+
if: inputs.windows_authenticode_signing == true
198+
env:
199+
HAS_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID != '' }}
200+
HAS_TENANT_ID: ${{ secrets.AZURE_TENANT_ID != '' }}
201+
HAS_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID != '' }}
202+
TS_ENDPOINT: ${{ inputs.trusted_signing_endpoint }}
203+
TS_ACCOUNT: ${{ inputs.trusted_signing_account_name }}
204+
TS_PROFILE: ${{ inputs.trusted_signing_certificate_profile }}
205+
MSI_ENABLED: ${{ inputs.msi }}
206+
run: |
207+
if [ "$MSI_ENABLED" != "true" ]; then
208+
echo "::error::windows_authenticode_signing requires msi to be true (the Windows build job is gated on msi)"
209+
exit 1
210+
fi
211+
212+
missing=0
213+
require_secret() {
214+
if [ "$2" != "true" ]; then
215+
echo "::error::$1 secret is required when windows_authenticode_signing is true"
216+
missing=1
217+
fi
218+
}
219+
require_input() {
220+
if [ -z "$2" ]; then
221+
echo "::error::$1 input is required when windows_authenticode_signing is true"
222+
missing=1
223+
fi
224+
}
225+
226+
require_secret "AZURE_CLIENT_ID" "$HAS_CLIENT_ID"
227+
require_secret "AZURE_TENANT_ID" "$HAS_TENANT_ID"
228+
require_secret "AZURE_SUBSCRIPTION_ID" "$HAS_SUBSCRIPTION_ID"
229+
require_input "trusted_signing_endpoint" "$TS_ENDPOINT"
230+
require_input "trusted_signing_account_name" "$TS_ACCOUNT"
231+
require_input "trusted_signing_certificate_profile" "$TS_PROFILE"
232+
233+
if [ -n "$TS_ENDPOINT" ] && [[ ! "$TS_ENDPOINT" =~ ^https://[A-Za-z0-9.-]+/?$ ]]; then
234+
echo "::error::trusted_signing_endpoint must be an https URL (e.g. https://wus2.codesigning.azure.net/). Got: $TS_ENDPOINT"
235+
missing=1
236+
fi
237+
238+
if [ "$missing" -ne 0 ]; then
239+
exit 1
240+
fi
241+
echo "✅ Authenticode signing configuration valid"
242+
167243
determine-workflows-ref:
168244
needs: validate-inputs
169245
runs-on: ubuntu-latest
@@ -589,15 +665,54 @@ jobs:
589665
WXS_PATH: ${{ steps.wxs.outputs.wxs_path }}
590666
WORKFLOWS_REF: ${{ needs.determine-workflows-ref.outputs.ref }}
591667
RELEASE_TAG: ${{ inputs.tag }}
668+
AUTHENTICODE: ${{ inputs.windows_authenticode_signing }}
592669
run: |
593670
export BUILD_STARTED_ON=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
594671
672+
# Authenticode signing is opt-in. When enabled, inject two blocks into
673+
# the GoReleaser config (same envsubst block pattern used for Docker
674+
# extra_files), so the template stays valid YAML when signing is off:
675+
# - WINDOWS_BUILD_SIGN_HOOK: a build post-hook that signs the raw
676+
# .exe BEFORE it is packaged into the zip and the MSI.
677+
# - WINDOWS_AUTHENTICODE_MSI_SIGN: a `signs` entry that signs the
678+
# .msi in place, ordered ahead of the cosign signature so the
679+
# Sigstore bundle covers the Authenticode-signed bytes.
680+
export WINDOWS_BUILD_SIGN_HOOK=""
681+
export WINDOWS_AUTHENTICODE_MSI_SIGN=""
682+
if [ "${AUTHENTICODE}" = "true" ]; then
683+
echo "Authenticode signing enabled: injecting signing config"
684+
SIGN_SCRIPT="../_workflows/scripts/sign-windows-authenticode.ps1"
685+
686+
WINDOWS_BUILD_SIGN_HOOK=$(printf '\n hooks:\n post:\n - cmd: pwsh -NoProfile -File %s -Path "{{ .Path }}"\n output: true' "$SIGN_SCRIPT")
687+
688+
WINDOWS_AUTHENTICODE_MSI_SIGN=$(printf '\n - id: authenticode-msi\n cmd: pwsh\n artifacts: installer\n ids:\n - windows-msi\n signature: "{{ .Env.artifact }}.authenticode.json"\n output: true\n args:\n - "-NoProfile"\n - "-File"\n - "%s"\n - "-Path"\n - "{{ .Env.artifact }}"\n - "-EmitMarker"' "$SIGN_SCRIPT")
689+
fi
690+
595691
# Generate GoReleaser config
596692
envsubst < templates/.goreleaser-windows-template.yaml.tmpl | tee "_generated/.goreleaser.windows.yaml"
597693
598694
# Generate provenance predicate
599695
envsubst < templates/.slsa-provenance-predicate-template.json.tmpl | tee "_generated/predicate.json"
600696
697+
- name: Azure login for Trusted Signing (OIDC)
698+
if: inputs.windows_authenticode_signing == true
699+
uses: azure/login@v2
700+
with:
701+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
702+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
703+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
704+
705+
- name: Install Microsoft sign tool
706+
if: inputs.windows_authenticode_signing == true
707+
shell: pwsh
708+
run: |
709+
# The `sign` tool currently ships pre-release only. Pin a specific
710+
# version here once validated against the provisioned Trusted Signing
711+
# account for reproducible, supply-chain-stable signing.
712+
dotnet tool install --global sign --prerelease
713+
# Surface the global tools directory for subsequent steps / hooks.
714+
"$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
715+
601716
- name: Run GoReleaser for Windows
602717
uses: goreleaser/goreleaser-action@v6
603718
with:
@@ -608,6 +723,11 @@ jobs:
608723
env:
609724
GITHUB_TOKEN: ${{ secrets.RELENG_GITHUB_TOKEN }}
610725
GORELEASER_KEY: ${{ secrets.GORELEASER_PRO_KEY }}
726+
# Consumed by scripts/sign-windows-authenticode.ps1 (invoked from the
727+
# build post-hook and the MSI signs entry). Empty when signing is off.
728+
TRUSTED_SIGNING_ENDPOINT: ${{ inputs.trusted_signing_endpoint }}
729+
TRUSTED_SIGNING_ACCOUNT_NAME: ${{ inputs.trusted_signing_account_name }}
730+
TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ inputs.trusted_signing_certificate_profile }}
611731

612732
- name: Flatten MSI directory structure
613733
shell: pwsh

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ The release workflow accepts the following input parameters:
8282
| `docker_extra_files` | No | `""` | Comma-separated list of extra files/dirs to include in Docker build context |
8383
| `msi` | No | `true` | Whether to build MSI Windows installers |
8484
| `msi_wxs_path` | No | `""` | Path to custom WXS template for MSI installer (uses default if not set) |
85+
| `windows_authenticode_signing` | No | `false` | Authenticode-sign Windows `.exe` and `.msi` via Azure Trusted Signing (requires the `trusted_signing_*` inputs and `AZURE_*` secrets) |
86+
| `trusted_signing_endpoint` | No | `""` | Azure Trusted Signing region endpoint (e.g. `https://wus2.codesigning.azure.net/`); required when `windows_authenticode_signing: true` |
87+
| `trusted_signing_account_name` | No | `""` | Azure Trusted Signing account name; required when `windows_authenticode_signing: true` |
88+
| `trusted_signing_certificate_profile` | No | `""` | Azure Trusted Signing certificate profile name; required when `windows_authenticode_signing: true` |
8589

8690
2. Ensure your repository has the following secrets configured:
8791

@@ -92,6 +96,7 @@ The release workflow accepts the following input parameters:
9296
- `AC_PROVIDER`: Apple Connect provider
9397
- `DATADOG_API_KEY`: Datadog API key for monitoring releases
9498
- `GORELEASER_PRO_KEY`: GoReleaser Pro license key (required when `msi: true`, the default)
99+
- `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_SUBSCRIPTION_ID`: Azure OIDC identity for Trusted Signing (required only when `windows_authenticode_signing: true`)
95100

96101
3. Remove all GoReleaser, gon files, Dockerfile, and Dockerfile.lambda files from your connector repository, if they were previously created there.
97102

@@ -175,6 +180,37 @@ To disable MSI builds entirely (e.g., for connectors that don't need Windows ins
175180

176181
When `msi: false`, the `GORELEASER_PRO_KEY` secret is not required.
177182

183+
### Windows Authenticode Signing
184+
185+
Sigstore (cosign) signatures prove provenance but do not satisfy Windows
186+
SmartScreen. To eliminate SmartScreen warnings on the `.exe` and `.msi`, enable
187+
Authenticode signing via [Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/):
188+
189+
```yaml
190+
jobs:
191+
release:
192+
uses: ConductorOne/github-workflows/.github/workflows/release.yaml@v4
193+
with:
194+
tag: ${{ github.ref_name }}
195+
windows_authenticode_signing: true
196+
trusted_signing_endpoint: https://wus2.codesigning.azure.net/
197+
trusted_signing_account_name: conductorone-signing
198+
trusted_signing_certificate_profile: conductorone
199+
secrets:
200+
# ... existing secrets ...
201+
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
202+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
203+
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
204+
```
205+
206+
This is **opt-in** (default `false`) so connectors keep releasing unchanged
207+
until the Azure Trusted Signing account is provisioned. Authentication uses
208+
GitHub Actions OIDC — no signing certificate is stored in repository secrets.
209+
The `.exe` is signed before it is packaged, so both the standalone binary (in
210+
the `.zip`) and the copy inside the `.msi` carry the signature. See the
211+
[release workflow docs](docs/release-workflow.md#windows-authenticode-signing-azure-trusted-signing)
212+
for Azure setup and verification details.
213+
178214
## Verify Workflow
179215

180216
Runs linting, tests, and optional regression verification. See [detailed documentation](docs/verify-workflow.md) for jobs, regression testing, and all options.

cmd/generate-windows-manifest/main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ func main() {
9393
}
9494

9595
// MSI uses key "windows-amd64-msi"
96-
// MSI has cosign signatures and attestations; Azure Trusted Signing (Windows code signing) planned for Stage 2
96+
// MSI always has cosign signatures and attestations; Windows Authenticode
97+
// signing (Azure Trusted Signing) is applied in-place earlier in the
98+
// pipeline when windows_authenticode_signing is enabled, so the hash here
99+
// covers the Authenticode-signed bytes.
97100
assets["windows-amd64-msi"] = asset
98101
fmt.Fprintf(os.Stderr, "✅ Added MSI asset: windows-amd64-msi -> %s\n", filename)
99102
}

docs/release-workflow.md

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Builds Windows zip and MSI installer:
5656
- MSI built using WiX Toolset with GoReleaser Pro
5757
- Deterministic UpgradeCode via UUID v5 from repository name
5858
- Supports custom WXS templates via `msi_wxs_path` input
59+
- Optionally Authenticode-signs the `.exe` and `.msi` with Azure Trusted Signing
60+
(opt-in via `windows_authenticode_signing`; see Security Properties)
5961
- Generates SBOMs and SLSA v1 provenance attestations
6062
- Uploads all artifacts to S3 with no-overwrite writes
6163

@@ -167,7 +169,55 @@ Both Windows zip and MSI have:
167169
- SLSA provenance attestations
168170
- SBOM attestations
169171

170-
**Note:** Windows code signing via Azure Trusted Signing is planned for Stage 2.
172+
### Windows Authenticode Signing (Azure Trusted Signing)
173+
174+
Sigstore signatures prove supply-chain provenance but are not recognized by
175+
Windows SmartScreen. To remove SmartScreen warnings, the workflow can also
176+
Authenticode-sign Windows artifacts with **Azure Trusted Signing**. This is
177+
**opt-in** via the `windows_authenticode_signing` input (default `false`), so
178+
releases continue to work unchanged until the Azure account is provisioned.
179+
180+
When enabled, the `goreleaser-windows` job:
181+
182+
1. Logs in to Azure via GitHub Actions OIDC (`azure/login`) — no long-lived
183+
certificate material is stored in secrets.
184+
2. Installs the Microsoft [`sign`](https://github.com/dotnet/sign) CLI.
185+
3. Signs the raw `.exe` in a GoReleaser **build post-hook**, before it is
186+
packaged — so the binary inside both the `.zip` and the `.msi` is signed.
187+
4. Signs the `.msi` in place via a GoReleaser `signs` entry that is ordered
188+
**ahead of** the cosign signature, so the Sigstore `.sig`/`.cert` and all
189+
downstream hashes/attestations cover the Authenticode-signed bytes.
190+
191+
All signing is delegated to `scripts/sign-windows-authenticode.ps1`, which calls
192+
`sign code trusted-signing` and then verifies the result with
193+
`Get-AuthenticodeSignature`.
194+
195+
Because Azure Trusted Signing issues short-lived certificates chained to a
196+
Microsoft-managed root, SmartScreen reputation is immediate — unlike standard OV
197+
certificates, which must accrue reputation through download volume.
198+
199+
**Required configuration when `windows_authenticode_signing: true`:**
200+
201+
| Kind | Name | Description |
202+
|-|-|-|
203+
| input | `trusted_signing_endpoint` | Region endpoint, e.g. `https://wus2.codesigning.azure.net/` |
204+
| input | `trusted_signing_account_name` | Trusted Signing account name |
205+
| input | `trusted_signing_certificate_profile` | Certificate profile name |
206+
| secret | `AZURE_CLIENT_ID` | App registration (federated credential) client ID |
207+
| secret | `AZURE_TENANT_ID` | Azure AD tenant ID |
208+
| secret | `AZURE_SUBSCRIPTION_ID` | Subscription hosting the Trusted Signing account |
209+
210+
The federated credential on the Azure AD app must trust the connector
211+
repository's GitHub OIDC subject, and the identity must hold the **Trusted
212+
Signing Certificate Profile Signer** role on the account.
213+
214+
**Verification** (on a Windows machine):
215+
216+
```powershell
217+
Get-AuthenticodeSignature .\baton-foo_v1.0.0_windows_amd64.msi | Format-List
218+
# Status should be 'Valid'; SignerCertificate should chain to the Microsoft root.
219+
signtool verify /pa /v .\baton-foo_v1.0.0_windows_amd64.msi
220+
```
171221

172222
### Verification
173223

@@ -335,19 +385,23 @@ Test the MSI installer on an actual Windows machine:
335385

336386
## Future Work
337387

338-
### Stage 2: Windows Code Signing
339-
340-
Currently MSI installers have Sigstore signatures (cosign) but not Windows Authenticode signatures. Stage 2 will add:
341-
342-
- **Azure Trusted Signing** integration for Authenticode signatures
343-
- MSI files will be signed with Microsoft-trusted certificate
344-
- Windows SmartScreen warnings will be eliminated
345-
- Users can verify publisher identity in Windows UAC prompts
346-
347-
This requires:
348-
- Azure Trusted Signing account setup
349-
- GitHub Actions OIDC integration with Azure
350-
- Workflow updates to sign MSI after build
388+
### Windows Code Signing (implemented, opt-in)
389+
390+
Windows Authenticode signing via **Azure Trusted Signing** is implemented and
391+
gated behind the `windows_authenticode_signing` input (see
392+
[Windows Authenticode Signing](#windows-authenticode-signing-azure-trusted-signing)).
393+
It remains off by default pending Azure Trusted Signing account provisioning
394+
(tracked in CE-178). To roll it out:
395+
396+
1. Provision an Azure Trusted Signing account and certificate profile.
397+
2. Create an Azure AD app with a federated credential trusting the connector
398+
repos' GitHub OIDC subjects, and grant it the **Trusted Signing Certificate
399+
Profile Signer** role.
400+
3. Add the `AZURE_CLIENT_ID` / `AZURE_TENANT_ID` / `AZURE_SUBSCRIPTION_ID`
401+
secrets and set `windows_authenticode_signing: true` plus the
402+
`trusted_signing_*` inputs in each connector's release workflow.
403+
4. Validate on a test connector per [Testing Changes](#testing-changes) and
404+
confirm `Get-AuthenticodeSignature` reports `Valid`.
351405

352406
### Other Potential Improvements
353407

0 commit comments

Comments
 (0)