Skip to content

How-To: Integrate the Keyserver with Azure DevOps

This guide shows how to integrate Azure DevOps Pipelines with the Cryptera Keyserver for automated signing.
You will configure Workload Identity Federation (WIF), obtain an OAuth2 token inside a pipeline, request a signing operation, and retrieve the signature.

This guide assumes:

  • That you have an Azure DevOps account and that an Azure Pipeline agent is configured with the account.
  • A self-hosted or Microsoft-hosted agent is available
  • The pipeline agent needs to have openssl installed
  • That you have followed the Azure Entra ID & in particular the Workload Identity Federation (WIF) section
  • You know the Keyserver URL and your assigned URL: *.<UNIQUE-ID>.cryptera-security.io
  • A Keyserver client subject exists for your service connection
  • A signing key is already configured and has the appropriate approval requirements

Production-Ready Authentication

This guide uses Azure Entra ID Workload Identity Federation (WIF), which is the recommended
approach for production CI/CD environments.
Pipelines should authenticate using federated tokens issued by Azure, not via the Keyserver’s
internal Authserver.

Do Not Use Internal Authserver in Production

The internal Authserver may be used for demos or local testing, but must never be used
for production CI/CD signing or certificate issuance workflows.
Production deployments require Azure Entra ID or another external identity provider.

Configuring Azure DevOps to use Azura Entra ID through Workload Identity Federation

The recommended way to setup authentication against KeyServer when using Azure DevOps is to setup Workload Identity Federation, which consists of:

  • A Service Connection from Azure DevOps to an App Registration on Entra Id
  • A link between the Service Connection App Registration and the App Registration thats configured for KeyServer

Before following these steps, the Azure Entra ID guide must be completed, including the optional Workload Identity Federation (WIF) section.

