Code Signing Service
Sign binaries, archives, commits and container images with a non-exportable key held in your vault or HSM, under approval and with a complete journal.
Overview
A code signing application binds one non-exportable vault key, optionally a code signing certificate chain, and optionally an approval policy. Build machines and developers send a digest or a small payload to the signing API; the vault signs it; the signature goes back to the standard tool, which embeds it. The key never leaves the vault, every signature can require M-of-N approval, and every signature is written to the application's journal.
What is supported
| Use | Tool | How it signs |
|---|---|---|
| Authenticode (EXE, DLL, MSI, CAB, …) | Microsoft SignTool | signtool sign /dg writes the digest, the DuoKey client has the vault sign it, signtool sign /di embeds the signature |
| Java archives (JAR) | JDK JAR signing engine, through the DuoKey JAR signer; verify with jarsigner -verify | The JDK builds the JAR signature block; its signature operation is performed by DuoKey |
| Git commits and tags | Git SSH signing (gpg.format=ssh) with the DuoKey client as gpg.ssh.program | OpenSSH signature made with the application key |
| Container images | cosign generate / cosign attach signature / cosign verify --key | The signature payload is signed through the DuoKey client |
| Any detached signature | Any tool that can sign a digest or a small file over HTTPS | POST /api/apps/{id}/code-sign/sign |
| Kernel modules, Office VBA macros | — | Application types exist and use the same signing API; no tool integration is provided |
Not supported: Notation (Notary Project) signing, and local PKCS#11 or Windows CNG providers for code signing. Tools integrate through the remote signing API and the DuoKey reference clients: dkesign.py (Python 3.8+, standard library only) and DuoKeyJarSigner.java (JDK 17+, no dependency), available from DuoKey support.
Security model
- The key stays in the vault. The bound key must be RSA 2048/4096 or EC P-256/P-384, have the sign capability and have been created non-exportable. Exportability is one-way (a non-exportable key can never be made exportable), and every key-export path refuses it.
- No way around the application. The generic key-signing API refuses a key bound to a code signing application, and one key backs at most one application per tenant, so a second application without approval cannot be pointed at the same key.
- Approval. An application can require every signature to be approved under a quorum policy that enforces dual control (threshold of 2 or more, or the requester barred from approving). The approval is bound to the application, the digest, the algorithm and the artefact name, usable once and by the requester only. Switching approval off, moving it to another policy or deleting the application itself needs an approval.
- Journal. Each signature is journaled before the vault is called and completed after, so no signature leaves without its record. Signatures, application changes and certificate requests are also written to the activity log.
- Permissions. Signing and verification need
Operations.Apps.CodeSignAccess; reading the configuration and journal needsOperations.Apps.Read. Lookups are tenant- and organizational-unit-scoped. Crypto policies apply to every signature. - Credentials. Clients authenticate with
Authorization: Bearer <token>: a machine API key (stored only as a hash) or a session token. The key is never part of a URL and never logged.
Set up an application
Create the key
Create an RSA or EC key in a vault with exportable off and the Sign operation enabled. For Authenticode, choose a vault that signs precomputed digests (see Vault support).
Create the application
In the application wizard choose SignTool, JAR signing, Git commit signing or container signing, and select the key.
Add a certificate (Authenticode and JAR)
Generate a certificate request signed by the vault key (CSR on the application page), have a CA issue a code signing certificate, and store the chain (leaf first). The chain is refused if the leaf does not certify the key, has expired, or has an extended key usage without code signing. Git and container signatures do not need a certificate.
Require approval (optional)
Set requires approval and a dual-control quorum policy on the application page.
Create a machine key for CI
Create an API key for a service user that holds only Operations.Apps.CodeSignAccess and Operations.Apps.Read.
The CA/Browser Forum code signing requirements require publicly trusted code signing keys to be held in hardware. Use an HSM-backed vault and follow your CA's key-attestation process; Cockpit does not produce key attestation statements for code signing keys.
Vault support
Signing a payload (data_b64) works on every vault that signs. Signing a digest computed by the caller (digest_b64) — which SignTool's /dg flow requires — is available on the Software Vault, Azure Key Vault / Managed HSM and Google Cloud KMS; other vaults answer 400 and ask for the payload instead. Authenticode therefore needs one of these three vaults. EC P-256 keys sign with SHA-256, EC P-384 keys with SHA-384.
Recipes
Common environment for the DuoKey client (in CI, map the secret explicitly and never echo it):
export DKE_URL=https://cockpit.example.com
export DKE_CODESIGN_APP=<application uuid>
export DKE_API_KEY=<machine API key> # secret
export DKE_APPROVAL_WAIT=0 # seconds to wait for approvers; 0 = exit 3 if pending
python3 dkesign.py config --out-dir signing # public key, OpenSSH key and certificate chain
Authenticode with SignTool
rem 1. digest of the file's to-be-signed content
signtool sign /dg out /fd SHA256 /f signing\leaf.cer app.exe
rem 2. vault signature of that digest
python dkesign.py sign-digest --digest-file out\app.exe.dig --out out\app.exe.dig.signed --artifact app.exe
rem 3. embed it
signtool sign /di out app.exe
rem 4. optional RFC 3161 timestamp from your timestamping authority
signtool timestamp /tr https://tsa.example.com /td SHA256 app.exe
signtool verify /pa /v app.exe
Use the same /fd as the client's digest algorithm. Produce the leaf in DER with openssl x509 -in signing/chain.pem -outform DER -out signing/leaf.cer. Use a signing certificate with a normal validity period: SignTool does not select certificates with an extreme expiry date.
JAR signing
java DuoKeyJarSigner.java app.jar app-signed.jar SHA256
jarsigner -verify -verbose -certs app-signed.jar
The JDK's own JAR signing engine builds the signature; the signer name in META-INF is DUOKEY. Timestamping is not wired in the reference JAR signer.
Git commit and tag signing
python3 dkesign.py config --out-dir ~/.config/duokey-signing
git config --global gpg.format ssh
git config --global user.signingkey "key::$(cat ~/.config/duokey-signing/signing-key.pub)"
git config --global gpg.ssh.program /usr/local/bin/dkesign-ssh
git config --global commit.gpgsign true # and/or tag.gpgSign true
/usr/local/bin/dkesign-ssh:
#!/bin/sh
exec python3 /opt/duokey/dkesign.py ssh-keygen "$@"
Verify with an allowed-signers file (git config gpg.ssh.allowedSignersFile allowed_signers, then git verify-commit HEAD). Register the same public key as a signing key of the committer on your Git hosting service to have commits shown as verified.
Container images with cosign
cosign generate registry.example.com/app@sha256:<digest> > payload.json
python3 dkesign.py sign-file --in payload.json --out payload.sig \
--artifact registry.example.com/app@sha256:<digest>
cosign attach signature --payload payload.json --signature payload.sig \
registry.example.com/app@sha256:<digest>
cosign verify --key signing/public-key.pem --insecure-ignore-tlog=true \
registry.example.com/app@sha256:<digest>
Sign images by digest, not by tag. --insecure-ignore-tlog=true is needed because the signature is not uploaded to a transparency log; verification relies on the application's public key.
CI pipelines
The machine API key is a secret variable mapped into the step's environment. The client sends it in the Authorization header only: it never appears in a URL, a command line or a log. With approval required, DKE_APPROVAL_WAIT makes the client re-send the request every 15 seconds until the approvers decide (or the wait ends, exit status 3). The approval is bound to the digest, so the artefact signed is exactly the one the approvers saw.
The examples below show the pattern; adapt paths and build steps to your pipeline.
Azure Pipelines
# Variable group "duokey-signing": DKE_URL, DKE_CODESIGN_APP (plain),
# DKE_API_KEY (secret, lock icon).
variables:
- group: duokey-signing
jobs:
- job: sign_windows
pool: { vmImage: windows-latest }
steps:
- script: dotnet publish -c Release -o out
displayName: Build
- pwsh: |
python tools/dkesign.py config --out-dir signing
openssl x509 -in signing/chain.pem -outform DER -out signing/leaf.cer
signtool sign /dg dig /fd SHA256 /f signing/leaf.cer out/app.exe
python tools/dkesign.py sign-digest --digest-file dig/app.exe.dig --out dig/app.exe.dig.signed --artifact "app.exe $(Build.BuildNumber)"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
signtool sign /di dig out/app.exe
signtool verify /pa out/app.exe
displayName: Sign with DuoKey
env:
DKE_URL: $(DKE_URL)
DKE_CODESIGN_APP: $(DKE_CODESIGN_APP)
DKE_API_KEY: $(DKE_API_KEY) # secret variables are only visible when mapped
DKE_APPROVAL_WAIT: "1800" # wait up to 30 min for approvers
GitHub Actions
on: { push: { tags: ["v*"] } }
permissions: { contents: read }
jobs:
sign-jar:
runs-on: ubuntu-latest
environment: release # protect the secret with environment reviewers if wanted
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "21" }
- run: ./gradlew jar
- name: Sign with DuoKey
env:
DKE_URL: ${{ vars.DKE_URL }}
DKE_CODESIGN_APP: ${{ vars.DKE_CODESIGN_APP }}
DKE_API_KEY: ${{ secrets.DKE_API_KEY }}
DKE_APPROVAL_WAIT: "1800"
run: |
java tools/DuoKeyJarSigner.java build/libs/app.jar build/libs/app-signed.jar SHA256
jarsigner -verify build/libs/app-signed.jar
sign-image:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: "${{ github.actor }}", password: "${{ secrets.GITHUB_TOKEN }}" }
- id: build
uses: docker/build-push-action@v6
with: { push: true, tags: "ghcr.io/${{ github.repository }}:${{ github.ref_name }}" }
- name: Sign image
env:
DKE_URL: ${{ vars.DKE_URL }}
DKE_CODESIGN_APP: ${{ vars.DKE_CODESIGN_APP }}
DKE_API_KEY: ${{ secrets.DKE_API_KEY }}
IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
run: |
cosign generate "$IMAGE" > payload.json
python3 tools/dkesign.py sign-file --in payload.json --out payload.sig --artifact "$IMAGE"
cosign attach signature --payload payload.json --signature payload.sig "$IMAGE"
Signing API
All routes are under /api and authenticated with Authorization: Bearer.
| Route | Purpose |
|---|---|
POST /apps/code-sign, GET /apps/code-sign, GET / PUT / DELETE /apps/code-sign/{id} | Create, list, read, update (name, certificate chain, approval), delete. Weakening approval or deleting answers 202 until approved |
POST /apps/{id}/code-sign/enable, /disable | A disabled application refuses to sign |
GET /apps/{id}/code-sign/config | Public key (PEM and OpenSSH), certificate chain, digest algorithms. Nothing secret |
POST /apps/{id}/code-sign/sign | Sign digest_b64 (caller-hashed) or data_b64 (at most 1 MiB), with digest_algorithm (sha256, sha384, sha512), rsa_padding (pkcs1 or pss) and artifact_name. Answers 202 with a request_id while approval is pending |
POST /apps/{id}/code-sign/verify | Verify a signature with the application's public key |
POST /apps/{id}/code-sign/csr | PKCS#10 request for the key, signed in the vault |
GET /apps/{id}/code-sign/health | Enabled, key non-exportable and usable, vault reachable, certificate valid, approval policy still dual control |
GET /apps/{id}/code-sign/operations | Signing journal |
When a signature is pending approval, approvers decide under Quorum requests; the requester then sends the same request again and receives the signature. Repeating the request while it is pending returns the same request_id.
Limits
- Notation, local PKCS#11 / CNG providers for code signing, and key attestation statements are not provided.
- Authenticode needs a vault that signs precomputed digests (Software Vault, Azure Key Vault / Managed HSM, Google Cloud KMS).
- The reference JAR signer does not add an RFC 3161 timestamp; use SignTool's
timestampcommand for Authenticode. - Kernel-module and Office-macro signing are available through the API only.