CI/CD Code Signing with TPM-Protected Keys

Keep your signing private key locked inside TPM hardware. Your CI pipeline signs artifacts without ever touching the key — even if CI secrets leak, the key stays safe.

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:

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:

CI Runner | [1] Unlock file key with password (password from CI secret, encrypted file key from disk) | [2] Call PrepareSign(key_id, signer_pub) | v TPM HSM Server (gRPC) | [3] TPM starts PolicySigned session, returns nonce_tpm | v CI Runner | [4] Sign nonce with file key: SHA-256(nonce_tpm || expiration=0) | [5] Call Sign(session_id, digest, policy_signature) | v TPM HSM Server | [6] TPM verifies PolicySigned: - Is this signer trusted for this key? - Is the nonce signature valid? - PolicyAuthorize approval ticket | [7] TPM signs the artifact digest (private key NEVER leaves the chip) | v CI Runner | [8] Receives signature, attaches to artifact

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:

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:

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.

Frequently Asked Questions

Can the CI pipeline extract the private signing key?

No. The signing private key is stored inside the TPM chip and never leaves it in plaintext. The TPM hardware enforces this — there is no API to export the key, and the silicon is designed to resist physical extraction. The CI pipeline can request signatures (if it can satisfy the PolicySigned policy), but it can never read, copy, or exfiltrate the key material.

What if our CI secret leaks?

The CI secret is only a password that unlocks an encrypted file key on disk. The password alone cannot sign anything. An attacker would also need (1) network access to the TPM HSM server, and (2) the encrypted file key file from the CI runner's filesystem. Without all three factors, the TPM will refuse to sign. Compare this to the traditional approach where leaking the CI secret means the attacker has the actual signing key and can sign from anywhere.

How is this different from Sigstore/Cosign?

Sigstore uses ephemeral keys tied to OIDC identity tokens. A new key pair is generated for each signing event, and the private key is immediately discarded. Sigstore answers the question "who signed this?" by linking signatures to verified identities. TPM HSM uses persistent hardware-bound keys with explicit authorization policies enforced by the TPM chip. It answers the question "was this signed by the authorized key, and did the authorized parties approve it?" They are complementary: use TPM HSM for the signing operation with hardware-enforced access control, and Sigstore for transparency logging and identity-based verification.

Can we use this with GitHub Actions / GitLab CI / Jenkins?

Yes. Any CI system that can run a Python script and reach the TPM HSM server over gRPC works. The TPM HSM client is a standard Python CLI tool with no special hardware requirements on the CI runner side. You need: (1) Python 3.10+ with the cryptography and grpcio packages, (2) network connectivity from the CI runner to the TPM HSM server (port 50051 with TLS), and (3) the encrypted file key file accessible to the runner. Self-hosted runners are recommended for the persistent file key storage, but you can also inject the encrypted file key as a CI file secret.

Protect Your Signing Pipeline

Stop storing signing keys as CI secrets. TPM HSM gives you hardware-enforced code signing that survives secret leaks, server compromises, and insider threats.

View on GitHub Read the Docs