Setting up an Azure DevOps Service Connection

  • Go to Project Settings for the project that requires access to KeyServer and select "Service Connections" tab
  • Click "New service connection"
  • Select Azure Resource Manager
  • Identity type: App registration (automatic)
  • Credential: Workload identity federation
  • Scope level: Subscription
  • Subscription: Select an active subscription
  • Service Connection Name: Choose an appropriate name, note it down, as it is needed in the pipeline to reference the service connection
  • Grant access permission to all pipelines: Not recommended, instead authorize the individual pipelines that requires access
  • Click Save
  • For the newly created Service Connection, click "Manage App registration" - this opens the newly create App registration in Portal view.
  • Note the Application (client) ID, this will be used as the client/subject registration for this service account in KeyServer
  • Click "Add an Application ID URI"
    • Click the Add button next to Application ID URI, this will open a dialog - use default value and click Save
  • Go to API Permissions menu item
    • Click "Add a permission"
    • Select (either My API's or) APIs my organization uses and select the App registration thats linked to KeyServer (see separate Azure Entra ID guide)
    • Select Application permissions
    • Select the permission that was previously created in the Azure Entra ID guide and click "Add permission"
    • Click "Grant admin consent for XYZ"

Add Service Connection Subject to Keyserver

For each Service Connection thats created in Azure DevOps a subject must be created in Keyserver. Creating multiple service connections allows for a more fine grained control.

In the Keyserver:

  • Create a client subject
  • Set name to something that links it with the Service Connection
  • Set Subject ID to the Application (client) ID noted in the previous section
    • Assign roles (e.g., orderer:key1)
    • Subject should have access to perform signing operations as well as getting the status of a pending operation

This enables Azure DevOps to request an OAuth2 access token scoped for the Keyserver.


Creating a pipeline

Pipelines are configured using YAML files. These files describe the environment, stages, jobs and tasks that should be carried out.

Below is a minimal working YAML pipeline that:

  1. Builds LibPKCS#11 Keyserver library from sources off Github
  2. Authenticates
  3. Hashes a test file
  4. Creates an operation
  5. Waits for approval
  6. Requests the signature
  7. Verifies the signature

Define initial pipeline

Below example uses a local 'azure-runner'.

Adapt variables as needed: * KEYSERVER: This is the URL of your keyserver, set to the unique assigned keyserver * KEYID: The name of the key as displayed in the web ui * TENANT_ID: Tenant ID of the primary Azure Entra ID * CLIENT_ID: Client ID of the primary Azure Entra ID (not the service connection) * OP_DESCRIPTION: Description that will be attached to the signing operation for auditing purposes. It should help approvers understand the context of the operation when asked to approve

trigger:
- main

pool:
  name: 'Development'
  demands:
    - Agent.Name -equals azure-runner

variables:
  KEYSERVER: 'https://keyserver.<UNIQUE-ID>.cryptera-security.io'
  HASH_ALGORITHM: '-sha256'
  KEYID: 'key1'
  TENANT_ID: 'AZURE ENTRA ID TENANT ID'
  CLIENT_ID: 'AZURE ENTRA CLIENT ID (of the primary Entra ID, not the service connection)'
  SCOPE: 'api://$(CLIENT_ID)/.default'
  OP_DESCRIPTION: 'Azure DevOps PKCS#11 signing'

stages:
- stage: SignBinary
  displayName: "Sign Binary"
  jobs:

Acquire Azure Access token to access Keyserver API

Azure DevOps provides an AzureCLI task capable of exchanging the pipeline’s identity for an access token.

"azureSubscription" must be updated to the name of the Service Connection previously created.

  - job: Main
    displayName: "Main signing flow"
    steps:
      - task: AzureCLI@2
        name: GetToken
        displayName: "Acquire Azure access token to access Keyserver API"
        inputs:
          azureSubscription: 'iot-azure'
          scriptType: 'bash'
          scriptLocation: 'inlineScript'
          inlineScript: |
            set -Eeuo pipefail
            ACCESS_TOKEN=$(az account get-access-token \
              --tenant $(TENANT_ID) \
              --scope $(SCOPE) \
              --query accessToken -o tsv)
            if [ -z "$ACCESS_TOKEN" ]; then
              echo "##vso[task.logissue type=error]Failed to acquire access token"
              exit 1
            fi
            echo "##vso[task.setvariable variable=KSC_ID_TOKEN;issecret=true]$ACCESS_TOKEN"

This exposes KSC_ID_TOKEN as a valid JWT.

(Optional) Build library from Github sources

This step can be skipped or modified to use a static release, for completeness it shows how to build the library locally (through docker)

Note Remember to update the paths below this and the following step, to match your setup

      - task: Bash@3
        displayName: Build libpkcs11ks from source (Docker)
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            SRC_DIR=/home/cryptera/libpkcs11ks-src
            OUT_DIR=/home/cryptera/scripts
            rm -rf "$SRC_DIR"
            git clone --depth=1 https://github.com/cryptera-device-security/libpkcs11ks.git "$SRC_DIR"
            cd "$SRC_DIR"
            docker build -t libpkcs11ks .
            mkdir -p "$OUT_DIR"
            docker run --rm -v "$OUT_DIR:/output" libpkcs11ks
            built=$(find "$OUT_DIR" -maxdepth 1 -name 'libpkcs11ks*.so' | head -n1)
            if [ -z "$built" ]; then
              echo "libpkcs11ks.so not found after build"; exit 1
            fi
            dest="$OUT_DIR/libpkcs11ks.so"
            if [ "$built" != "$dest" ]; then
              cp -f "$built" "$dest"
            fi
            ls -l "$dest"

Generate OpenSSL PKCS#11 config

This stage creates a custom openssl.cnf that points at the custom PKCS11 library. This stage could be replaced with a static configuration in the repository.

      - task: Bash@3
        displayName: Generate OpenSSL PKCS#11 config
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            cat >/home/cryptera/scripts/openssl-ks.cnf <<'EOF'
            openssl_conf = openssl_init

            [openssl_init]
            engines = engine_section
            providers = provider_sect

            [provider_sect]
            default = default_sect
            base = base_sect

            [default_sect]
            activate = 1

            [base_sect]
            activate = 1

            [engine_section]
            pkcs11 = pkcs11_section

            [pkcs11_section]
            engine_id = pkcs11
            dynamic_path = /usr/lib/x86_64-linux-gnu/engines-3/libpkcs11.so
            MODULE_PATH = /home/cryptera/scripts/libpkcs11ks.so
            init = 0
            EOF
            echo "##vso[task.setvariable variable=OPENSSL_CONF;issecret=false]/home/cryptera/scripts/openssl-ks.cnf"

Create Keyserver Operation

Create a Keyserver operation asking for 1 use of $KEYID for a signing operation

      - task: Bash@3
        displayName: Create Keyserver operation
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          OPENSSL_CONF: $(OPENSSL_CONF)
          KSC_API_SERVER: $(KEYSERVER)
          KSC_OPERATION_DESCRIPTION: $(OP_DESCRIPTION)
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            : "${KSC_ID_TOKEN:?Missing KSC_ID_TOKEN}"
            export OPENSSL_CONF
            export KSC_API_SERVER
            export KSC_OPERATION_DESCRIPTION
            export KSC_OPERATION_ID=$(
              curl -sS --tlsv1.3 \
                -X POST "$KSC_API_SERVER/api/operations" \
                -H "Authorization: Bearer $KSC_ID_TOKEN" \
                -H "Content-Type: application/json" \
                -d "{
                      \"validms\": 60000,
                      \"description\": \"$KSC_OPERATION_DESCRIPTION\",
                      \"new-keyusagerestrictions\": [
                        { \"keyid\": \"$(KEYID)\", \"maxusagecount\": 1 }
                      ]
                    }" | jq -r .id
            )
            if [ -z "$KSC_OPERATION_ID" ] || [ "$KSC_OPERATION_ID" = "null" ]; then
              echo "Failed to create operation"; exit 1
            fi
            echo "##vso[task.setvariable variable=KSC_OPERATION_ID;issecret=false]$KSC_OPERATION_ID"
            echo "Operation ID: $KSC_OPERATION_ID"

