2020-01-31

Building strong named assemblies in Azure Pipelines (or solving the MSB3325 error)

If you, like me, are using a certificate to sign your assemblies, making them strong named, you will get in trouble when trying to build the project on another machine (or as in this case, using an azure pipeline). MSBuild will throw an error (MSB3325) that looks like below.

Error MSB3325: Cannot import the following key file: mycertificate.pfx. 
The key file may be password protected. 
To correct this, try to import the certificate again 
or manually install the certificate to the Strong Name CSP 
with the following key container name: VS_KEY_F287A1878DD02205

Problem background

Certificates (in pfx containers) are password protected and for MSBuild to be able to use them, they must be installed in a key container. (MSBuild uses the "Microsoft Strong Cryptographic Provider", MSC)

Normally, when selecting a certificate in Visual Studio, you are prompted for the password and Visual studio creates the container for you. When the container is created, you do not need the password as long as the container exists. If you would like to build the project on another machine you will get the error above since the container is not present.

Manual solutions

If you go to another machine, one simple way to create the container is to deselect and select the certificate again (in visual studio, in the properties/signing window) which will prompt you for the password.
Another way is to use sn.exe and pass the certificate and key container name as parameters

Sn.exe -i mycertificate.pfx VS_KEY_F287A1878DD02205

This command will then prompt you for the password and create the container
https://docs.microsoft.com/en-us/dotnet/framework/tools/sn-exe-strong-name-tool

Running on Azure pipelines

To be able to build this on Azure Pipelines you need to create the container beforehand.
One way is to create a self-hosted agent that you install the container on.
https://docs.microsoft.com/en-us/azure/devops/pipelines/agents/v2-windows?view=azure-devops

If you would like to use a Microsoft-hosted agent, then you must include the creation of the container in the pipeline itself. Using sn.exe seems like a promising way.

Automated solutions

If we would like to automate the process we have two problems that need solving
1. We need to calculate the name of the container
2. We need to pass the password to the sn.exe tool somehow.

The container name is determined by a class in Microsoft.Build.Tasks.v4.0.dll named ResolveKeySource. It will base the name upon the contents of the certificate, plus the user's domain and username that are running the MSBuild command. kdrapel posted an answer regarding this on StackOverflow

The other issue, passing the password to the sn.exe tool seemed a bit harder. There were some examples where piping it to the exe or writing a wrapper around stdin but none of them worked for me.

SnInstallPfx

Thanks to honzajscz, who also had this issue, the GitHub project SnInstallPfx solved both issues in a nice way. Both calculating the container name and allowing us to enter the password ended us up with a simple command line

SnInstallPfx.exe mycertificate.pfx mypassword

Final solution in Azure Pipelines

To include it in the pipeline I used the library function
https://docs.microsoft.com/en-us/azure/devops/pipelines/library/?view=azure-devops

In the library, I uploaded both the certificate and the sninstallpfx.exe tool.
The password I added in a variable group.

In the YML I first added the variable group

variables:
- group: 'My password'

Then the files needed to be copied to the target host using DownloadSecureFile task

- task: DownloadSecureFile@1
  displayName: Download Pfx
  name: myCertificatePfx
  inputs:
    secureFile: MyCertificate.pfx

- task: DownloadSecureFile@1
  displayName: Download sni
  name: snInstallPfx
  inputs:
    secureFile: SnInstallPfx.exe

Then added a PowerShell task where I imported the file paths and password as an environment variable and just invoked the exe

- task: PowerShell@2
  env:
    SN_INSTALL_PFX: $(snInstallPfx.secureFilePath)
    MYCERTIFICATE_PFX: $(myCertificatePfx.secureFilePath)
    MYCERTIFICATE_PFX_PASSWORD: $(myCertificatePfxPassword)
  inputs:
    targetType: 'inline'
    script: '&"$($ENV:SN_INSTALL_PFX)" "$($ENV:MYCERTIFICATE_PFX)" "$($ENV:MYCERTIFICATE_PFX_PASSWORD)"'

After a long research, I was finally able to build the project on a Microsoft hosted pipeline.

