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
- Assign roles (e.g.,
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:
- Builds LibPKCS#11 Keyserver library from sources off Github
- Authenticates
- Hashes a test file
- Creates an operation
- Waits for approval
- Requests the signature
- 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"
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.