Signing Windows Software with GitHub Actions, Encrypted Secrets and PowerShell

Code signing your scripts, modules and applications can help increase enterprise adoption, as large organizations often prohibit running unsigned tools on Windows. Signed code allows machines to verify that what they're running hasn't been altered or compromised by a third party, which ultimately increases their confidence and trust in your module.

As a PowerShell developer who makes tools for platforms like SQL Server that mostly run in enterprises, this was especially important to me to figure out. Microsoft Authenticode is available to sign apps, libraries, drivers, scripts and more, but this post will focus mostly on signing PowerShell modules but will also help devs who want to sign their executables and dlls.

So how does it work?

Imagine a certified notary verifies your identity then gives you an official stamp to sign your letters ✍🏽. That's sorta what code signing is like, but PKI/certificates are used instead of stamps and certificate authorities (CAs) instead of notaries.

When using PowerShell scripts and modules, the execution policy will then determine if it'll "check your ID" when the module or script is loaded. If you leave the execution policy set to the default of Restricted, only signed modules and scripts can execute and modules take a bit longer to load as the files are verified. If you have your execution policy set to Bypass, then any module or script can get right on in and loading goes quickly.

When it comes to getting the signing certificate, the same vendors that sell SSL certs for your website will also sell code signing certs, they're just way more expensive and actually require proof of ID.

Initially, I was surprised at the thoroughness of the identification process, which was pretty involved as it required sending a bunch of forms of ID to DigiCert. I chose DigiCert as my cert vendor because they've got a great reputation and they give free Code Signing Certificates to Microsoft MVPs.

If your company has its own CA in its domain (like Active Directory Certificate Services), you can also get a Code Signing cert from your org's security administrator.

More benefits of code signing

When it comes to the benefits of code signing, DigiCert's website actually had a great rundown of benefits, so I'll just repeat most of it here.

  • Prevent security warning labels
    • Your customers expect a smooth and professional installation process when they download your software. Digitally signed programs can avoid warning messages during download and install for better adoption. 
  • Protect your intellectual property
    • Code signing certificates allow customers to verify that your code is authentic and has not been tampered with—protecting both parties against fraud, malware and theft.
  • Efficient monitoring and enforcement
    • When a piece of code is digitally signed, you can easily detect modified files. Additionally, code signed with a timestamp tells a user that the code was signed with a valid certificate even after the actual certificate expires.

Essentially, signed modules make enterprises, security teams and anti-virus software just a little more amenable to using all of your hard work. You'll notice a lot of software from vendors like Microsoft, VMware, Google, and GitHub are all signed. You can see this using both PowerShell and Windows Explorer.

1PS> Get-AuthenticodeSignature -FilePath D:\Downloads\GitHubDesktop.exe
2
3    Directory: D:\Downloads
4
5SignerCertificate                         Status     Path
6-----------------                         ------     ----
7D85ED65B9B0C4DAADE1DD4E6DF474A76DE3929FE  Valid      GitHubDesktop.exe

Or, you can see it in Explorer by going to Properties -> Digital Signatures.

What about Linux and macOS?

Verifying checksums is standard practice for Linux users, but verifying digital signatures for apps doesn't appear as common. Or maybe it is, it's just done through package management? Figuring htis out was harder than I expected. Linux apparently supports code signing, but tools for it like evmctl are not included in the baseline OS, at least not with Ubuntu 20.04.

I attempted to verify with openssl and gpg with no luck. This may be because I didn't want to mess with finding the required public keys -- who does? I even tried with evmctl ima_verify but that didn't work, either. I'm probably missing something.

1ctrlb@ubuntu:~$ evmctl ima_verify /mnt/c/Users/ctrlb/AppData/Local/GitHubDesktop/GitHubDesktop.exe
2getxattr failed: /mnt/c/Users/ctrlb/AppData/Local/GitHubDesktop/GitHubDesktop.exe
3errno: No data available (61)

Later, I ended up finding a Linux-based implementation of Microsoft's signtool called osslsigncode and Java app called Jsign but that's just for signing for Windows-based apps, I think? Oh well, I'm not interested enough to figure this out today.

As far as macOS goes, Apple actually provides Mac developers with a certificate when they sign up ($100/year). That's a brilliant model! I wish Microsoft would do something similar for PowerShell.

The process

Once I knew that I wanted to sign my code via CI/CD (in GitHub specifically), I considered that this would likely be done using a Windows runner and repository secrets. So essentially, I would:

  • Obtain a Code Signing Certificate
  • Add the certificate to a repository secret
  • Use a Windows runner to import the certificate and sign the required files

