Post-Quantum Artifact Signing in CI/CD: Migrating cosign and Sigstore to ML-DSA

Post-Quantum Artifact Signing in CI/CD: Migrating cosign and Sigstore to ML-DSA

Problem

Every container image signature, SLSA provenance attestation, and in-toto link file your pipeline produces today carries an ECDSA-P256 or RSA signature. That signature is safe against every adversary that exists right now. The problem is tomorrow: a cryptographically-relevant quantum computer running Shor’s algorithm can break ECDSA and RSA completely. When that capability arrives — estimates range from 2030 to the late 2030s — every classical signature ever produced can be forged retroactively.

This is the “harvest-now, verify-later” (HNVL) threat: an adversary collects signed artifacts today, stores them, and waits. Once quantum capability arrives they can:

  1. Forge new signatures on malicious artifacts and claim those artifacts were signed by your legitimate build pipeline — a forged signature on a backdoored image that appears identical to a known-good release.
  2. Repudiate legitimate signatures — claim a signed artifact was signed by an attacker, breaking non-repudiation and potentially voiding regulatory compliance records.
  3. Replay modified provenance — strip an SLSA attestation, alter the build metadata to hide tampering, and re-sign with a forged key.

The threat is particularly severe for supply chain signing because artifact retention is long. A financial institution archiving container images for audit purposes, a government agency retaining signed software deployments for regulatory review, or a healthcare company preserving signed firmware for medical device compliance — all of these contexts involve artifacts with 7 to 20 year retention requirements. Those artifacts need signatures that remain unforgeable beyond the quantum transition.

What is currently at risk in the Sigstore ecosystem:

  • cosign default key type: ECDSA-P256. Every cosign sign invocation without a custom key type produces a quantum-vulnerable signature.
  • Sigstore Fulcio-issued certificates: Fulcio currently issues ECDSA-P256 certificates for keyless signing workflows. The short-lived certificate itself is ECDSA, and the signature cosign produces using that identity is ECDSA.
  • Rekor log entries: The Rekor transparency log records signature metadata; the log itself uses RFC 3161 timestamps and Trillian Merkle trees, which have their own algorithm considerations.
  • SLSA provenance attestations: Generated by slsa-github-generator and similar tools; signed with ECDSA via Sigstore keyless or DSSE with ECDSA keys.
  • in-toto link files and layouts: Signed with ECDSA or RSA depending on the implementation. The in-toto specification has active ITE (in-toto Enhancement) proposals for ML-DSA support.

Sigstore PQC roadmap: Sigstore’s Technical Advisory Committee has approved ML-DSA (formally NIST FIPS 204, formerly known as CRYSTALS-Dilithium) as the target algorithm for post-quantum signing. The planned timeline for Fulcio to issue ML-DSA certificates is 2025–2026, with Rekor updates to accept ML-DSA signed entries following shortly after. The transition will use a hybrid approach — Fulcio will issue certificates covering both an ECDSA key and an ML-DSA key during the transition window, and cosign will produce and verify both signatures.

Until native Sigstore PQC support is complete, this guide implements a practical hybrid signing strategy: every artifact receives two independent signatures — one ECDSA (for current verifiers) and one ML-DSA (for future-proof verification). Both signatures are stored as OCI referrer artifacts alongside the image in the registry.

Threat Model

Primary adversary — Future quantum actor targeting supply chain archives:

An adversary with a cryptographically-relevant quantum computer targets software artifacts archived with long retention periods. They do not need access to the build pipeline. They recover a corpus of classically-signed container images, compute the ECDSA private key from the public key embedded in any signature, and can now produce valid-looking ECDSA signatures on arbitrary content. They create a modified version of a critical base image (e.g., a PostgreSQL operator or a mutual TLS sidecar), forge the ECDSA signature, and inject it into a registry or artifact store. Operators performing a compliance-mandated audit of historical deployments have no way to distinguish the forged artifact from the legitimate one.

Secondary adversary — Insider with access to classical signing keys:

A malicious insider who exfiltrates the ECDSA signing key can forge signatures today. Adding ML-DSA signing with a separately protected key (hardware token, HSM) limits blast radius: compromising either key alone is insufficient for an adversary who needs to fool a dual-signature verifier.

Long-term audit requirements:

Sectors where this is an active compliance requirement, not a theoretical concern:

  • Financial services: DORA (Digital Operational Resilience Act) and SEC Cybersecurity rules require demonstrable software supply chain integrity; artifact retention requirements commonly reach 7–10 years.
  • Healthcare: FDA guidance on medical device software (SBOM and provenance) and HIPAA audit requirements — firmware signed for a medical device implanted today may need verifiable provenance for its operational lifetime.
  • Government / Defense: NIST SP 800-208 and CISA post-quantum guidance require agencies to inventory and migrate cryptographic algorithms; signed software artifacts are in scope.

