How to code sign Windows installers with an EV cert on GitHub Actions

How to code sign Windows installers with an EV cert on GitHub Actions

This post walks through what it takes to code sign Windows installers in the cloud with an Extended Validation (EV) cert.

This process is surprisingly difficult and not well documented. It’s enough of a problem that I cold-emailed the CEO of GitHub about the difficulty of building deployable Windows apps on GitHub infrastructure and ended up speaking with several helpful members of the GitHub Actions team about improving the process and documentation.

Until then, come navigate this disastrous-and-just-barely-functional world of enterprise code signing with me.

Why Bother?

Your installer will throw up a Malware warning on Windows by default.

That’s a bad thing. Installation is the start of the user’s first experience with your product. Adding friction there is unfriendly — especially for less technical users, who might not know what action to take. (Microsoft adding this much friction to the code signing process is also unfriendly, but hey!)

To resolve this friction, you’ll need to cough up and pay the certificate mafia a yearly fee for an “Extended Validation” (EV) code signing certificate.

No, you don’t want the (cheaper) OV cert. That’s a pain in the neck and will still throw up Malware warnings under circumstances you can’t control — especially while getting started as a business. Only the EV cert solves the issue completely.

What’s the damage?

On macOS, you pay a flat rate to Apple to have a dev account — a code signing cert and service is just one of the many benefits. Plus, you can store that cert itself as a secret in CI. Life is easy.

On Windows, it’s nasty:

  1. You need a EV cert from a third party issuer (not Microsoft). You’ll have the absolute pleasure of selecting which one of the cabal of slow moving, expensive enterprise companies (sporting websites looking like they were made in 1999) you’d like your soul crushed by.
  2. It’ll cost you to interact with these cert issuers, both in time and money. I paid 709 EUR for a 3 year cert.
  3. The “old school” method is still dominant: Certs are deployed on a physical dongle, referred to as a Hardware Security Module (HSM). That’s pretty incompatible with modern development workflows that use CI… which is the motivation for this blog post.
  4. Luckily, the industry is in transition (read: 🐢🐢🤑) and both Google and Azure offer “cloud HSM” services. So…. Microsoft won’t offer you the cert, but they’ll host it. And you’ll need to pay a bit ($5/mo) for opportunity to store your cert on a cloud HSM.

We’re going to use Azure to store the key and GitHub Actions to do the signing. If you just want to fast forward to the end result, check out my Pamplejuce template on GitHub.

Prefer Google as your corporate overlord of choice? Apparently someone made a GitHub Action for Google KMS.

Prefer Amazon? Sorry, they don’t support OV/EV storage at this time.

Step 1: Get the EV cert process started

I went with GlobalSign (no particular endorsement) because they advertise and have explicit support for using Azure as certificate storage.

It cost me 709 EUR for a 3 year cert. I’m hoping to set-it-and-forget-it for 3 years, because despite how much fun I’m having, I don’t want this to be an annual event.

The vetting process takes a while (weeks). It can take longer (months) if you reside in a smaller country and have documents (business license, proof of address, ID) that require notarized translations. My German documents were accepted as-is, and obviously English documents are fine, too.

Regardless, place your order and get started early. They might want things from you like a letter from your accountant, snail mail to confirm your address, etc.

Step 2: Sign up with Azure & create an Azure Key Vault

Azure offers a “Premium” HSM product as a part of Azure Key Vault.

I haven’t used Azure before this. It looks a lot like Amazon AWS — wayyy too many services with wayyyy too much config and wayyyyy too much admin. (People now have full time jobs specializing and getting certified in these platforms!)

After you sign up, go ahead and create a Key Vault. There are two flavors of Vaults. We’ll need the “Premium” HSM Key Vault which lets you store the fancy third party certs. It’s currently $5/mo. They sometimes have promotions going on that offer free service for a year, etc.

Note your key vault name, the subscription-id, the resource-group and the uhhh… the Key Vault’s https URI (🤷‍♂️ don’t ask). We’ll need that all later.

Step 3: Create a CSR and generate your cert

Once you are vetted and able to generate a certificate by your favorite Third Party Corporate Vulture:

1. Create a Certificate Signing Request (CSR) (on Azure)
2. Use the CSR to generate the actual code signing cert (with your third party)
3. Merge the certs (on Azure)

Here’s GlobalSign’s (of course slightly out of date) instructions.

This step is actually pretty quick, but given the amount of waiting that happens before you get your cert, it feels like the most important step.

Note the certificate name you used on Azure — you’ll need it for the signing step later.

Step 4: Setup an Azure “Application”

Install the Azure CLI locally. Assuming macOS, you can brew install azure-cli.

This will pop open a browser window to authenticate you:

az login

Next, ask your local wizard for the precise incantation and/or decipher this cryptic disaster. You’ll need the subscription-id (it’s a uuid) and resource-group (it’s a name) from Azure.

az ad sp create-for-rbac --name "myApp" --role contributor --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} --sdk-auth

myApp is arbitrary. It’s the name of the Azure “Application” you are creating which will do the signing. You could call it “GitHub” or whatever you call your CI workflow.

You’ll get back something magnificent:

  {
    "clientId": "{uuid}",
    "clientSecret": "{uuid}",
    "subscriptionId": "{uuid}",
    "tenantId": "{uuid}",
  }