Wait for Approval

If required by the selected $KEYID, this step will wait for the operation to become approved by one or more approvers. I no approvers are required, the step will continue immediately.

      - task: Bash@3
        name: WaitForApproval
        displayName: "Wait for signing approval"
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          KSC_OPERATION_ID: $(KSC_OPERATION_ID)
          KSC_API_SERVER: $(KEYSERVER)
        inputs:
          targetType: inline
          script: |
            for i in {1..12}; do
              STATUS=$(curl -sS --tlsv1.3 \
                "$(KEYSERVER)/api/operations/$(KSC_OPERATION_ID)" \
                -H "Authorization: Bearer $(KSC_ID_TOKEN)" | jq -r .approved)

              echo "Attempt $i - Approved: $STATUS"
              [ "$STATUS" = "true" ] && exit 0
              sleep 10
            done

            echo "Approval timeout"
            exit 1

Perform Signing Operation

This step will perform a signing of a text file "test.txt" using OpenSSL through the custom PKCS11 library.

      - task: Bash@3
        displayName: Sign test file with OpenSSL + PKCS#11
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          KSC_OPERATION_ID: $(KSC_OPERATION_ID)
          OPENSSL_CONF: $(OPENSSL_CONF)
          KSC_API_SERVER: $(KEYSERVER)
          KSC_OPERATION_DESCRIPTION: $(OP_DESCRIPTION)
          KSC_HTTP_CLIENT_TIMEOUT: 15s
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            : "${KSC_ID_TOKEN:?Missing KSC_ID_TOKEN}"
            : "${KSC_OPERATION_ID:?Missing KSC_OPERATION_ID}"
            export OPENSSL_CONF
            export KSC_API_SERVER
            export KSC_OPERATION_DESCRIPTION
            export KSC_OPERATION_ID
            echo "Test" > test.txt
            openssl dgst \
              -engine pkcs11 \
              -keyform engine \
              -sign "pkcs11:object=$(KEYID)" \
              $(HASH_ALGORITHM) \
              -out test.txt.sig \
              test.txt
            echo "Signature written to test.txt.sig"

Validate the signature

The following two steps will perform a validation of the signature against the public key of the private key used for the signing operation.

The first step will get the certificate of the key and extract the public key. The second step performs the actual validation using the public key, text file, signature and openssl

      - task: Bash@3
        name: GetCertificate
        displayName: "Download certificate for signing key (for validation)"
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          KSC_API_SERVER: $(KEYSERVER)
        inputs:
          targetType: inline
          script: |
            set -Eeuo pipefail
            : "${KSC_ID_TOKEN:?Missing KSC_ID_TOKEN}"
            : "${KSC_API_SERVER:?Missing KSC_API_SERVER}"

            curl --fail-with-body -sS --tlsv1.3 \
              -H "Authorization: Bearer $KSC_ID_TOKEN" \
              -H "Accept: application/json" \
              "$KSC_API_SERVER/api/keys/$(KEYID)" \
              | jq -er '.certificate' > certificate.pem

            test -s certificate.pem
            echo "Saved certificate to certificate.pem"


      - task: Bash@3
        displayName: "Validate signature using certificate"
        inputs:
          targetType: inline
          script: |
            set -Eeuo pipefail

            test -s certificate.pem
            test -s test.txt
            test -s test.txt.sig

            # Extract pubkey from cert
            openssl x509 -in certificate.pem -pubkey -noout > pubkey.pem
            test -s pubkey.pem

            # Verify signature
            openssl dgst \
              -verify pubkey.pem \
              $(HASH_ALGORITHM) \
              -signature test.txt.sig \
              test.txt

            echo "Signature verification OK"
Expected output:

Verified OK