Regulatory deadline context: NIST finalized FIPS 204 (ML-DSA) in August 2024. US federal agencies are required to begin PQC migration by 2025 and complete it for new systems by 2030. Financial regulators in the EU and UK are publishing similar timelines. Planning the migration now and implementing hybrid signing immediately is the minimum prudent response.

Configuration and Implementation

Step 1: Assess the Current Signing State

Before changing anything, audit what algorithms are in use across your registry.

# Identify where cosign stores signatures for an image (OCI referrers API)
cosign triangulate ghcr.io/myorg/myapp:v1.2.3

# Verify an existing signature and display the certificate details
cosign verify \
  --certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp:v1.2.3

# Inspect the raw signature bundle to see the key type
cosign download signature ghcr.io/myorg/myapp:v1.2.3 | \
  jq '.[0].optional.Bundle.Payload.body' | \
  base64 -d | jq '.spec.signature.publicKey.content' | \
  base64 -d | openssl x509 -noout -text | grep "Public Key Algorithm"

The output of the last command will show id-ecPublicKey for all currently-issued Fulcio certificates — this is what needs to change.

Step 2: Generate an ML-DSA-65 Signing Key

ML-DSA (FIPS 204) is not yet supported natively in cosign’s key generation. There are two approaches depending on your infrastructure:

Option A — liboqs-pkcs11 with a software HSM (development/testing):

# Install SoftHSM2 and the liboqs PKCS#11 provider
# On Debian/Ubuntu:
apt-get install softhsm2 libengine-pkcs11-openssl

# Build liboqs-pkcs11 from source (includes ML-DSA module)
git clone https://github.com/open-quantum-safe/oqs-provider.git
cd oqs-provider && cmake -B build . && cmake --build build

# Initialize a SoftHSM2 token
softhsm2-util --init-token --slot 0 --label "pqc-signing" \
  --pin 1234 --so-pin 5678

# Generate an ML-DSA-65 key in the token using OpenSSL + oqs-provider
OPENSSL_MODULES=./build/lib openssl genpkey \
  -provider oqsprovider \
  -algorithm mldsa65 \
  -out mldsa65-private.pem

# Extract the public key
OPENSSL_MODULES=./build/lib openssl pkey \
  -provider oqsprovider \
  -in mldsa65-private.pem \
  -pubout -out mldsa65-public.pem

Option B — KMS-backed ML-DSA (production):

AWS KMS, Google Cloud KMS, and HashiCorp Vault all have ML-DSA support in preview or planned for 2025–2026. For production use, generate the key in a KMS and reference it via the cosign KMS provider interface. The cosign --key flag accepts awskms://, gcpkms://, hashivault://, and custom pkcs11:// URIs.

# HashiCorp Vault Transit engine with ML-DSA (Vault 1.17+ with PQC preview)
vault write transit/keys/mldsa-signing-key type=mldsa65

# Reference the Vault key in cosign via the vault KMS provider
cosign sign --key hashivault://mldsa-signing-key ghcr.io/myorg/myapp:v1.2.3

Step 3: Implement Hybrid Dual-Signature Signing in CI/CD

This GitHub Actions workflow signs every container image with both the existing ECDSA keyless signature (for backward compatibility with current verifiers) and a new ML-DSA signature stored as a separate OCI referrer:

# .github/workflows/build-sign-hybrid.yml
name: Build and Hybrid Sign

on:
  push:
    branches: [main]
    tags: ["v*"]

permissions:
  contents: read
  id-token: write      # Required for OIDC / Sigstore keyless
  packages: write      # Required to push to GHCR