Make sure to save this entire awkward json result somewhere safe for now. You’ll need to reference it.

Step 5: Add permissions to your Azure client

Use the clientId uuid you just got and the key vault name to give your client the appropriate permissions. You can also do this through the UI if you like.

az keyvault set-policy -n {keyVaultName} --key-permissions sign --spn {clientId}

Disregard the garbage output. Toss it out with your indie integrity. Er, you can manually inspect the output json to check to be sure the actual permissions were added.

Oh, but don’t forget: We need to actually get the cert before we can sign with it:

keyvault set-policy -n plugins --certificate-permissions get --spn {clientId}

You can verify all went well on the website.

Navigate to your Key Vault. Under “Settings” click “Access Policies”. You should see an “Application” with the following policies in place:

Sign and Get. Get and Sign.

Step 6: Package things up

Do you have something to actually sign yet?

InnoSetup is what many people package Windows installers with.

It is on the list of pre-installed software on GitHub Action’s windows-latest (Windows Server 2022) environment. So on GitHub you don’t need to install anything to create an installer that installs things.

Yes, msix is a thing. If you can figure out how to do that nicely with a few lines of easy to read config, let me know! It looked like an unfriendly birds nest of xml and json to me. 🪹🍝

Step 7: Sign with Azure SignTool

Things get a bit hairy now.

Azure provides docs around accessing keys from GitHub Actions, however they reference a now-deprecated Azure-official GitHub Action which leads to some dead end suggesting to “pass a custom script” to the more general Azure CLI Action.

Corporate dysfunction aside, our goal is to use something something like signtool (Window’s code signing program) to sign things with the cert.

Enter Azure SignTool.

This (somewhat confusingly) is an unofficial project run by a GitHub employee, but referenced by official Microsoft documentation as The Way to Sign with Azure Key Vault and The Way To Use Key Vault for GitHub Actions Secrets. However those are only pieces of the puzzle and contain some issues, so I recommend following this flow:

Add AzureSignTool as a step in CI like so:

dotnet tool install --global AzureSignTool 

Note that you don’t need to run the az tool to login, like you might see in other guides.

Ready for more magic incantations?🪄

You’ll run this command to actually sign your installer:

AzureSignTool sign -kvu "${{ secrets.AZURE_KEY_VAULT_URI }}" -kvi "${{ secrets.AZURE_CLIENT_ID }}" -kvt "${{ secrets.AZURE_TENANT_ID }}"-kvs "${{ secrets.AZURE_CLIENT_SECRET }}" -kvc ${{ secrets.AZURE_CERT_NAME }} -tr http://timestamp.digicert.com -v yourInstaller.exe

This means adding the following 5 secrets to your GitHub repo:

AZURE_KEY_VAULT_URI For unknown reasons, this is an https URI? It is located on your key vault page
AZURE_CLIENT_ID (uuid from the awkward json result we had above)
AZURE_CLIENT_SECRET(string from the awkward json)
AZURE_CERT_NAME This is just the name of your cert on Azure, you can grab it from the UI. Note that the official Microsoft docs for pipeline signing call this “AzureKeyVaultName” which is terrible — both the key vault and the certificate have names. You’ll want to pass the certificate name to -kvc.
AZURE_TENANT_ID (uuid from awkward json)

The 5 secrets to uhh… Azure-ing your success in CI

You did it!

This is what the final result might look like on GitHub Actions:

    - name: Generate Installer and Sign with EV cert on Azure (Windows)
      if: ${{ matrix.name == 'Windows' }}
      shell: bash
      run: |
        iscc "packaging\installer.iss"
        mv "packaging/Output/${{ env.PRODUCT_NAME }}.exe" ${{ env.ARTIFACTS_PATH }}/
        dotnet tool install --global AzureSignTool 
        AzureSignTool sign -kvu "${{ secrets.AZURE_KEY_VAULT_URI }}" -kvi "${{ secrets.AZURE_CLIENT_ID }}" -kvt "${{ secrets.AZURE_TENANT_ID }}" -kvs "${{ secrets.AZURE_CLIENT_SECRET }}" -kvc ${{ secrets.AZURE_CERT_NAME }} -tr http://timestamp.digicert.com -v "${{ env.ARTIFACTS_PATH }}/${{ env.PRODUCT_NAME }}.exe"

You can check Pamplejuce (JUCE plugin template) repo which runs this action on every build.

Troubleshooting: Azure Permissions

If you get far enough along that you are having permission issues — congratulations! So close!

Luckily the az error output is actually pretty decent! For example, I was missing the “certificate get permission” and got the error does not have certificates get permission on key vault.

Troubleshooting: A certificate with (name/id) *** was not found in this key vault.

I ran into this one. Make sure you are passing your certificate name, not your key vault name as the value to -kvc.


Comments

  1. Anna K Shipman Avatar

    I think you can get it at much cheaper price at https://signmycode.com/sectigo-ev-code-signing . This is also supports using Azure as Certificate Storage. (Disclaimer : I own this site!).

    1. sudara Avatar

      Thanks for sharing. $600/3 years is a bit nicer than the $700 at GlobalSign, but on the page you linked it says “Validation Type: OV” — Is that a typo? Users will still see the malware warning with an OV cert…

      Do you have docs anywhere for how to do the CSR dance with Azure or other providers?

Leave a Reply

Your email address will not be published.