Public artifacts

This step will publish the new signature

      - task: PublishBuildArtifacts@1
        displayName: Publish signature
        inputs:
          PathtoPublish: "$(System.DefaultWorkingDirectory)"
          ArtifactName: signed-output
          publishLocation: "Container"

All stages combined:

trigger:
- main

pool:
  name: 'Development'
  demands:
    - Agent.Name -equals azure-runner

variables:
  KEYSERVER: 'https://keyserver.<UNIQUE-ID>.cryptera-security.io'
  HASH_ALGORITHM: '-sha256'
  KEYID: 'key1'
  TENANT_ID: 'cd7628a5-eb0a-4913-91a9-64049355c7fa'
  CLIENT_ID: '618547b5-847d-403f-a25e-0e9f0cc56877'
  SCOPE: 'api://618547b5-847d-403f-a25e-0e9f0cc56877/.default'
  OP_DESCRIPTION: 'Azure DevOps PKCS#11 signing'

stages:
- stage: SignBinary
  displayName: "Sign Binary"
  jobs:
  - job: Main
    displayName: "Main signing flow"
    steps:
      - task: AzureCLI@2
        name: GetToken
        displayName: "Acquire Azure access token to access Keyserver API"
        inputs:
          azureSubscription: 'iot-azure'
          scriptType: 'bash'
          scriptLocation: 'inlineScript'
          inlineScript: |
            set -Eeuo pipefail
            ACCESS_TOKEN=$(az account get-access-token \
              --tenant $(TENANT_ID) \
              --scope $(SCOPE) \
              --query accessToken -o tsv)
            if [ -z "$ACCESS_TOKEN" ]; then
              echo "##vso[task.logissue type=error]Failed to acquire access token"
              exit 1
            fi
            echo "##vso[task.setvariable variable=KSC_ID_TOKEN;issecret=true]$ACCESS_TOKEN"

      - task: Bash@3
        displayName: Build libpkcs11ks from source (Docker)
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            SRC_DIR=/home/cryptera/libpkcs11ks-src
            OUT_DIR=/home/cryptera/scripts
            rm -rf "$SRC_DIR"
            git clone --depth=1 https://github.com/cryptera-device-security/libpkcs11ks.git "$SRC_DIR"
            cd "$SRC_DIR"
            docker build -t libpkcs11ks .
            mkdir -p "$OUT_DIR"
            docker run --rm -v "$OUT_DIR:/output" libpkcs11ks
            built=$(find "$OUT_DIR" -maxdepth 1 -name 'libpkcs11ks*.so' | head -n1)
            if [ -z "$built" ]; then
              echo "libpkcs11ks.so not found after build"; exit 1
            fi
            dest="$OUT_DIR/libpkcs11ks.so"
            if [ "$built" != "$dest" ]; then
              cp -f "$built" "$dest"
            fi
            ls -l "$dest"

      - task: Bash@3
        displayName: Generate OpenSSL PKCS#11 config
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            cat >/home/cryptera/scripts/openssl-ks.cnf <<'EOF'
            openssl_conf = openssl_init

            [openssl_init]
            engines = engine_section
            providers = provider_sect

            [provider_sect]
            default = default_sect
            base = base_sect

            [default_sect]
            activate = 1

            [base_sect]
            activate = 1

            [engine_section]
            pkcs11 = pkcs11_section

            [pkcs11_section]
            engine_id = pkcs11
            dynamic_path = /usr/lib/x86_64-linux-gnu/engines-3/libpkcs11.so
            MODULE_PATH = /home/cryptera/scripts/libpkcs11ks.so
            init = 0
            EOF
            echo "##vso[task.setvariable variable=OPENSSL_CONF;issecret=false]/home/cryptera/scripts/openssl-ks.cnf"

      - task: Bash@3
        displayName: Create Keyserver operation
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          OPENSSL_CONF: $(OPENSSL_CONF)
          KSC_API_SERVER: $(KEYSERVER)
          KSC_OPERATION_DESCRIPTION: $(OP_DESCRIPTION)
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            : "${KSC_ID_TOKEN:?Missing KSC_ID_TOKEN}"
            export OPENSSL_CONF
            export KSC_API_SERVER
            export KSC_OPERATION_DESCRIPTION
            export KSC_OPERATION_ID=$(
              curl -sS --tlsv1.3 \
                -X POST "$KSC_API_SERVER/api/operations" \
                -H "Authorization: Bearer $KSC_ID_TOKEN" \
                -H "Content-Type: application/json" \
                -d "{
                      \"validms\": 60000,
                      \"description\": \"$KSC_OPERATION_DESCRIPTION\",
                      \"new-keyusagerestrictions\": [
                        { \"keyid\": \"$(KEYID)\", \"maxusagecount\": 1 }
                      ]
                    }" | jq -r .id
            )
            if [ -z "$KSC_OPERATION_ID" ] || [ "$KSC_OPERATION_ID" = "null" ]; then
              echo "Failed to create operation"; exit 1
            fi
            echo "##vso[task.setvariable variable=KSC_OPERATION_ID;issecret=false]$KSC_OPERATION_ID"
            echo "Operation ID: $KSC_OPERATION_ID"

      - task: Bash@3
        name: WaitForApproval
        displayName: "Wait for signing approval"
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          KSC_OPERATION_ID: $(KSC_OPERATION_ID)
          KSC_API_SERVER: $(KEYSERVER)
        inputs:
          targetType: inline
          script: |
            for i in {1..12}; do
              STATUS=$(curl -sS --tlsv1.3 \
                "$(KEYSERVER)/api/operations/$(KSC_OPERATION_ID)" \
                -H "Authorization: Bearer $(KSC_ID_TOKEN)" | jq -r .approved)

              echo "Attempt $i - Approved: $STATUS"
              [ "$STATUS" = "true" ] && exit 0
              sleep 10
            done

            echo "Approval timeout"
            exit 1




      - task: Bash@3
        displayName: Sign test file with OpenSSL + PKCS#11
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          KSC_OPERATION_ID: $(KSC_OPERATION_ID)
          OPENSSL_CONF: $(OPENSSL_CONF)
          KSC_API_SERVER: $(KEYSERVER)
          KSC_OPERATION_DESCRIPTION: $(OP_DESCRIPTION)
          KSC_HTTP_CLIENT_TIMEOUT: 15s
        inputs:
          targetType: inline
          script: |
            set -euo pipefail
            : "${KSC_ID_TOKEN:?Missing KSC_ID_TOKEN}"
            : "${KSC_OPERATION_ID:?Missing KSC_OPERATION_ID}"
            export OPENSSL_CONF
            export KSC_API_SERVER
            export KSC_OPERATION_DESCRIPTION
            export KSC_OPERATION_ID
            echo "Test" > test.txt
            openssl dgst \
              -engine pkcs11 \
              -keyform engine \
              -sign "pkcs11:object=$(KEYID)" \
              $(HASH_ALGORITHM) \
              -out test.txt.sig \
              test.txt
            echo "Signature written to test.txt.sig"

      - task: Bash@3
        name: GetCertificate
        displayName: "Download certificate for signing key (for validation)"
        env:
          KSC_ID_TOKEN: $(KSC_ID_TOKEN)
          KSC_API_SERVER: $(KEYSERVER)
        inputs:
          targetType: inline
          script: |
            set -Eeuo pipefail
            : "${KSC_ID_TOKEN:?Missing KSC_ID_TOKEN}"
            : "${KSC_API_SERVER:?Missing KSC_API_SERVER}"

            curl --fail-with-body -sS --tlsv1.3 \
              -H "Authorization: Bearer $KSC_ID_TOKEN" \
              -H "Accept: application/json" \
              "$KSC_API_SERVER/api/keys/$(KEYID)" \
              | jq -er '.certificate' > certificate.pem

            test -s certificate.pem
            echo "Saved certificate to certificate.pem"


      - task: Bash@3
        displayName: "Validate signature using certificate"
        inputs:
          targetType: inline
          script: |
            set -Eeuo pipefail

            test -s certificate.pem
            test -s test.txt
            test -s test.txt.sig

            # Extract pubkey from cert
            openssl x509 -in certificate.pem -pubkey -noout > pubkey.pem
            test -s pubkey.pem

            # Verify signature
            openssl dgst \
              -verify pubkey.pem \
              $(HASH_ALGORITHM) \
              -signature test.txt.sig \
              test.txt

            echo "Signature verification OK"



      - task: PublishBuildArtifacts@1
        displayName: Publish signature
        inputs:
          PathtoPublish: "$(System.DefaultWorkingDirectory)"
          ArtifactName: signed-output
          publishLocation: "Container"

Summary

In this guide you:

  • Configured Azure DevOps to authenticate with the Keyserver via WIF
  • Obtained a JWT access token inside the pipeline
  • Built Custom PKCS#11 Keyserver library
  • Hashed your artifact
  • Created an operation request
  • Waited for approval (if required)
  • Performed a signing request
  • Verified the signature

This enables fully automated, secure firmware or release artifact signing inside Azure DevOps.