jobs:
  build-sign:
    runs-on: ubuntu-latest
    outputs:
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      - name: Build and push container image
        id: build
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

      - name: Install cosign
        uses: sigstore/cosign-installer@v3
        with:
          cosign-release: "v2.4.1"

      # --- ECDSA keyless signature (current standard, backward-compatible) ---
      - name: Sign with ECDSA keyless (Sigstore Fulcio)
        run: |
          cosign sign --yes \
            "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"
        env:
          COSIGN_EXPERIMENTAL: "1"

      # --- ML-DSA signature (post-quantum, stored as separate OCI referrer) ---
      - name: Retrieve ML-DSA signing key from Vault
        id: get-mldsa-key
        run: |
          # Authenticate to Vault using OIDC
          VAULT_TOKEN=$(vault write -field=token auth/jwt/login \
            role="ci-artifact-signer" \
            jwt="${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }}")
          echo "VAULT_TOKEN=${VAULT_TOKEN}" >> "$GITHUB_ENV"

      - name: Sign with ML-DSA key (post-quantum signature)
        run: |
          # cosign sign with KMS-backed ML-DSA key
          # The --key flag routes through the Vault Transit KMS provider
          # The signature is stored as a separate OCI referrer with
          # media type application/vnd.dev.cosign.simplesigning.v1+json
          # but with a custom annotation marking it as a PQC signature
          cosign sign --yes \
            --key "hashivault://mldsa-signing-key" \
            --annotations "signing.dev/algorithm=ML-DSA-65" \
            --annotations "signing.dev/fips=FIPS-204" \
            "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"
        env:
          VAULT_ADDR: ${{ secrets.VAULT_ADDR }}

      - name: Verify both signatures are present
        run: |
          echo "--- Verifying ECDSA keyless signature ---"
          cosign verify \
            --certificate-identity \
              "https://github.com/${{ github.repository }}/.github/workflows/build-sign-hybrid.yml@${{ github.ref }}" \
            --certificate-oidc-issuer \
              "https://token.actions.githubusercontent.com" \
            "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"

          echo "--- Verifying ML-DSA signature ---"
          cosign verify \
            --key "hashivault://mldsa-signing-key" \
            --annotations "signing.dev/algorithm=ML-DSA-65" \
            "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"

Each signature is stored as an independent OCI referrer object attached to the image manifest. Inspecting the referrers with the OCI distribution spec:

# List all referrers (signatures, attestations) for an image
crane ls "$(cosign triangulate ghcr.io/myorg/myapp@sha256:abc123)"

# The output will show two signature tags:
# sha256-abc123...sig            <- ECDSA keyless signature
# sha256-abc123...sig            <- ML-DSA signature (different digest)
# Both are stored at distinct content-addressable locations in the referrers API

Step 4: Sigstore Fulcio PQC Migration Path

The Sigstore keyless workflow depends on Fulcio issuing the signing certificate. Until Fulcio supports ML-DSA certificate issuance natively, the hybrid approach above uses an independently managed ML-DSA key alongside the Fulcio-issued ECDSA key.

Once Fulcio ML-DSA support ships, the migration has three phases:

Phase 1 (now through Fulcio ML-DSA release) — Dual signing with independent keys:

  • Fulcio issues ECDSA certificate; cosign produces ECDSA signature.
  • ML-DSA signature generated separately with a KMS or PKCS#11 key.
  • Verifiers check ECDSA signature; ML-DSA signature is stored but not yet required.

Phase 2 (after Fulcio ML-DSA release) — Hybrid certificates:

  • Fulcio issues a certificate covering both an ECDSA public key and an ML-DSA public key.
  • cosign produces a composite signature covering both algorithms.
  • Verification policy: ECDSA OR ML-DSA is sufficient (transition window).

Phase 3 (post-quantum transition complete) — ML-DSA only:

  • Fulcio stops issuing pure ECDSA certificates.
  • cosign requires ML-DSA signature for verification.
  • Old ECDSA-only signatures on archived artifacts are flagged for review.

Self-hosted Fulcio with ML-DSA (if you need to get ahead of upstream):

Organizations running a private Sigstore stack can deploy a patched Fulcio that uses an ML-DSA CA:

# Run a private Fulcio instance with a custom CA config
# The ca-config.json specifies the signing algorithm
cat > fulcio-ca-config.json << 'EOF'
{
  "ca": {
    "backend": "fileca",
    "fileca": {
      "certPath": "/etc/fulcio/mldsa-ca.pem",
      "keyPath": "/etc/fulcio/mldsa-ca-key.pem",
      "keyType": "mldsa65"
    }
  }
}
EOF

fulcio serve \
  --config-path fulcio-ca-config.json \
  --host 0.0.0.0 \
  --port 5555 \
  --ct-log-url http://ctlog:6962/sigstore

# Configure cosign to use the private Fulcio and Rekor instances
export SIGSTORE_FULCIO_URL=https://fulcio.internal.example.com
export SIGSTORE_REKOR_URL=https://rekor.internal.example.com
export SIGSTORE_ROOT_FILE=/etc/cosign/internal-root.json

Step 5: SLSA Provenance and in-toto with ML-DSA