A Windows runner is required because Set-AuthenticodeSignature only exists on Windows at the moment and I was basically migrating my local script which uses PowerShell to sign the various PowerShell filetypes (*.ps1, *.ps1xml, *.psd1, *.psm1, *.pssc, *.psrc, *.cdxml, *.dll, and sometimes *.exe).

Surprisingly, even to me, I'm not keeping this workflow in the dbatools repository with the rest of the project. This is because repository secrets are available to anyone in the collaborator role and we have a ton of community members in the collaborator role. Perhaps that works for an organization with employees that are bound by a contract, but our organization is an open source community org, and because the code signing cert is such a big deal, it must remain private. The only way to ensure it stays private is to keep it in my own potatoqualitee repo.

In the end, I created a dedicated repository called "release" for keeping the workflows and secrets that publish my PowerShell modules.

Obtaining Code Signing Certificate

While I do qualify for a free certificate, I wanted to assign the certificate to dbatools instead of me, so I ended up needing to pay for it. Thankfully, my awesome friends at Data Masterminds stepped up and paid the big ol fee! They even offered to cover the Extended Validation (EV) Code Signing Certificate but I did not get the EV as it's incompatible with CI/CD unless you run your own GitHub Runner (or maybe not, I'm just guessing).

When purchasing the 3-year code signing certificate for a whopping $699, I selected the option for "Microsoft Authenticode". I was then offered a p7b file but I wanted a PFX file, so I had to install the p7b then export. To install the p7b on my machine, I right-clicked, then selected Install Certificate.

Once the cert was installed on my machine, I could then export the cert and its private key, secured with a password, using the MMC.

To do this, find your cert, then right click -> Export. Make sure you export the private key.

When it comes to the properties, I selected Include all certificates in the certification path, if possible and Enable certificate privacy.

I believe that second checkbox is optional. It basically encrypts the entire certificate instead of just the private key. And finally, I set a long password and chose the stronger encryption.

This password will be required in the CI/CD process, so be sure to take note of it. If you mess up and forget it, you can just re-export the certificate and note the password.

Getting it on GitHub

Once I exported my certificate to a PFX file, I needed to get it into GitHub to make it a secure part of the dbatools CI/CD process.

📝 Continuous Integration/Manual Deployment

My setup should probably be called a CI/MD process because I manually kick off the deployment. I don't set the publish process to kick off automatically with each commit to dev or main on dbatools. Instead, I start the workflow manually using the GitHub CLI after I've double-checked the most recent commits. This is because I like control but also because sometimes, we want to internally test the development branch a bit more before publishing to production

To my knowledge, GitHub's Encrypted Secrets can't be binary files like PFXs or EXEs. But with PowerShell, you can easily encode any binary file into base64 then later decode it back into a file. How cool! Here's basically how:

  • Make it base64 string
  • Add it to a GitHub Secret
  • Grab it in the workflow and turn it into a certificate object.

Previously, I saved my PFX file to C:\temp\dbatools-newer.pfx. In the command below, we'll convert that file to base64, then save it right to the clipboard so that it can be pasted into a GitHub secret.

