Search code examples
azureazure-pipelinescode-signingazure-pipelines-build-taskazure-pipelines-yaml

Azure build pipeline: Is it possible to sign an MSIX within the VSBuild task using a code signing certificate stored in the Key Vault?


I am able to sign an MSIX file within the VSBuild task when using a code signing certificate (*.PFX) that is stored as a secure file from the Build Pipeline's library section using the following setup (truncated for brevity):

Note: The key is how we are assigning the p:PackageCertificateKeyFile argument.

- task: AzureKeyVault@2
  inputs:
    azureSubscription: 'Dev (SomeGuid)'
    KeyVaultName: 'SomeKeyVault'
    SecretsFilter: 'SomeCertPassword'
    RunAsPreJob: false
    
- task: DownloadSecureFile@1
  name: signingCert
  inputs:
    secureFile: 'SomeCertName.pfx'  
    
- task: VSBuild@1
  inputs:
    platform: '$(buildPlatform)'
    solution: '$(solution)'
    configuration: '$(buildConfiguration)'
    msbuildArgs: '
      /p:AppInstallerUri=$(msixInstallUrl)
      /p:AppxBundle=Never 
      /p:AppxBundlePlatforms="$(buildPlatform)" 
      /p:AppxPackageDir="$(Build.ArtifactStagingDirectory)/" 
      /p:AppxPackageSigningEnabled=true
      /p:GenerateAppInstallerFile=true      
      /p:PackageCertificateThumbprint="" 
      /p:PackageCertificateKeyFile="$(signingCert.secureFilePath)"
      /p:PackageCertificatePassword="$(SomeCertPassword)"
      /p:UapAppxPackageBuildMode=SideLoadOnly 
      ' 

However, as an alternative to storing the code signing certificate within the secure files section of the pipeline library, I would like to store it in the Key Vault's certificate section and then just retrieve that within the AzureKeyVault task. So, the YAML would look something like this (truncated for brevity):

- task: AzureKeyVault@2
  inputs:
    azureSubscription: 'Dev (SomeGuid)'
    KeyVaultName: 'SomeKeyVault'
    SecretsFilter: 'SomeCertName,SomeCertPassword'
    RunAsPreJob: false
        
- task: VSBuild@1
  inputs:
    platform: '$(buildPlatform)'
    solution: '$(solution)'
    configuration: '$(buildConfiguration)'
    msbuildArgs: '
      /p:AppInstallerUri=$(msixInstallUrl)
      /p:AppxBundle=Never 
      /p:AppxBundlePlatforms="$(buildPlatform)" 
      /p:AppxPackageDir="$(Build.ArtifactStagingDirectory)/" 
      /p:AppxPackageSigningEnabled=true
      /p:GenerateAppInstallerFile=true      
      /p:PackageCertificateThumbprint="" 
      /p:PackageCertificateKeyFile="$(SomeCertName)"
      /p:PackageCertificatePassword="$(SomeCertPassword)"
      /p:UapAppxPackageBuildMode=SideLoadOnly 
      ' 

The reason for wanting to do it this way is because I get cannot find *.appinstaller file when I try to run a separate MSIX Code Signing task. It just seems simpler and easier to sign the package within the same build task.

But, I get the following error:

Error APPX0104: Certificate file '***' not found. C:\Program Files (x86)\Microsoft Visual

From what I can tell, when the certificate file is retrieved from the AzureKeyVault task variable, it is stored as a string as opposed to a file. But, the MSBuild task is expecting a file. I have tried searching all over the web and tried some Powershell scripts to convert the imported AzureKeyVault task variable from string to base64, but I am having no luck (see links below for reference). I have literally tried about 40 different ways of doing this and I'm afraid that an attempt to list them would only confuse the question. As a result, I felt it better to present what I am trying to do and ask if it is even possible, and if yes, how?