SLSA provenance attestations use the DSSE (Dead Simple Signing Envelope) format, which wraps a payload with one or more signatures. DSSE natively supports multiple signers — this is the mechanism for dual-algorithm SLSA provenance.

The in-toto specification’s ITE-9 proposal (pending merge as of mid-2026) adds ML-DSA as a supported algorithm identifier. Until that lands, provenance attestations can carry dual signatures using the existing DSSE multi-signer support:

# Generate SLSA provenance with dual signatures using slsa-github-generator
# (this uses the experimental PQC branch pending upstream merge)
- name: Generate SLSA provenance
  uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
  with:
    image: ghcr.io/${{ github.repository }}
    digest: ${{ steps.build.outputs.digest }}
    # When ML-DSA support ships, this flag enables dual signatures:
    experimental-pqc-cosigning: true

For current workflows, generate provenance with the standard generator and add a separate ML-DSA attestation:

# Attach an ML-DSA-signed attestation to the image
# The attestation payload is identical; only the signing algorithm differs
cosign attest --yes \
  --key "hashivault://mldsa-signing-key" \
  --predicate slsa-provenance.json \
  --type slsaprovenance \
  --annotations "signing.dev/algorithm=ML-DSA-65" \
  "ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}"

Step 6: Admission Control Policy During Transition

Kyverno and policy-controller both need to be updated to understand and require ML-DSA signatures. Until both tools have native ML-DSA verification support, use a dual-policy that accepts either signature type:

# kyverno-verify-image-hybrid.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-hybrid-pqc
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-ecdsa-or-mldsa-signature
      match:
        any:
          - resources:
              kinds: [Pod]
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          # Require AT LEAST ONE of these signatures to be valid.
          # During the transition window, ECDSA is sufficient.
          # After the deadline, remove the ECDSA entry to enforce ML-DSA only.
          attestors:
            - count: 1   # At least one of the following must verify
              entries:
                # ECDSA keyless (current)
                - keyless:
                    subject: "https://github.com/myorg/*/.github/workflows/release.yml@*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: "https://rekor.sigstore.dev"
                # ML-DSA keyed (post-quantum)
                - keys:
                    kms: "hashivault://mldsa-signing-key"
                    signatureAlgorithm: mldsa65
                  annotations:
                    "signing.dev/algorithm": "ML-DSA-65"

The count: 1 semantics mean one verified signature from either the ECDSA or ML-DSA entry satisfies the policy. When the transition deadline passes, change count: 1 to count: 2 — requiring both — and then in the final phase remove the ECDSA entry entirely.

Step 7: Monitoring the Transition

Track which images in your registry have ML-DSA signatures and alert when images that should have them do not:

#!/usr/bin/env bash
# check-pqc-signing-coverage.sh
# Scans a registry namespace and reports images missing ML-DSA signatures.

REGISTRY="ghcr.io/myorg"
PQC_ANNOTATION="signing.dev/algorithm=ML-DSA-65"
DEADLINE="2027-01-01"

crane catalog "${REGISTRY}" | while read -r repo; do
  crane ls "${REGISTRY}/${repo}" | grep -v sha256 | while read -r tag; do
    IMAGE="${REGISTRY}/${repo}:${tag}"
    DIGEST=$(crane digest "${IMAGE}" 2>/dev/null) || continue

    # Check for ML-DSA signature in OCI referrers
    HAS_MLDSA=$(cosign download signature "${REGISTRY}/${repo}@${DIGEST}" 2>/dev/null | \
      jq -r '.[].optional | select(.["signing.dev/algorithm"] == "ML-DSA-65") | .["signing.dev/algorithm"]' | \
      head -1)

    if [[ -z "${HAS_MLDSA}" ]]; then
      echo "MISSING_MLDSA: ${IMAGE} (digest: ${DIGEST})"
    fi
  done
done

Integrate this script into a scheduled CI job and alert via your observability stack when the count of images missing ML-DSA signatures increases or when images past the compliance deadline lack PQC signatures.

Expected Behaviour

Artifact Type Current Signing Target Signing Transition Verification
Container image (cosign keyless) ECDSA-P256 via Fulcio ML-DSA-65 via Fulcio (hybrid cert) ECDSA OR ML-DSA accepted
Container image (keyed) ECDSA-P256 key file ML-DSA-65 key in KMS Both signatures attached; either accepted
SLSA provenance (slsa-github-generator) ECDSA via Fulcio DSSE ML-DSA via Fulcio DSSE Dual-signed DSSE envelope
in-toto link metadata ECDSA key ML-DSA key (pending ITE-9) Dual-signed link; layout requires both
Helm chart (cosign OCI) ECDSA-P256 ML-DSA-65 Both referrers stored in OCI registry
Binary release (GitHub Releases) ECDSA via Gitsign / cosign blob ML-DSA detached signature Both .sig files attached to release