1# Add to the SIGNCERT secret in GitHub Actions
2[Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\temp\dbatools-newer.pfx")) | Set-Clipboard

Next, I navigated to the Secrets section of my repo at (Top) Settings -> (Sidebar) Secrets -> Actions.

I pasted in the base64 cert and saved it as SIGNCERT. Then I added the password (from the PFX export) to another secret called CERTPASS. Well, in reality I called it something else but for this blog post, I'll clarify and just call it CERTPASS.

Next, I created a workflow in my personal release repository that does a whole bunch of stuff like packaging, signing and publishing.

That release script does a ton, but the part that's important for code signing with PowerShell can be distilled down to the following code:

 1    - name: 📝 Sign PowerShell scripts
 2      env:
 3        CERTPASS: ${{secrets.CERTPASS}}
 4        SIGNCERT: ${{secrets.SIGNCERT}}
 5      run: |
 6        # Create buffer from the BASE64 string of the PFX stored in the secret
 7        $buffer = [System.Convert]::FromBase64String($env:SIGNCERT)
 8        # Create new certificate object from the buffer and the certificate pass
 9        $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::New($buffer, $env:CERTPASS)
10        $null = Get-ChildItem C:\gallery\dbatools -File -Recurse -Include *.ps1, *.ps1xml, *.psd1, *.psm1, *.pssc, *.psrc, *.cdxml | 
11        Set-AuthenticodeSignature -HashAlgorithm SHA256 -Certificate $certificate -TimestampServer http://timestamp.digicert.com        

Here, I accessed the secrets using environmental variables. So I assigned secrets.CERTPASS to $env:CERTPASS and secrets.SIGNCERT to $env:SIGNCERT.

Next, I created a $Certificate object using some .NET code, then I used that $Certificate object for the -Certificate parameter when signing all of the following file types: *.ps1, *.ps1xml, *.psd1, *.psm1, *.pssc, *.psrc, *.cdxml.

⚠️ Make sure you sign the whole module

You may assume that you just need to sign the psd1 or psm1, but all types of PowerShell files must be signed. Initially, I didn't think to sign the ps1xml files in our module and we got a bunch of warnings that it wasn't actually signed.

I tried signing a .txt file and it failed, so I'm assuming these are the only specific types of files can be signed.

Oh, also! If you're interested, you can also do something called catalog signing. I found catalog signing to be much slower (depending on the execution policy) and chose not to do it.

Compiling and signing DLLs

For a while there, I blanked and forgot that Set-AuthenticodeSignature also signs DLLs and EXEs, so I ended up signing the dbatools.dll library with signtool. In the most recent release, however, I used PowerShell and signed it using Set-AuthenticodeSignature instead. That was way easier.

 1# Go compile the DLLs
 2Push-Location .\bin\projects\dbatools
 3dotnet build --configuration Release --no-restore | Out-String -OutVariable build
 4dotnet test --no-restore --verbosity normal | Out-String -OutVariable test
 5Pop-Location
 6
 7# Remove all the SMO directories that the build created -- they are elsewhere in the project
 8Get-ChildItem -Directory .\bin\net462 | Remove-Item -Recurse -Confirm:$false
 9Get-ChildItem -Directory .\bin\netcoreapp3.1 | Remove-Item -Recurse -Confirm:$false
10
11# Remove all the SMO files that the build created -- they are elsewhere in the project
12Get-ChildItem .\bin\netcoreapp3.1 -File -Recurse -Exclude dbatools.* | Remove-Item -Recurse -Confirm:$false
13Get-ChildItem .\bin\net462 -File -Recurse -Exclude dbatools.* | Remove-Item -Recurse -Confirm:$false
14Get-ChildItem .\bin -File -Recurse -Include dbatools.deps.json | Remove-Item -Confirm:$false
15
16# Sign the DLLs, how cool -- Set-AuthenticodeSignature works on DLLs (and presumably Exes) too
17$buffer = [IO.File]::ReadAllBytes(".\dbatools-code-signing-cert.pfx")
18$certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::New($buffer, $password)
19Get-ChildItem dbatools\dbatools.dll -Recurse | Set-AuthenticodeSignature -HashAlgorithm SHA256 -Certificate $certificate -TimestampServer http://timestamp.digicert.com

To prove to myself that signing the DLL actually worked, I verified by right-clicking dbatools.dll, then I went to Properties and clicked on on Digital Signatures.

Feels so grown up and Enterprise-y 🏢 Also, shoutout to Marc-André Moreau for highlighting that previously, I was signing our code with the default algorithm, SHA1. Yuck! I didn't even realize that I could change the HashAlgorithm. So now I've updated the blog post and my scripts as well. And dbatools dlls and files are now signed with a non-deprecated algorithm!

Btw, Marc-André created the Devolutions.Authenticode module which has better signing defaults and even signs zip files. The README for the project is illuminating and provides a PowerShell-centric guide to exploring Authenticode.

Local impacts of signing modules

I talk a bit more about signing in a really old blog post called "walk-thru: installing modules from the powershell gallery". In the post, I share how I was surprised that users with Execution Policies which check for signing, had to explicitly trust us as a Trusted Publisher once we started signing our module.

1PS> Import-Module dbatools
2
3Do you want to run software from this untrusted publisher?
4File C:\Program Files\WindowsPowerShell\Modules\dbatools\1.1.100\xml\dbatools.Format.ps1xml is published by CN=dbatools, O=dbatools, L=Vienna, S=Virginia, C=US and is not trusted on your system. Only run scripts from trustesd publishers.
5
6[V] Never run [D] Do not run [R] Run once [A] Always run [?] Help
7(default is "D"):

Once Always run is selected, that will be the last time a prompt will be presented and the public part of our certificate will be stored in the user's Certificate Store under the Trusted Publishers folder.

Kind of a pain, but we've gotten zero complaints and actually, we do get kudos for signing dbatools as it makes it possible to use for many people who wouldn't be able to otherwise.

On a final note, I do want to highlight that sometimes...sometimes signing seems like it might invite more scrutiny from anti-viruses like Defender and Carbon Black. But maybe I'm wrong. Maybe dbatools sometimes gets blocked because Microsoft tweaked Defender a bit too hard, or maybe we recently renewed our certificate and needed more reputation points or maybe it was just a random Thursday. Either way, I'll never stop signing and AVs can figure out better ways to determine if my legitimate code is malware or not.