The Problem: Signing Keys as CI Secrets
Most CI/CD pipelines that sign code store the signing private key directly as a CI secret — a base64-encoded blob in GitHub Actions secrets, GitLab CI variables, or a Jenkins credential store. This is fundamentally broken.
When the private key lives as a CI secret, a single leak means game over. An attacker who exfiltrates the secret can sign any artifact, anywhere, forever. They do not need access to your infrastructure. They do not need to compromise your build server. They have the key itself.
This is not a theoretical risk. Real-world supply chain attacks have demonstrated the consequences:
- SolarWinds (2020) — Attackers compromised the build pipeline and signed malicious updates with the legitimate signing key. 18,000 organizations installed the backdoored update because the signature was valid.
- Codecov (2021) — A compromised CI script exfiltrated environment variables — including secrets and credentials — from thousands of CI pipelines.
- 3CX (2023) — A supply chain attack resulted in a legitimately signed desktop application distributing malware to millions of users.
The common thread: once a signing key is exfiltrable, the entire trust chain collapses. There is no hardware boundary. There is no second factor. There is nothing stopping lateral movement from one compromised CI job to every artifact you have ever shipped.
Key insight: If your signing key can be copied out of your CI environment, your code signing provides a false sense of security. You need a solution where the key physically cannot leave the hardware.
The Architecture: TPM-Enforced Signing
TPM HSM solves this by keeping the signing private key inside a TPM 2.0 chip. The key is imported into the TPM using client-side wrapping (RSA-OAEP + AES-128-CFB) and can never be extracted — not by the server, not by an administrator, not by anyone. The TPM hardware enforces this.
The CI pipeline authenticates using a file key — an ECDSA P-256 key that is encrypted at rest with scrypt + AES-256-CBC. The CI runner holds the password to unlock this file key as a CI secret. The unlocked file key is then used to satisfy the TPM's PolicySigned policy, which is the TPM's hardware-enforced authorization gate.
Here is how the signing flow works:
The two-phase protocol (PrepareSign / Sign) is critical. The TPM generates a fresh nonce for each signing session. The file key must sign this nonce to prove liveness — replay attacks are impossible because each nonce is unique and bound to the session.
Key properties: The private signing key never leaves the TPM. The password alone is useless without the TPM server. The TPM server alone cannot sign without a valid policy signature from the file key.
Security Model: Threat Analysis
The strength of this architecture comes from separation of secrets across multiple trust boundaries. Let us walk through what happens when each component is compromised.
| Scenario | Traditional (Key in CI) | TPM HSM |
|---|---|---|
| CI secret (password) leaks | Full compromise — attacker has the signing key | Safe — password alone cannot sign. Attacker also needs access to the TPM server and the encrypted file key file |
| Server is compromised | N/A (key not on server) | Safe — TPM enforces PolicySigned. Attacker cannot sign without the correct policy signature from the file key |
| Both CI secret + server compromised | Full compromise | Safe — attacker still needs the encrypted file key file (stored on the CI runner, not the server) |
| All three factors compromised | Full compromise | Compromised — but attacker needed to breach three separate systems |
With a traditional CI secret approach, one leak is all it takes. With TPM HSM, an attacker must simultaneously compromise the CI secret store, the TPM HSM server, and the file on disk where the encrypted file key is stored. Each of these lives in a different trust domain.
Step-by-Step Setup
Step 1: Generate a File Key
On your workstation, generate a password-protected ECDSA P-256 key in the TPM HSM local vault. This key will act as the policy signer for CI operations.
python client.py keygen ci-signer -a ecdsa-p256
# Enter a strong password when prompted
# Generated ecdsa-p256 key: ~/.tpm-hsm/keys/ci-signer.pem
# Public key: ~/.tpm-hsm/keys/ci-signer.pub.pem
The private key is stored encrypted with scrypt + AES-256-CBC. The password you choose here will become the CI secret.
Step 2: Import Your Signing Key into the TPM
Import your code signing private key into the TPM, using the file key's public key as the initial trusted signer. The client wraps the key locally — the plaintext private key never touches the network.
python client.py -s tpm-server:50051 import code-signing-key \
~/.tpm-hsm/keys/ci-signer.pub.pem \
--vault-key ci-signer
# Imported: key_id=a1b2c3d4-... algorithm=ecdsa-p256
Save the key_id — your CI pipeline will reference this UUID.
Step 3: Register the File Key as a Trusted Signer
The file key was already set as the initial trusted key during import. If you need to add additional trusted signers later (for example, a human with a YubiKey for release signing), use:
python client.py -s tpm-server:50051 add-trusted \
a1b2c3d4-... \
~/.tpm-hsm/keys/another-signer.pub.pem \
~/.tpm-hsm/keys/ci-signer.pem \
~/.tpm-hsm/keys/ci-signer.pub.pem
Step 4: Deploy the Encrypted File Key to the CI Server
Copy the encrypted file key PEM file to your CI runner. This file is encrypted at rest — without the password, it is just ciphertext. Store it at a known path on the build machine or as a CI file secret.
# Copy to CI runner (example: SCP to a self-hosted runner)
scp ~/.tpm-hsm/keys/ci-signer.pem ci-runner:/opt/signing/ci-signer.pem
scp ~/.tpm-hsm/keys/ci-signer.pub.pem ci-runner:/opt/signing/ci-signer.pub.pem
Step 5: Add the Password as a CI Secret
Store the file key password as SIGNING_KEY_PASSWORD in your CI platform's secret management:
- GitHub Actions: Settings → Secrets → Actions → New repository secret
- GitLab CI: Settings → CI/CD → Variables (masked, protected)
- Jenkins: Credentials → System → Global credentials
Step 6: Sign Artifacts in Your Pipeline
In your CI pipeline, use the TPM HSM client to unlock the file key with the password and request a signature from the TPM server.
# Sign a build artifact
python client.py -s tpm-server:50051 \
--password "$SIGNING_KEY_PASSWORD" \
sign a1b2c3d4-... \
./dist/myapp-v1.0.0.tar.gz \
--vault-key ci-signer \
--vault-dir /opt/signing \
-o ./dist/myapp-v1.0.0.tar.gz.sig
Example: GitHub Actions Workflow
# .github/workflows/release.yml
name: Build, Sign & Publish
on:
push:
tags: ['v*']
jobs:
release:
runs-on: self-hosted # Runner with file key + network to TPM server
steps:
- uses: actions/checkout@v4
- name: Build
run: |
make build
sha256sum ./dist/*.tar.gz > ./dist/checksums.txt
- name: Sign with TPM HSM
env:
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
TPM_HSM_SERVER: tpm-server.internal:50051
TPM_KEY_ID: a1b2c3d4-5678-9abc-def0-123456789abc
run: |
python client.py -s "$TPM_HSM_SERVER" \
--password "$SIGNING_KEY_PASSWORD" \
sign "$TPM_KEY_ID" \
./dist/myapp-${GITHUB_REF_NAME}.tar.gz \
--vault-key ci-signer \
--vault-dir /opt/signing \
-o ./dist/myapp-${GITHUB_REF_NAME}.tar.gz.sig
- name: Verify signature
run: |
openssl dgst -sha256 -verify /opt/signing/signing-key.pub.pem \
-signature ./dist/myapp-${GITHUB_REF_NAME}.tar.gz.sig \
./dist/myapp-${GITHUB_REF_NAME}.tar.gz
- name: Publish release
uses: softprops/action-gh-release@v2
with:
files: |
./dist/*.tar.gz
./dist/*.sig
./dist/checksums.txt
Example: GitLab CI Pipeline
# .gitlab-ci.yml
stages:
- build
- sign
- publish
build:
stage: build
script:
- make build
- sha256sum ./dist/*.tar.gz > ./dist/checksums.txt
artifacts:
paths:
- dist/
sign:
stage: sign
tags:
- signing-runner # Self-hosted runner with file key access
variables:
TPM_HSM_SERVER: tpm-server.internal:50051
TPM_KEY_ID: a1b2c3d4-5678-9abc-def0-123456789abc
script:
- python client.py -s "$TPM_HSM_SERVER"
--password "$SIGNING_KEY_PASSWORD"
sign "$TPM_KEY_ID"
./dist/myapp-${CI_COMMIT_TAG}.tar.gz
--vault-key ci-signer
--vault-dir /opt/signing
-o ./dist/myapp-${CI_COMMIT_TAG}.tar.gz.sig
artifacts:
paths:
- dist/
publish:
stage: publish
script:
- |
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file ./dist/myapp-${CI_COMMIT_TAG}.tar.gz \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/myapp/${CI_COMMIT_TAG}/myapp-${CI_COMMIT_TAG}.tar.gz"
Multi-Party Signing for High-Security Releases
For production releases, a single CI pipeline should not be the only party that can sign. If the pipeline is compromised, it could sign a malicious artifact. TPM HSM supports multi-party threshold signing (M-of-N) to address this.
Configure your signing key to require approval from multiple parties. For example, a 2-of-3 policy where the CI pipeline is one signer, but a human operator with a YubiKey must also approve before the TPM will sign:
- CI file key — automated, provides the first signature
- Release engineer's YubiKey — physical presence required, provides the second signature
- Security team member's YubiKey — backup signer if the release engineer is unavailable
This prevents a compromised CI pipeline from unilaterally signing releases. Even if an attacker has full control of the build infrastructure, they cannot produce a valid signature without physical access to a YubiKey. The TPM enforces the quorum requirement in hardware — it cannot be bypassed by software.
Practical workflow: CI builds and tests automatically. When a release tag is pushed, CI creates a signing request. A release engineer reviews the build, plugs in their YubiKey, and co-signs. Only then does the TPM produce the final signature.
Verification: Validating Signatures
Consumers of your signed artifacts do not need access to the TPM. Verification uses standard cryptographic operations with the public key.
The signing key's public key is available through the TPM HSM key registry — a server-side database of registered public keys with verification certificates that create a chain of trust. Anyone can query the registry or download the public key directly.
# Verify a signature with OpenSSL
openssl dgst -sha256 -verify signing-key.pub.pem \
-signature myapp-v1.0.0.tar.gz.sig \
myapp-v1.0.0.tar.gz
# Output: Verified OK
For stronger assurance, the key registry supports verification certificates — one key can vouch for another by signing its public key. This creates a web of trust where consumers can verify not just the signature, but the provenance of the signing key itself. All verification signatures are validated client-side — the server is a dumb store that cannot forge attestations.