Trade-offs

Signature size: ML-DSA-65 produces signatures of approximately 3,309 bytes. An ECDSA-P256 signature is 64 bytes. For a high-throughput pipeline producing hundreds of signed image pushes per hour, the increased signature payload size affects:

  • OCI registry storage (each ML-DSA signature manifest is ~10–20 KB including the envelope overhead, vs ~2 KB for ECDSA)
  • Registry API transfer time for signature verification at admission
  • Rekor transparency log storage if you are logging ML-DSA signatures

In practice, signature size is rarely the binding constraint — image layers dominate. The overhead becomes relevant only in extremely high-volume pipelines or constrained network environments.

Public key size: ML-DSA-65 public keys are 1,952 bytes vs 64 bytes for ECDSA-P256. This affects certificate size (Fulcio-issued ML-DSA certificates will be larger) and any system that embeds public keys in configurations.

Tooling maturity: As of mid-2026, native ML-DSA support in cosign, policy-controller, and Kyverno is in active development but not yet in stable releases. The configuration shown above uses the KMS provider path, which is stable, combined with annotation-based policy matching. Full first-class ML-DSA support — including cosign generate-key-pair --kdf mldsa65, Kyverno’s signatureAlgorithm: mldsa65 field, and Fulcio certificate issuance — is expected to land across the Sigstore ecosystem in the second half of 2026.

Key management complexity: The hybrid approach requires managing an additional key type (ML-DSA) in parallel with existing ECDSA infrastructure. Use a KMS or hardware HSM rather than software key files; ML-DSA private keys are 4,032 bytes and their longer storage lifetime (these keys are intended to remain valid post-quantum transition) makes HSM storage more important than for the short-lived Fulcio ECDSA certificates.

Failure Modes

Verification failures with older cosign versions: cosign versions prior to 2.3.x do not understand ML-DSA signature media types as OCI referrers and will silently ignore them during cosign verify. They will not fail — they simply skip the unknown referrer type. This means a cosign 2.2.x binary running admission verification will verify the ECDSA signature and report success, never checking the ML-DSA signature. If your policy moves to ML-DSA-only enforcement, ensure all cosign installations (admission controllers, CI verify steps, developer local tools) are on a version that supports ML-DSA verification before updating policies.

Fulcio certificate chain compatibility: When Fulcio transitions to issuing ML-DSA certificates, the root CA certificate changes. Clients that have pinned the old ECDSA root (via SIGSTORE_ROOT_FILE or TUF metadata) will reject ML-DSA-issued certificates. Coordinate the root CA rotation with a TUF update: the TUF repository at tuf.sigstore.dev will distribute the new ML-DSA root CA alongside the existing ECDSA root during the transition, and clients on cosign 2.4+ will automatically update via TUF.

Dual-signature storage overhead with large image volumes: Each ML-DSA signature stored as an OCI referrer requires a separate manifest object in the registry. Registries that implement garbage collection based on manifest count (rather than storage size) may unexpectedly prune signature referrers if the count thresholds are not adjusted. Review and increase OCI referrer retention thresholds in Harbor, JFrog Artifactory, or your registry of choice before deploying dual-signing at scale.

PKCS#11 token latency: If using a physical HSM for ML-DSA key operations, signature generation is slower than software key operations. ML-DSA-65 signing with liboqs on a PKCS#11 HSM typically takes 5–15 ms per signature (vs sub-millisecond for software ECDSA). For pipelines signing large numbers of artifacts in parallel, ensure the HSM supports concurrent operation or use a KMS with a higher request rate limit.

Policy drift during transition: The dual-policy (ECDSA OR ML-DSA) is a temporary measure. Without a hard deadline and automated enforcement, teams may leave the permissive dual-policy in place indefinitely, negating the security benefit of the ML-DSA signature. Set a calendar deadline in your policy as a comment, implement the monitoring script from Step 7 as a required CI check, and schedule a policy update to require ML-DSA signatures before the regulatory deadline for your sector.

The migration to post-quantum artifact signing is not a single switch — it is a phased transition that needs to be started before quantum computers arrive to be effective. Hybrid signing gives you the forward security of ML-DSA without breaking current verifiers, and the monitoring tooling gives you visibility into how complete your coverage is. Start with new image repositories and new signing keys; retrofit existing repositories as tooling matures.