Solution

  • I was able to solve this by installing the MSIX Packaging extension to my Azure subscription, as referenced here: https://learn.microsoft.com/en-us/windows/msix/desktop/msix-packaging-extension?tabs=yaml

    This extension is extremely useful because, much like the Azure Sign Tool (AST), which seems to be the only alternative to this method, it handles the converting of the code signing certificate string to base64 and then signing the package. Obviously, there is a lot going on under the hood. You will need to configure your build pipeline to have access to the key vault. However, unlike the AST, this extension is far easier to use because it does not require all of the configurations that the AST requires. This is because the AST handles the task of retrieving the code signing certificate from the key vault whereas in my implementation I just use the Azure Key Vault task to pull the file into my build pipeline, which is also fairly easy to setup and implement. From my perspective, AST is too complicated to be useful.

    Below is the full implementation that I use, which also parameterizes the Key Vaults that store the various code signing certificates by our subscriptions/environments.

    trigger:
      branches:
        include:
        - refs/heads/main
        - refs/heads/demo
        - refs/heads/develop
    # The following is for testing purposes only. For now we disable automatically building for commits to feature branches in develop.
    #    - refs/heads/feature*
    
    pool:
      vmImage: 'windows-latest'
    
    # Variable Declaration(s)
    # Note: We must use the name/value combination because we are (dynamically) mixing in variable groups.
    variables:
    - name: buildPlatform
      value: 'x64'
    - name: buildConfiguration
      value: 'Release'
    - name: major
      value: 1
    - name: minor
      value: 0
    - name: build
      value: 0
    - name: revision
      value: $[counter('rev', 0)]
    # Set conditional variable group based off of the trigger branch (see trigger section above).
    # Note: If we use SourceBranch then it will append `refs/heads/` to the SourceBranchName.
    - ${{ if eq(variables['build.SourceBranch'], 'refs/heads/main') }}:
      - group: PROD
    - ${{ if eq(variables['build.SourceBranch'], 'refs/heads/qa') }}:
      - group: QA
    - ${{ if eq(variables['build.SourceBranch'], 'refs/heads/develop') }}:
      - group: DEV
    # The following is for testing purposes only.
    # Note: If you want to run for a feature branch in develop then you must uncomment this line. Otherwise, you will get an error that the pipeline is not valid.
    #       "Step AzureKeyVault input ConnectedServiceName references service connection $(azureSubscription) which could not be found."
    - ${{ if contains(variables['Build.SourceBranch'], 'refs/heads/feature') }}:
      - group: DEV
    
    steps: 
    # Update the AppXManifest file's major, minor, build, and revision parameters with the current values.
    # Note: We use the day's build counter to determine the revision number.
    #       By rule, this counter is limited to 255 values per day and will start over at 1!
    - powershell: |
       [Reflection.Assembly]::LoadWithPartialName("System.Xml.Linq")
       $path = "SomeAppName.MsixInstaller/Package.appxmanifest";
       $doc = [System.Xml.Linq.XDocument]::Load($path);
       $xName = [System.Xml.Linq.XName]::Get("{http://schemas.microsoft.com/appx/manifest/foundation/windows10}Identity");
       $doc.Root.Element($xName).Attribute("Version").Value = "$(major).$(minor).$(build).$(revision)";
       $doc.Save($path);
      displayName: 'Version the Package Manifest'
      
    # Retrieve the code signing certificate from the Key Vault.
    # Note: 20211008 - Ignore the errors because the values are correct.
    - task: AzureKeyVault@2
      displayName: 'Azure Key Vault: Retrieve Variable(s)'
      inputs:
        azureSubscription: '$(azureSubscription)'
        KeyVaultName: '$(keyVaultName)'
        SecretsFilter: 'SomeCertName'
        RunAsPreJob: false
    
    # Specify the minimum version of NuGet that we want to use to restore the solution's NuGet packages.
    - task: NuGetToolInstaller@1
      displayName: 'NuGet: Use v5.11.0'
      inputs:
        versionSpec: 5.11.0
        checkLatest: true
    
    # Run NuGet restore to download the NuGet packages before building the solution.
    - task: NuGetCommand@2
      displayName: 'NuGet: Run restore'
      inputs:
        command: 'restore'
        restoreSolution: '**/*.sln'
    
    # Build the MSIX package.
    # Note: Set AppxPackageSigningEnabled=false to avoid a build error (i.e., missing thumbprint). We will sign the package in a subsequent step.
    - task: VSBuild@1
      displayName: 'VSBuild: Build the MSIX package'
      inputs:
        clean: true
        configuration: '$(buildConfiguration)'
        platform: '$(buildPlatform)'
        restoreNugetPackages: false  #adding this because we're doing an explicit restore above, lets skip the implicit restore.
        solution: '**/*.sln'
        msbuildArgs: '
          /p:AppInstallerUri="$(appServiceUri)"
          /p:AppxPackageDir="$(Build.ArtifactStagingDirectory)/" 
          /p:AppxBundle=Never 
          /p:AppxBundlePlatforms="$(buildPlatform)" 
          /p:UapAppxPackageBuildMode=SideLoadOnly 
          /p:GenerateAppInstallerFile=true
          /p:AppxPackageSigningEnabled=false
          '
    
    # Sign the MSIX package with the code signing certificate from the Key Vault.
    - task: MsixSigning@1
      displayName: 'Code Sign MSIX Package'
      inputs:
        package: '$(Build.ArtifactStagingDirectory)\**\*.msix'
        certificateType: 'base64'
        encodedCertificate: '$(DeveloperCodeSigningCertFile)'
        continueOnError: true
    
    # Publish the MSIX package in preparation for deployment. This will be consumed by the Release Pipeline.
    - task: PublishBuildArtifacts@1
      displayName: 'Publish ArtifactName: drop'
      inputs:
        PathtoPublish: '$(Build.ArtifactStagingDirectory)'
        ArtifactName: 'drop'
        publishLocation: 'Container'
        
    # Run tests (if any).
    # Note: This tests sources that are found matching the given filter '**\*test*.dll,!**\*TestAdapter.dll,!**\obj\**
    - task: VSTest@2
      displayName: 'VSTest: Run Tests (if any)'
      inputs:
        platform: '$(buildPlatform)'
        configuration: '$(buildConfiguration)'