13 comments:

  1. Hi Dan!

    Thanks a lot for your post, it helped a lot to my build.
    I run my pipelines on self-hosted Agent pool implemented on Azure VMSS, and can't import the certificate locally on every VM Azure will create in the set to build components.

    SnInstallPfx helped, but the PS script output from the tool ("SnInstallPfx.exe : The key pair is already installed in the strong name CSP key container 'VS_KEY_XXXXXXXX'" or "The key pair has been installed into the strong name CSP key container 'VS_KEY_XXXXXXXXXXX'.") returns to stage log as an error.

    Maybe you are familiar with the ways of making that output recognizible as a success, or at least not as an Error?

    ReplyDelete
  2. When I run it on azure pipelines it does not get interpreted as an error. Supplying my output from the pipeline task if you want to compare.

    2020-08-14T09:18:49.6768386Z ##[section]Starting: PowerShell
    2020-08-14T09:18:49.6938376Z ==============================================================================
    2020-08-14T09:18:49.6938760Z Task : PowerShell
    2020-08-14T09:18:49.6939057Z Description : Run a PowerShell script on Linux, macOS, or Windows
    2020-08-14T09:18:49.6939360Z Version : 2.170.1
    2020-08-14T09:18:49.6939600Z Author : Microsoft Corporation
    2020-08-14T09:18:49.6939951Z Help : https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/powershell
    2020-08-14T09:18:49.6940362Z ==============================================================================
    2020-08-14T09:18:53.1779936Z Generating script.
    2020-08-14T09:18:53.2622737Z ========================== Starting Command Output ===========================
    2020-08-14T09:18:53.2983892Z ##[command]"C:\windows\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command ". 'D:\a\_temp\27b747f6-83ae-4b0d-b916-e2fba95b5bb3.ps1'"
    2020-08-14T09:18:55.4443633Z The key pair has been installed into the strong name CSP key container 'VS_KEY_56968BA016C083FC'.
    2020-08-14T09:18:55.4445567Z VS_KEY_56968BA016C083FC
    2020-08-14T09:18:56.9263469Z ##[section]Finishing: PowerShell

    ReplyDelete
    Replies
    1. Thanks Dan!
      Forgot to update you on this, sorry.
      I've set the inline PS script to run silently. In case of any issue with a certificate I'll recieve the Error in MSBuild task log anyway.
      Thanks again for your post, it was helpful, now I don't need to install certificate manually on every instance I want to build and sign apps on.

      Delete
  3. Hi Dan,

    Thanks for the details. But can you please elaborate on how powershell was able to read YAML format? I am trying to replicate the below step:
    Then added a PowerShell task where I imported the file paths and password as an environment variable and just invoked the exe

    - task: PowerShell@2
    env:
    SN_INSTALL_PFX: $(snInstallPfx.secureFilePath)
    MYCERTIFICATE_PFX: $(myCertificatePfx.secureFilePath)
    MYCERTIFICATE_PFX_PASSWORD: $(myCertificatePfxPassword)
    inputs:
    targetType: 'inline'
    script: '&"$($ENV:SN_INSTALL_PFX)" "$($ENV:MYCERTIFICATE_PFX)" "$($ENV:MYCERTIFICATE_PFX_PASSWORD)"'


    I selected powershell task and selected inline and added the YAML to it, but it does not detect it as a YAML and hence throwing error.

    env: : The term 'env:' is not recognized as the name of a cmdlet, function, script file, or operable program. Check
    the spelling of the name, or if a path was included, verify that the path is correct and try again.
    At D:\a\_temp\2dd4a17b-5bdf-4a3a-bf71-5ed565ba8f27.ps1:3 char:1

    ReplyDelete
    Replies
    1. Ok I made some changes and see the script run ##[debug]INPUT_SCRIPT: 'Start-Process -FilePath "D:\a\_temp\SnInstallPfx.exe" -ArgumentList "D:\a\_temp\test.pfx ***"' but it seems to still fail?

      error MSB3325: Cannot import the following key file: test.pfx. The key file may be password protected. To correct this, try to import the certificate again or manually install the certificate to the Strong Name CSP with the following key container name: VS_.......

      Delete

    2. ##[debug]Loaded 11 strings.
      ##[debug]INPUT_ERRORACTIONPREFERENCE: 'stop'
      ##[debug]INPUT_SHOWWARNINGS: 'false'
      ##[debug] Converted to bool: False
      ##[debug]INPUT_FAILONSTDERR: 'false'
      ##[debug] Converted to bool: False
      ##[debug]INPUT_IGNORELASTEXITCODE: 'false'
      ##[debug] Converted to bool: False
      ##[debug]INPUT_PWSH: 'false'
      ##[debug] Converted to bool: False
      ##[debug]INPUT_WORKINGDIRECTORY: 'D:\a\1\s'
      ##[debug]Asserting container path exists: 'D:\a\1\s'
      ##[debug]INPUT_TARGETTYPE: 'inline'
      ##[debug]INPUT_SCRIPT: ''&"$($ENV:SN_INSTALL_PFX)" "$($ENV:MYCERTIFICATE_PFX)" "$($ENV:MYCERTIFICATE_PFX_PASSWORD)"''
      ##[debug]INPUT_RUNSCRIPTINSEPARATESCOPE: 'false'
      ##[debug] Converted to bool: False
      Generating script.
      ##[debug]AGENT_VERSION: '2.187.2'
      ##[debug]AGENT_TEMPDIRECTORY: 'D:\a\_temp'
      ##[debug]Asserting container path exists: 'D:\a\_temp'
      ##[debug]Asserting leaf path exists: 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
      ========================== Starting Command Output ===========================
      ##[debug]Entering Invoke-VstsTool.
      ##[debug] Arguments: '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command ". 'D:\a\_temp\2f524d82-33a6-43b8-a88e-771d86cec62a.ps1'"'
      ##[debug] FileName: 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
      ##[debug] WorkingDirectory: 'D:\a\1\s'
      "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command ". 'D:\a\_temp\2f524d82-33a6-43b8-a88e-771d86cec62a.ps1'"
      &"$($ENV:SN_INSTALL_PFX)" "$($ENV:MYCERTIFICATE_PFX)" "$($ENV:MYCERTIFICATE_PFX_PASSWORD)"
      ##[debug]$LASTEXITCODE is not set.
      ##[debug]Exit code: 0
      ##[debug]Leaving Invoke-VstsTool.
      ##[debug]Leaving D:\a\_tasks\PowerShell_e213ff0f-5d5c-4791-802d-52ea3e7be1f1\2.186.0\powershell.ps1.
      Finishing: PowerShell Script copy

      Delete
  4. Hi.
    For the first error, i suspect a formatting error. In yaml, whitespace is very important. The env must be aligned with the t in task, as in my example above.
    For the second problem, the output above seem to indicate that the script is never executed. In fact, the error you see, says that the pfx was not installed and the output only echoes the inlined script, not executing it.
    From the output it seems that the inline script contains extra quotes around the script. (two apostrophes before and after the script, should only be one)

    For reference, I'll provide the same output from my script so you can compare

    ##[debug]Loaded 11 strings.
    ##[debug]INPUT_ERRORACTIONPREFERENCE: 'stop'
    ##[debug]INPUT_SHOWWARNINGS: 'false'
    ##[debug] Converted to bool: False
    ##[debug]INPUT_FAILONSTDERR: 'false'
    ##[debug] Converted to bool: False
    ##[debug]INPUT_IGNORELASTEXITCODE: 'false'
    ##[debug] Converted to bool: False
    ##[debug]INPUT_PWSH: 'false'
    ##[debug] Converted to bool: False
    ##[debug]INPUT_WORKINGDIRECTORY: 'D:\a\1\s'
    ##[debug]Asserting container path exists: 'D:\a\1\s'
    ##[debug]INPUT_TARGETTYPE: 'inline'
    ##[debug]INPUT_SCRIPT: '&"$($ENV:SN_INSTALL_PFX)" "$($ENV:IMAGEVAULT_PFX)" "$($ENV:IMAGEVAULT_PFX_PASSWORD)"'
    ##[debug]INPUT_RUNSCRIPTINSEPARATESCOPE: 'false'
    ##[debug] Converted to bool: False
    Generating script.
    ##[debug]AGENT_VERSION: '2.187.2'
    ##[debug]AGENT_TEMPDIRECTORY: 'D:\a\_temp'
    ##[debug]Asserting container path exists: 'D:\a\_temp'
    ##[debug]Asserting leaf path exists: 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
    ========================== Starting Command Output ===========================
    ##[debug]Entering Invoke-VstsTool.
    ##[debug] Arguments: '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command ". 'D:\a\_temp\7f7f1972-33a8-4cc9-a78b-5a9aa4859ee0.ps1'"'
    ##[debug] FileName: 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
    ##[debug] WorkingDirectory: 'D:\a\1\s'
    "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command ". 'D:\a\_temp\7f7f1972-33a8-4cc9-a78b-5a9aa4859ee0.ps1'"
    The key pair has been installed into the strong name CSP key container 'VS_KEY_78CDD94BD2B2D3C2'.
    VS_KEY_78CDD94BD2B2D3C2
    ##[debug]$LASTEXITCODE: 0
    ##[debug]Exit code: 0
    ##[debug]Leaving Invoke-VstsTool.
    ##[debug]Leaving D:\a\_tasks\PowerShell_e213ff0f-5d5c-4791-802d-52ea3e7be1f1\2.186.0\powershell.ps1.
    Finishing: PowerShell
    What does your yaml script look like (the install pfx script part)?

    ReplyDelete
    Replies
    1. Hi Dan,
      Thank you for the response. My Yaml looks like:
      - task: PowerShell@2
      env:
      SN_INSTALL_PFX: $(snInstallPfx.secureFilePath)
      MYCERTIFICATE_PFX: $(myCertificatePfx.secureFilePath)
      MYCERTIFICATE_PFX_PASSWORD: $(myCertificatePfxPassword)
      inputs:
      targetType: 'inline'
      script: '&"$($ENV:SN_INSTALL_PFX)" "$($ENV:MYCERTIFICATE_PFX)" "$($ENV:MYCERTIFICATE_PFX_PASSWORD)"'

      Sorry formatting in this post is not showing correctly.

      The other way I tried was:
      Start-Process -FilePath "$(SnInstallPfx.secureFilePath)" -ArgumentList "$(RMS.secureFilePath) $(myCertificatePfxPassword)"

      both seem to fail

      Delete
    2. Well, it looks correct. I cannot help you further. I would suggest that you contact devops support to get help with this issue.

      Delete
  5. Nice post, I already tried to do this and the certificate is added correctly, but seems that the PowerShell task runs in a different container than the build task and thats why is not recognized.

    This is the output of the PowerShell task.

    The key pair has been installed into the strong name CSP key container 'VS_KEY_2211CE6F0D92996F'

    Then I have a task for the build:

    - task: VSBuild@1
    displayName: 'Build .csproj file'
    inputs:
    solution: '$(solution)'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

    And I get for the 2 projects different errors referencing different containers names:

    Error MSB3325: Cannot import the following key file: ***cert.pfx. The key file may be password protected. To correct this, try to import the certificate again or manually install the certificate to the Strong Name CSP with the following key container name: VS_KEY_71452E506F1E61FB

    Error MSB3325: Cannot import the following key file: ***cert.pfx. The key file may be password protected. To correct this, try to import the certificate again or manually install the certificate to the Strong Name CSP with the following key container name: VS_KEY_1789CEF560C2266D

    Any clue?

    ReplyDelete
    Replies
    1. Hi Alex. I am having an identical problem and trying to find a way around it.
      I've used sninstallpfx.exe to install the pfx cert. I am not getting the same message as you but I suspect it might be something very similar. My error is

      The key file may be password protected. To correct this, try to import the certificate again or import the certificate manually into the current user’s personal certificate store.

      Wondering if you ever found a way around this?

      Delete
    2. Well, the key point to this problem is that the certificate needs to be installed in a container before the build takes place. Since you have created a container earlier in the pipeline but it was created with a different name than the ones the msbuild process tries to retrieve, then something that the container name is calculated from, has changed.
      The container name is built upon the contents of the certificate plus the user that runs the build.
      If you use the same certificate, then the user running the build must have changed. Make sure that the pipeline tasks not being run in paralell or that the powershell task that installs the certificate and the msbuild process is run in the same context.

      Delete
  6. hello this is my dockerfile and still get
    C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\Microsoft.Common.CurrentVersion.targets(3222,5): error MSB3325: Cannot import the following key file: igakey.pfx. The key file may be password protected. To correct this, try to import the certificate again or manually install the certificate to the Strong Name CSP with the following key container name: VS_KEY_1E532C4C0BA55C05 [C:\inetpub\wwwroot\DTOPrestation\DTOPrestation.csproj]
    C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\Microsoft.Common.CurrentVersion.targets(3222,5): error MSB3321: Importing key file "igakey.pfx" was canceled. [C:\inetpub\wwwroot\DTOPrestation\DTOPrestation.csproj]

    dockerfile :
    FROM mcr.microsoft.com/dotnet/framework/sdk:4.8-windowsservercore-ltsc2019 AS base
    ARG source
    ENV COMPLUS_NGenProtectedProcess_FeatureEnabled=0
    ENV ASPNETCORE_ENVIRONMENT Development

    SHELL ["cmd", "/S", "/C"]
    RUN powershell -Command $ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -UseBasicParsing -Uri https://dot.net/v1/dotnet-install.ps1 -OutFile dotnet-install.ps1; ./dotnet-install.ps1 -InstallDir '/Program Files/dotnet' -Channel 6.0 -Runtime aspnetcore; Remove-Item -Force dotnet-install.ps1
    SHELL ["powershell", "-Command"]

    WORKDIR /app
    EXPOSE 80
    EXPOSE 8001

    FROM mcr.microsoft.com/dotnet/framework/sdk:3.5-20191008-windowsservercore-ltsc2019 AS build

    RUN net user mqAdmin /ADD
    RUN net localgroup Administrators /add mqAdmin

    USER mqAdmin
    RUN whoami

    SHELL ["cmd", "/S", "/C"]
    RUN powershell -Command $ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12;Invoke-WebRequest -UseBasicParsing -Uri https://dot.net/v1/dotnet-install.ps1 -OutFile dotnet-install.ps1; ./dotnet-install.ps1 -InstallDir '/Program Files/dotnet' -Channel 2.2; Remove-Item -Force dotnet-install.ps1
    RUN powershell setx /M PATH '%PATH%;/Program Files/dotnet'
    SHELL ["powershell", "-Command"]

    WORKDIR /inetpub/wwwroot

    COPY ["nuget.config", "."]
    COPY ["SAGILEA_WEB/WebApplication/WebApplication.csproj", "SAGILEA_WEB/WebApplication/"]




    RUN dotnet restore "SAGILEA_WEB/WebApplication/WebApplication.csproj"
    COPY . .
    RUN ls

    SHELL ["cmd", "/S", "/C"]
    RUN powershell -NoProfile -Command \
    $Secure_String_Pwd = ConvertTo-SecureString "****** " -AsPlainText -Force ; \
    Import-PfxCertificate -FilePath ./CommonDataV2/igaKey.pfx -CertStoreLocation Cert:\LocalMachine\Root -Exportable -Password $Secure_String_Pwd
    SHELL ["powershell", "-Command"]

    RUN dotnet tool install pfxtool -g
    RUN pfxtool import --file ./CommonDataV2/igaKey.pfx --password ****** --scope user

    SHELL ["cmd", "/S", "/C"]
    RUN powershell -Command $ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12; Invoke-WebRequest https://github.com/honzajscz/SnInstallPfx/releases/download/0.1.2-beta/SnInstallPfx.exe -OutFile SnInstallPfx.exe
    RUN SnInstallPfx.exe ./DTOPrestation/igakey.pfx ******
    RUN SnInstallPfx.exe ./CommonDataV2/igaKey.pfx ******
    RUN SnInstallPfx.exe ./SAGILEA_WEB/Resources/SagileaWeb.pfx ******
    RUN SnInstallPfx.exe ./SEPAVirement/igaKey.pfx ******
    RUN SnInstallPfx.exe ./Normes/DSN_Parametrage/DSN_ParamDAL/igaKey.pfx ******
    SHELL ["powershell", "-Command"]


    WORKDIR /inetpub/wwwroot/SAGILEA_WEB/WebApplication/
    RUN msbuild ./WebApplication.csproj /property:Configuration=Release


    FROM base AS final
    WORKDIR /app
    #COPY --from=build /app/publish .
    COPY --from=build /inetpub/wwwroot/SAGILEA_WEB/WebApplication/bin/Release/Publish/. .
    ENTRYPOINT ["dotnet", "WebApplication.dll"]
    #ENTRYPOINT ["WebApplication.exe"]
    #ENTRYPOINT ["WebApplication.csprog"]

    ReplyDelete